1use std::path::PathBuf;
4
5use clap::{Parser, Subcommand};
6use serde_json::json;
7
8fn default_db() -> PathBuf {
9 dirs_next::home_dir()
10 .unwrap_or_else(|| PathBuf::from("."))
11 .join(".innate")
12 .join("personal.db")
13}
14
15#[derive(Parser)]
16#[command(name = "innate", version, about = "Self-growing knowledge layer")]
17pub struct Cli {
18 #[arg(long, global = true, env = "INNATE_DB")]
19 pub db: Option<PathBuf>,
20
21 #[command(subcommand)]
22 pub command: Commands,
23}
24
25#[derive(Subcommand)]
26pub enum Commands {
27 Recall {
29 query: String,
30 #[arg(long, default_value = "6000")]
31 budget: usize,
32 #[arg(long)]
33 top: Option<usize>,
34 #[arg(long, default_value = "text")]
35 format: String,
36 #[arg(long)]
37 include_sparks: bool,
38 #[arg(long, default_value = "false")]
40 expand_deps: String,
41 #[arg(long)]
43 allow_trim: bool,
44 #[arg(long, default_value = "off")]
46 refine_mode: String,
47 #[arg(long, default_value = "cli")]
49 source: String,
50 },
51 Record {
53 trace_id: String,
54 #[arg(long)]
55 query: Option<String>,
56 #[arg(long)]
57 outcome: Option<String>,
58 #[arg(long)]
60 used: Option<String>,
61 #[arg(long, default_value = "explicit")]
62 used_attribution: String,
63 #[arg(long)]
65 used_partial: bool,
66 #[arg(long)]
67 output: Option<String>,
68 #[arg(long)]
69 output_summary: Option<String>,
70 #[arg(long)]
71 nomination: Option<String>,
72 #[arg(long, default_value = "cli")]
73 source: String,
74 #[arg(long)]
76 feedback: Option<String>,
77 #[arg(long, default_value = "user")]
78 feedback_kind: String,
79 #[arg(long)]
80 feedback_actor: Option<String>,
81 #[arg(long)]
82 feedback_reason: Option<String>,
83 #[arg(long)]
84 task_state: Option<String>,
85 #[arg(long, default_value = "0")]
86 priority: i64,
87 },
88 Add {
90 content: String,
91 #[arg(long, default_value = "note")]
92 kind: String,
93 #[arg(long)]
94 trigger: Option<String>,
95 #[arg(long)]
96 anti_trigger: Option<String>,
97 #[arg(long, default_value = "chat")]
98 source: String,
99 #[arg(long)]
100 skill_name: Option<String>,
101 },
102 Spark {
104 content: String,
105 #[arg(long)]
106 trigger: Option<String>,
107 },
108 Evolve {
110 #[arg(long, default_value = "manual")]
111 trigger: String,
112 #[arg(long)]
114 rebuild_embeddings: bool,
115 },
116 Inspect { id: Option<String> },
118 Approve { chunk_id: String },
120 Archive {
122 chunk_id: String,
123 #[arg(long, default_value = "stale")]
124 reason: String,
125 },
126 Invalidate {
128 chunk_id: String,
129 #[arg(long, default_value = "")]
130 reason: String,
131 },
132 Restore { chunk_id: String },
134 MatureSpark { spark_id: String, to: String },
136 PromoteSpark {
138 spark_id: String,
139 #[arg(long, default_value = "note")]
140 to: String,
141 },
142 DropSpark {
144 spark_id: String,
145 #[arg(long, default_value = "")]
146 reason: String,
147 },
148 Backup {
150 #[command(subcommand)]
151 action: BackupCommands,
152 },
153 Install,
155 Uninstall {
157 #[arg(long, short = 'y')]
159 yes: bool,
160 #[arg(long)]
162 purge_data: bool,
163 },
164 Migrate,
166 Upgrade {
168 #[arg(long, value_name = "VERSION")]
170 version: Option<String>,
171 #[arg(long)]
173 check: bool,
174 },
175 Daemon {
177 #[command(subcommand)]
178 action: DaemonCommands,
179 },
180 Mcp,
182 Hook {
184 #[command(subcommand)]
185 action: HookCommands,
186 },
187}
188
189#[derive(Subcommand)]
190pub enum BackupCommands {
191 Run {
193 #[arg(long)]
195 force: bool,
196 },
197 Status,
199 List,
201 Prune,
203}
204
205#[derive(Subcommand)]
206pub enum HookCommands {
207 Stop,
209}
210
211#[derive(Subcommand)]
212pub enum DaemonCommands {
213 Start {
215 #[arg(long = "watch", value_name = "LOG_DIR")]
216 watch: Vec<std::path::PathBuf>,
217 #[arg(long, value_name = "PATH")]
218 pid_file: Option<std::path::PathBuf>,
219 #[arg(long, value_name = "PATH")]
220 state_db: Option<std::path::PathBuf>,
221 #[arg(long, value_name = "PATH")]
222 log_file: Option<std::path::PathBuf>,
223 },
224 Stop {
226 #[arg(long, value_name = "PATH")]
227 pid_file: Option<std::path::PathBuf>,
228 },
229 Status {
231 #[arg(long, value_name = "PATH")]
232 state_db: Option<std::path::PathBuf>,
233 #[arg(long, value_name = "PATH")]
234 pid_file: Option<std::path::PathBuf>,
235 },
236}
237
238pub fn run() -> anyhow::Result<()> {
239 let cli = Cli::parse();
240 let db_path = cli.db.unwrap_or_else(default_db);
241
242 if let Commands::Mcp = &cli.command {
243 return crate::mcp::run_server(db_path);
244 }
245
246 if let Commands::Install = &cli.command {
247 return crate::install::run_install();
248 }
249
250 if let Commands::Uninstall { yes, purge_data } = &cli.command {
251 return crate::install::run_uninstall(*yes, *purge_data);
252 }
253
254 if let Commands::Migrate = &cli.command {
255 let applied = crate::migrate::run_migrations(&db_path)?;
256 if applied.is_empty() {
257 println!("already at 4.14 — nothing to do");
258 } else {
259 for step in &applied {
260 println!(" applied: {step}");
261 }
262 println!("migration complete");
263 }
264 return Ok(());
265 }
266
267 if let Commands::Daemon { action } = &cli.command {
268 return run_daemon(action, &db_path);
269 }
270
271 if let Commands::Backup { action } = &cli.command {
272 return run_backup(action, &db_path);
273 }
274
275 if let Commands::Upgrade { version, check } = &cli.command {
276 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
277 }
278
279 if let Commands::Hook { action } = &cli.command {
280 return match action {
281 HookCommands::Stop => run_hook_stop(),
282 };
283 }
284
285 let kb = crate::open_kb(&db_path)?;
286
287 match cli.command {
288 Commands::Recall {
289 query,
290 budget,
291 top,
292 format,
293 include_sparks,
294 expand_deps,
295 allow_trim,
296 refine_mode,
297 source,
298 } => {
299 let result = kb.recall(
300 &query,
301 budget,
302 true,
303 include_sparks,
304 top,
305 &source,
306 &expand_deps,
307 allow_trim,
308 &refine_mode,
309 )?;
310 match format.as_str() {
311 "json" => println!(
312 "{}",
313 serde_json::to_string_pretty(&json!({
314 "trace_id": result.trace_id,
315 "knowledge": result.knowledge,
316 "sparks": result.sparks,
317 "empty": result.empty,
318 }))?
319 ),
320 "prompt" => {
321 for chunk in &result.knowledge {
322 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
323 println!("{content}\n---");
324 }
325 println!("<!-- innate_trace_id: {} -->", result.trace_id);
327 println!(
328 "<!-- innate_selected: {} -->",
329 result
330 .knowledge
331 .iter()
332 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
333 .collect::<Vec<_>>()
334 .join(",")
335 );
336 }
337 _ => {
338 for chunk in &result.knowledge {
339 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
340 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
341 let conf = chunk
342 .get("confidence")
343 .and_then(|v| v.as_f64())
344 .unwrap_or(0.5);
345 println!("[{id}] (conf={conf:.2})\n{content}\n");
346 }
347 if result.empty {
348 println!("(no results)");
349 }
350 }
351 }
352 }
353 Commands::Record {
354 trace_id,
355 query,
356 outcome,
357 used,
358 used_attribution,
359 used_partial,
360 output,
361 output_summary,
362 nomination,
363 source,
364 feedback,
365 feedback_kind,
366 feedback_actor,
367 feedback_reason,
368 task_state,
369 priority,
370 } => {
371 let used_ids = used.as_deref().map(|raw| {
372 raw.split(',')
373 .map(str::trim)
374 .filter(|id| !id.is_empty())
375 .map(str::to_string)
376 .collect::<Vec<_>>()
377 });
378 let used_ref = used_ids.as_deref();
379 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
381 match feedback.as_deref() {
382 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
383 (used_ids.clone(), None)
384 }
385 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
386 (None, used_ids.clone())
387 }
388 Some("up") => (None, None), Some("down") => (None, None),
390 _ => (None, None),
391 };
392 let fb_up_ref = fb_up.as_deref();
393 let fb_down_ref = fb_down.as_deref();
394 kb.record_detailed(
395 &trace_id,
396 query.as_deref(),
397 output.as_deref(),
398 output_summary.as_deref(),
399 outcome.as_deref(),
400 used_ref,
401 &used_attribution,
402 !used_partial,
403 fb_up_ref,
404 fb_down_ref,
405 &feedback_kind,
406 feedback_actor.as_deref(),
407 feedback_reason.as_deref(),
408 nomination.as_deref(),
409 priority,
410 task_state.as_deref(),
411 &source,
412 )?;
413 println!("recorded");
414 }
415 Commands::Add {
416 content,
417 kind,
418 trigger,
419 anti_trigger,
420 source,
421 skill_name,
422 } => {
423 let content = if kind == "skill" {
425 let p = std::path::Path::new(&content);
426 if p.exists() && p.is_file() {
427 std::fs::read_to_string(p).map_err(|e| {
428 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
429 })?
430 } else {
431 content
432 }
433 } else {
434 content
435 };
436 let id = kb.add(
437 &content,
438 &kind,
439 trigger.as_deref(),
440 anti_trigger.as_deref(),
441 &source,
442 skill_name.as_deref(),
443 )?;
444 println!("{id}");
445 }
446 Commands::Spark { content, trigger } => {
447 let id = kb.spark(&content, trigger.as_deref(), None)?;
448 println!("{id}");
449 }
450 Commands::Evolve {
451 trigger,
452 rebuild_embeddings,
453 } => {
454 if rebuild_embeddings {
455 let rebuilt = kb.rebuild_embeddings()?;
456 let report = kb.evolve(&trigger)?;
457 println!(
458 "{}",
459 serde_json::to_string_pretty(&json!({
460 "rebuilt_embeddings": rebuilt,
461 "evolve": report
462 }))?
463 );
464 } else {
465 let report = kb.evolve(&trigger)?;
466 println!("{}", serde_json::to_string_pretty(&report)?);
467 }
468 }
469 Commands::Inspect { id } => match id.as_deref() {
470 None => {
471 let info = kb.inspect()?;
472 println!("{}", serde_json::to_string_pretty(&info)?);
473 }
474 Some(id) => {
475 let detail = kb.inspect_id(id)?;
476 println!("{}", serde_json::to_string_pretty(&detail)?);
477 }
478 },
479 Commands::Approve { chunk_id } => {
480 kb.approve(&chunk_id)?;
481 println!("approved");
482 }
483 Commands::Archive { chunk_id, reason } => {
484 kb.archive(&chunk_id, &reason)?;
485 println!("archived");
486 }
487 Commands::Invalidate { chunk_id, reason } => {
488 kb.invalidate(&chunk_id, &reason)?;
489 println!("invalidated");
490 }
491 Commands::Restore { chunk_id } => {
492 kb.restore(&chunk_id)?;
493 println!("restored");
494 }
495 Commands::MatureSpark { spark_id, to } => {
496 kb.mature_spark(&spark_id, &to)?;
497 println!("matured");
498 }
499 Commands::PromoteSpark { spark_id, to } => {
500 let id = kb.promote_spark(&spark_id, &to)?;
501 println!("{id}");
502 }
503 Commands::DropSpark { spark_id, reason } => {
504 kb.drop_spark(&spark_id, &reason)?;
505 println!("dropped");
506 }
507 Commands::Mcp
508 | Commands::Install
509 | Commands::Uninstall { .. }
510 | Commands::Migrate
511 | Commands::Upgrade { .. }
512 | Commands::Daemon { .. }
513 | Commands::Backup { .. }
514 | Commands::Hook { .. } => unreachable!(),
515 }
516 Ok(())
517}
518
519fn default_pid_file() -> std::path::PathBuf {
524 dirs_next::home_dir()
525 .unwrap_or_else(|| std::path::PathBuf::from("."))
526 .join(".innate")
527 .join("daemon.pid")
528}
529
530fn default_state_db() -> std::path::PathBuf {
531 dirs_next::home_dir()
532 .unwrap_or_else(|| std::path::PathBuf::from("."))
533 .join(".innate")
534 .join("daemon_state.sqlite")
535}
536
537fn default_log_file() -> std::path::PathBuf {
538 dirs_next::home_dir()
539 .unwrap_or_else(|| std::path::PathBuf::from("."))
540 .join(".innate")
541 .join("daemon.log")
542}
543
544fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
545 match action {
546 DaemonCommands::Start {
547 watch,
548 pid_file,
549 state_db,
550 log_file,
551 } => {
552 let effective_watch: Vec<std::path::PathBuf> = if !watch.is_empty() {
554 watch.clone()
555 } else {
556 let s = crate::settings::load();
557 crate::settings::resolved_watch_dirs(&s)
558 .into_iter()
559 .map(std::path::PathBuf::from)
560 .collect()
561 };
562 crate::daemon::start(
563 &effective_watch,
564 db_path,
565 pid_file.as_deref().unwrap_or(&default_pid_file()),
566 state_db.as_deref().unwrap_or(&default_state_db()),
567 log_file.as_deref().unwrap_or(&default_log_file()),
568 )
569 }
570 DaemonCommands::Stop { pid_file } => {
571 crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
572 }
573 DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
574 state_db.as_deref().unwrap_or(&default_state_db()),
575 pid_file.as_deref().unwrap_or(&default_pid_file()),
576 ),
577 }
578}
579
580fn run_backup(action: &BackupCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
585 use crate::backup::R2BackupService;
586
587 let settings = crate::settings::load();
588 let cfg = settings.backup.as_ref().ok_or_else(|| {
589 anyhow::anyhow!(
590 "No backup config found in ~/.innate/settings.json.\n\
591 Add a \"backup\" section with \"enable\": true and \"r2\" credentials to enable R2 backup."
592 )
593 })?;
594
595 if let BackupCommands::Status = action {
597 use crate::backup::R2BackupService;
598 let state = R2BackupService::last_backup_state();
599 println!("R2 backup enabled : {}", cfg.enable);
600 println!("R2 bucket : {}", cfg.r2.as_ref().map(|r| r.bucket.as_str()).unwrap_or("-"));
601 println!("Last backup : {}", state.last_backup_at.as_deref().unwrap_or("never"));
602 println!("Last backup key : {}", state.last_backup_key.as_deref().unwrap_or("-"));
603 let due = R2BackupService::needs_backup(cfg.auto_backup_interval_hours);
604 println!("Backup due : {}", if cfg.enable && due { "yes" } else if !cfg.enable { "disabled" } else { "no" });
605 println!("Interval (h) : {}", cfg.auto_backup_interval_hours);
606 println!("Retention (days) : {}", cfg.retention_days);
607 println!("Min backups : {}", cfg.min_backups);
608 return Ok(());
609 }
610
611 if !cfg.enable {
612 anyhow::bail!(
613 "R2 backup is disabled (backup.enable = false).\n\
614 Set \"enable\": true in the backup section of ~/.innate/settings.json to activate."
615 );
616 }
617 let r2_cfg = cfg.r2.as_ref().ok_or_else(|| {
618 anyhow::anyhow!("backup.r2 not configured in ~/.innate/settings.json")
619 })?;
620
621 match action {
622 BackupCommands::Run { force } => {
623 if !force && !R2BackupService::needs_backup(cfg.auto_backup_interval_hours) {
624 let state = R2BackupService::last_backup_state();
625 println!(
626 "backup not due yet (last: {}; interval: {}h). Use --force to override.",
627 state.last_backup_at.as_deref().unwrap_or("never"),
628 cfg.auto_backup_interval_hours
629 );
630 return Ok(());
631 }
632 println!("Starting backup to R2 bucket '{}'…", r2_cfg.bucket);
633 let svc = R2BackupService::from_config(r2_cfg)?;
634 let result = svc.backup_now(db_path, cfg.retention_days, cfg.min_backups)?;
635 println!("Backed up: {} ({} bytes)", result.key, result.size_bytes);
636 if !result.prune.deleted.is_empty() {
637 println!("Pruned {} old backup(s):", result.prune.deleted.len());
638 for k in &result.prune.deleted {
639 println!(" - {k}");
640 }
641 }
642 if result.prune.protected_by_min > 0 {
643 println!(
644 " ({} old backup(s) kept to satisfy min_backups={})",
645 result.prune.protected_by_min, cfg.min_backups
646 );
647 }
648 println!("Done. {} backup(s) remain in R2.", result.prune.kept);
649 }
650 BackupCommands::Status => unreachable!(), BackupCommands::List => {
652 let svc = R2BackupService::from_config(r2_cfg)?;
653 let backups = svc.list_backups()?;
654 if backups.is_empty() {
655 println!("No backups found in R2.");
656 } else {
657 println!("{} backup(s):", backups.len());
658 for b in &backups {
659 println!(" {} | {} | {} bytes", b.last_modified, b.key, b.size_bytes);
660 }
661 }
662 }
663 BackupCommands::Prune => {
664 let svc = R2BackupService::from_config(r2_cfg)?;
665 let result = svc.prune_old_backups(cfg.retention_days, cfg.min_backups)?;
666 if result.deleted.is_empty() {
667 println!("Nothing to prune ({} backup(s) kept).", result.kept);
668 } else {
669 println!("Deleted {} backup(s):", result.deleted.len());
670 for k in &result.deleted {
671 println!(" - {k}");
672 }
673 if result.protected_by_min > 0 {
674 println!(
675 " ({} old backup(s) kept to satisfy min_backups={})",
676 result.protected_by_min, cfg.min_backups
677 );
678 }
679 println!("{} backup(s) remain.", result.kept);
680 }
681 }
682 }
683 Ok(())
684}
685
686fn extract_content_text(content: Option<&serde_json::Value>) -> String {
691 match content {
692 None => String::new(),
693 Some(serde_json::Value::String(s)) => s.clone(),
694 Some(serde_json::Value::Array(arr)) => arr
695 .iter()
696 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
697 .collect::<Vec<_>>()
698 .join(" "),
699 _ => String::new(),
700 }
701}
702
703fn run_hook_stop() -> anyhow::Result<()> {
704 use std::io::{Read, Write};
705
706 let mut input = String::new();
707 std::io::stdin().read_to_string(&mut input)?;
708
709 let data: serde_json::Value =
710 serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
711
712 let empty = vec![];
713 let transcript = data
714 .get("transcript")
715 .or_else(|| data.get("messages"))
716 .and_then(|v| v.as_array())
717 .unwrap_or(&empty);
718
719 let mut query = String::new();
720 let mut summary = String::new();
721
722 for m in transcript.iter().rev() {
723 let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
724 if query.is_empty() && role == "user" {
725 query = extract_content_text(m.get("content"))
726 .chars()
727 .take(200)
728 .collect();
729 }
730 if summary.is_empty() && role == "assistant" {
731 summary = extract_content_text(m.get("content"))
732 .chars()
733 .take(400)
734 .collect();
735 }
736 if !query.is_empty() && !summary.is_empty() {
737 break;
738 }
739 }
740
741 let mut events: Vec<serde_json::Value> = Vec::new();
742 if !query.is_empty() {
743 events.push(json!({"event_type": "session_start", "query": query.trim()}));
744 }
745 if !summary.is_empty() {
746 events.push(json!({"event_type": "tool_success", "output_summary": summary.trim(), "outcome": "ok"}));
747 }
748 events.push(json!({"event_type": "session_end"}));
749
750 let log_path = dirs_next::home_dir()
751 .unwrap_or_else(|| PathBuf::from("."))
752 .join(".innate")
753 .join("sessions")
754 .join("session.log");
755
756 if let Some(parent) = log_path.parent() {
757 std::fs::create_dir_all(parent)?;
758 }
759
760 let mut file = std::fs::OpenOptions::new()
761 .create(true)
762 .append(true)
763 .open(&log_path)?;
764
765 for event in &events {
766 writeln!(file, "{}", serde_json::to_string(event)?)?;
767 }
768
769 Ok(())
770}