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};
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 },
57 Appraise {
60 #[arg(long, default_value = "")]
62 query: String,
63 #[arg(long)]
65 last_error: Option<String>,
66 #[arg(long)]
68 recent_actions: Option<String>,
69 #[arg(long)]
71 stage: Option<String>,
72 #[arg(long)]
74 file_context: Option<String>,
75 #[arg(long)]
77 candidate: Option<String>,
78 #[arg(long)]
79 top: Option<usize>,
80 #[arg(long)]
81 min_strength: Option<f64>,
82 #[arg(long, default_value = "cli")]
83 source: String,
84 #[arg(long, default_value = "json")]
85 format: String,
86 },
87 Record {
89 trace_id: String,
90 #[arg(long)]
91 query: Option<String>,
92 #[arg(long)]
93 outcome: Option<String>,
94 #[arg(long)]
96 used: Option<String>,
97 #[arg(long, default_value = "explicit")]
98 used_attribution: String,
99 #[arg(long)]
101 used_partial: bool,
102 #[arg(long)]
103 output: Option<String>,
104 #[arg(long)]
105 output_summary: Option<String>,
106 #[arg(long)]
107 nomination: Option<String>,
108 #[arg(long, default_value = "cli")]
109 source: String,
110 #[arg(long)]
112 feedback: Option<String>,
113 #[arg(long, default_value = "user")]
114 feedback_kind: String,
115 #[arg(long)]
116 feedback_actor: Option<String>,
117 #[arg(long)]
118 feedback_reason: Option<String>,
119 #[arg(long)]
120 task_state: Option<String>,
121 #[arg(long, default_value = "0")]
122 priority: i64,
123 },
124 Add {
126 content: String,
127 #[arg(long, default_value = "note")]
128 kind: String,
129 #[arg(long)]
130 trigger: Option<String>,
131 #[arg(long)]
132 anti_trigger: Option<String>,
133 #[arg(long, default_value = "chat")]
134 source: String,
135 #[arg(long)]
136 skill_name: Option<String>,
137 #[arg(long = "depends-on")]
139 depends_on: Vec<String>,
140 #[arg(long, default_value = "hard")]
142 dep_kind: String,
143 },
144 Spark {
146 content: String,
147 #[arg(long)]
148 trigger: Option<String>,
149 },
150 Evolve {
152 #[arg(long, default_value = "manual")]
153 trigger: String,
154 #[arg(long)]
156 rebuild_embeddings: bool,
157 },
158 Inspect { id: Option<String> },
160 Approve { chunk_id: String },
162 Archive {
164 chunk_id: String,
165 #[arg(long, default_value = "stale")]
166 reason: String,
167 },
168 Invalidate {
170 chunk_id: String,
171 #[arg(long, default_value = "")]
172 reason: String,
173 },
174 Restore { chunk_id: String },
176 MatureSpark { spark_id: String, to: String },
178 PromoteSpark {
180 spark_id: String,
181 #[arg(long, default_value = "note")]
182 to: String,
183 },
184 DropSpark {
186 spark_id: String,
187 #[arg(long, default_value = "")]
188 reason: String,
189 },
190 Backup {
192 #[command(subcommand)]
193 action: BackupCommands,
194 },
195 Install,
197 Uninstall {
199 #[arg(long, short = 'y')]
201 yes: bool,
202 #[arg(long)]
204 purge_data: bool,
205 },
206 Migrate,
208 Vacuum,
210 Upgrade {
212 #[arg(long, value_name = "VERSION")]
214 version: Option<String>,
215 #[arg(long)]
217 check: bool,
218 },
219 Daemon {
221 #[command(subcommand)]
222 action: DaemonCommands,
223 },
224 Mcp,
226 Web {
228 #[arg(long, default_value = "127.0.0.1")]
230 bind: String,
231 #[arg(long, default_value_t = 8788)]
233 port: u16,
234 #[arg(long)]
236 no_token: bool,
237 #[arg(long)]
240 allow_remote: bool,
241 },
242 Hook {
244 #[command(subcommand)]
245 action: HookCommands,
246 },
247}
248
249pub fn run() -> anyhow::Result<()> {
250 let cli = Cli::parse();
251 crate::paths::ensure_layout();
254 let db_path = cli.db.unwrap_or_else(default_db);
255
256 if let Commands::Mcp = &cli.command {
257 return crate::mcp::run_server(db_path);
258 }
259
260 if let Commands::Install = &cli.command {
261 return crate::install::run_install();
262 }
263
264 if let Commands::Uninstall { yes, purge_data } = &cli.command {
265 return crate::install::run_uninstall(*yes, *purge_data);
266 }
267
268 if let Commands::Migrate = &cli.command {
269 let applied = crate::migrate::run_migrations(&db_path)?;
270 if applied.is_empty() {
271 println!("already at 4.14 — nothing to do");
272 } else {
273 for step in &applied {
274 println!(" applied: {step}");
275 }
276 println!("migration complete");
277 }
278 return Ok(());
279 }
280
281 if let Commands::Daemon { action } = &cli.command {
282 return crate::daemon::run_command(action, &db_path);
283 }
284
285 if let Commands::Backup { action } = &cli.command {
286 return crate::backup::run_command(action, &db_path);
287 }
288
289 if let Commands::Upgrade { version, check } = &cli.command {
290 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
291 }
292
293 if let Commands::Hook { action } = &cli.command {
294 return crate::hook::run_command(action, &db_path);
295 }
296
297 let kb = crate::open_kb(&db_path)?;
298
299 match cli.command {
300 Commands::Recall {
301 query,
302 budget,
303 top,
304 format,
305 include_sparks,
306 expand_deps,
307 allow_trim,
308 refine_mode,
309 source,
310 min_score,
311 } => {
312 let result = kb.recall(RecallParams {
313 query: &query,
314 budget,
315 trace: true,
316 include_sparks,
317 top,
318 source: &source,
319 expand_deps: &expand_deps,
320 allow_trim,
321 refine_mode: &refine_mode,
322 min_score,
323 })?;
324 match format.as_str() {
325 "json" => println!(
326 "{}",
327 serde_json::to_string_pretty(&json!({
328 "trace_id": result.trace_id,
329 "knowledge": result.knowledge,
330 "sparks": result.sparks,
331 "empty": result.empty,
332 }))?
333 ),
334 "prompt" => {
335 for chunk in &result.knowledge {
336 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
337 println!("{content}\n---");
338 }
339 println!("<!-- innate_trace_id: {} -->", result.trace_id);
341 println!(
342 "<!-- innate_selected: {} -->",
343 result
344 .knowledge
345 .iter()
346 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
347 .collect::<Vec<_>>()
348 .join(",")
349 );
350 }
351 _ => {
352 for chunk in &result.knowledge {
353 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
354 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
355 let conf = chunk
356 .get("confidence")
357 .and_then(|v| v.as_f64())
358 .unwrap_or(0.5);
359 println!("[{id}] (conf={conf:.2})\n{content}\n");
360 }
361 if result.empty {
362 println!("(no results)");
363 }
364 }
365 }
366 }
367 Commands::Appraise {
368 query,
369 last_error,
370 recent_actions,
371 stage,
372 file_context,
373 candidate,
374 top,
375 min_strength,
376 source,
377 format,
378 } => {
379 let actions: Vec<String> = recent_actions
380 .as_deref()
381 .map(|raw| {
382 raw.split(',')
383 .map(str::trim)
384 .filter(|a| !a.is_empty())
385 .map(str::to_string)
386 .collect()
387 })
388 .unwrap_or_default();
389 let situation = Situation {
390 query: (!query.is_empty()).then_some(query.as_str()),
391 last_error: last_error.as_deref(),
392 recent_actions: &actions,
393 stage: stage.as_deref(),
394 file_context: file_context.as_deref(),
395 };
396 let verdict = kb.appraise(AppraiseParams {
397 situation,
398 candidate: candidate.as_deref(),
399 min_strength,
400 top,
401 trace: true,
402 source: &source,
403 })?;
404 match format.as_str() {
405 "text" => {
406 println!(
407 "valence={:?} tier={:?} strength={:.3} trace_id={}",
408 verdict.valence, verdict.tier, verdict.strength, verdict.trace_id
409 );
410 for fp in &verdict.flagged_points {
411 println!(" ⚠ [{}] {} (s={:.3})", fp.chunk_id, fp.summary, fp.strength);
412 }
413 }
414 _ => println!("{}", serde_json::to_string_pretty(&json!({
415 "valence": verdict.valence,
416 "strength": verdict.strength,
417 "tier": verdict.tier,
418 "flagged_points": verdict.flagged_points,
419 "contributors": verdict.contributors,
420 "trace_id": verdict.trace_id,
421 }))?),
422 }
423 }
424 Commands::Record {
425 trace_id,
426 query,
427 outcome,
428 used,
429 used_attribution,
430 used_partial,
431 output,
432 output_summary,
433 nomination,
434 source,
435 feedback,
436 feedback_kind,
437 feedback_actor,
438 feedback_reason,
439 task_state,
440 priority,
441 } => {
442 let used_ids = used.as_deref().map(|raw| {
443 raw.split(',')
444 .map(str::trim)
445 .filter(|id| !id.is_empty())
446 .map(str::to_string)
447 .collect::<Vec<_>>()
448 });
449 let used_ref = used_ids.as_deref();
450 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
452 match feedback.as_deref() {
453 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
454 (used_ids.clone(), None)
455 }
456 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
457 (None, used_ids.clone())
458 }
459 Some("up") => (None, None), Some("down") => (None, None),
461 _ => (None, None),
462 };
463 let fb_up_ref = fb_up.as_deref();
464 let fb_down_ref = fb_down.as_deref();
465 kb.record(RecordParams {
466 trace_id: &trace_id,
467 query: query.as_deref(),
468 output: output.as_deref(),
469 output_summary: output_summary.as_deref(),
470 outcome: outcome.as_deref(),
471 used: used_ref,
472 used_attribution: &used_attribution,
473 used_complete: Some(!used_partial),
474 feedback_up: fb_up_ref,
475 feedback_down: fb_down_ref,
476 feedback_kind: &feedback_kind,
477 feedback_actor: feedback_actor.as_deref(),
478 feedback_reason: feedback_reason.as_deref(),
479 nomination: nomination.as_deref(),
480 priority,
481 task_state: task_state.as_deref(),
482 source: &source,
483 })?;
484 println!("recorded");
485 }
486 Commands::Add {
487 content,
488 kind,
489 trigger,
490 anti_trigger,
491 source,
492 skill_name,
493 depends_on,
494 dep_kind,
495 } => {
496 let content = if kind == "skill" {
498 let p = std::path::Path::new(&content);
499 if p.exists() && p.is_file() {
500 std::fs::read_to_string(p).map_err(|e| {
501 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
502 })?
503 } else {
504 content
505 }
506 } else {
507 content
508 };
509 let deps: Vec<(String, String)> = depends_on
510 .iter()
511 .map(|d| (d.clone(), dep_kind.clone()))
512 .collect();
513 let id = kb.add_with_deps(
514 &content,
515 &kind,
516 trigger.as_deref(),
517 anti_trigger.as_deref(),
518 &source,
519 skill_name.as_deref(),
520 &deps,
521 )?;
522 println!("{id}");
523 }
524 Commands::Spark { content, trigger } => {
525 let id = kb.spark(&content, trigger.as_deref(), None)?;
526 println!("{id}");
527 }
528 Commands::Evolve {
529 trigger,
530 rebuild_embeddings,
531 } => {
532 if rebuild_embeddings {
533 let rebuilt = kb.rebuild_embeddings()?;
534 let report = kb.evolve(&trigger)?;
535 println!(
536 "{}",
537 serde_json::to_string_pretty(&json!({
538 "rebuilt_embeddings": rebuilt,
539 "evolve": report
540 }))?
541 );
542 } else {
543 let report = kb.evolve(&trigger)?;
544 println!("{}", serde_json::to_string_pretty(&report)?);
545 }
546 }
547 Commands::Inspect { id } => match id.as_deref() {
548 None => {
549 let info = kb.inspect()?;
550 println!("{}", serde_json::to_string_pretty(&info)?);
551 }
552 Some(id) => {
553 let detail = kb.inspect_id(id)?;
554 println!("{}", serde_json::to_string_pretty(&detail)?);
555 }
556 },
557 Commands::Approve { chunk_id } => {
558 kb.approve(&chunk_id)?;
559 println!("approved");
560 }
561 Commands::Archive { chunk_id, reason } => {
562 kb.archive(&chunk_id, &reason)?;
563 println!("archived");
564 }
565 Commands::Invalidate { chunk_id, reason } => {
566 kb.invalidate(&chunk_id, &reason)?;
567 println!("invalidated");
568 }
569 Commands::Restore { chunk_id } => {
570 kb.restore(&chunk_id)?;
571 println!("restored");
572 }
573 Commands::MatureSpark { spark_id, to } => {
574 kb.mature_spark(&spark_id, &to)?;
575 println!("matured");
576 }
577 Commands::PromoteSpark { spark_id, to } => {
578 let id = kb.promote_spark(&spark_id, &to)?;
579 println!("{id}");
580 }
581 Commands::DropSpark { spark_id, reason } => {
582 kb.drop_spark(&spark_id, &reason)?;
583 println!("dropped");
584 }
585 Commands::Vacuum => {
586 let (before, after) = kb.storage.vacuum()?;
587 let mb = |b: i64| b as f64 / 1_048_576.0;
588 println!(
589 "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
590 mb(before),
591 mb(after),
592 mb(before - after)
593 );
594 }
595 Commands::Web {
596 bind,
597 port,
598 no_token,
599 allow_remote,
600 } => {
601 let loopback = crate::web::is_loopback(&bind);
602 if !loopback && !allow_remote {
603 anyhow::bail!(
604 "refusing to bind non-loopback address {bind} without --allow-remote \
605 (this exposes the knowledge base to the network)"
606 );
607 }
608 if !loopback && no_token {
609 anyhow::bail!(
610 "--no-token cannot be combined with a non-loopback bind: a network-exposed \
611 server must keep the auth token to gate reads and writes"
612 );
613 }
614 crate::web::serve(kb, &bind, port, !no_token)?;
615 }
616 Commands::Mcp
617 | Commands::Install
618 | Commands::Uninstall { .. }
619 | Commands::Migrate
620 | Commands::Upgrade { .. }
621 | Commands::Daemon { .. }
622 | Commands::Backup { .. }
623 | Commands::Hook { .. } => unreachable!(),
624 }
625 Ok(())
626}