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::{RecallParams, RecordParams};
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 Record {
59 trace_id: String,
60 #[arg(long)]
61 query: Option<String>,
62 #[arg(long)]
63 outcome: Option<String>,
64 #[arg(long)]
66 used: Option<String>,
67 #[arg(long, default_value = "explicit")]
68 used_attribution: String,
69 #[arg(long)]
71 used_partial: bool,
72 #[arg(long)]
73 output: Option<String>,
74 #[arg(long)]
75 output_summary: Option<String>,
76 #[arg(long)]
77 nomination: Option<String>,
78 #[arg(long, default_value = "cli")]
79 source: String,
80 #[arg(long)]
82 feedback: Option<String>,
83 #[arg(long, default_value = "user")]
84 feedback_kind: String,
85 #[arg(long)]
86 feedback_actor: Option<String>,
87 #[arg(long)]
88 feedback_reason: Option<String>,
89 #[arg(long)]
90 task_state: Option<String>,
91 #[arg(long, default_value = "0")]
92 priority: i64,
93 },
94 Add {
96 content: String,
97 #[arg(long, default_value = "note")]
98 kind: String,
99 #[arg(long)]
100 trigger: Option<String>,
101 #[arg(long)]
102 anti_trigger: Option<String>,
103 #[arg(long, default_value = "chat")]
104 source: String,
105 #[arg(long)]
106 skill_name: Option<String>,
107 #[arg(long = "depends-on")]
109 depends_on: Vec<String>,
110 #[arg(long, default_value = "hard")]
112 dep_kind: String,
113 },
114 Spark {
116 content: String,
117 #[arg(long)]
118 trigger: Option<String>,
119 },
120 Evolve {
122 #[arg(long, default_value = "manual")]
123 trigger: String,
124 #[arg(long)]
126 rebuild_embeddings: bool,
127 },
128 Inspect { id: Option<String> },
130 Approve { chunk_id: String },
132 Archive {
134 chunk_id: String,
135 #[arg(long, default_value = "stale")]
136 reason: String,
137 },
138 Invalidate {
140 chunk_id: String,
141 #[arg(long, default_value = "")]
142 reason: String,
143 },
144 Restore { chunk_id: String },
146 MatureSpark { spark_id: String, to: String },
148 PromoteSpark {
150 spark_id: String,
151 #[arg(long, default_value = "note")]
152 to: String,
153 },
154 DropSpark {
156 spark_id: String,
157 #[arg(long, default_value = "")]
158 reason: String,
159 },
160 Backup {
162 #[command(subcommand)]
163 action: BackupCommands,
164 },
165 Install,
167 Uninstall {
169 #[arg(long, short = 'y')]
171 yes: bool,
172 #[arg(long)]
174 purge_data: bool,
175 },
176 Migrate,
178 Vacuum,
180 Upgrade {
182 #[arg(long, value_name = "VERSION")]
184 version: Option<String>,
185 #[arg(long)]
187 check: bool,
188 },
189 Daemon {
191 #[command(subcommand)]
192 action: DaemonCommands,
193 },
194 Mcp,
196 Web {
198 #[arg(long, default_value = "127.0.0.1")]
200 bind: String,
201 #[arg(long, default_value_t = 8788)]
203 port: u16,
204 #[arg(long)]
206 no_token: bool,
207 #[arg(long)]
210 allow_remote: bool,
211 },
212 Hook {
214 #[command(subcommand)]
215 action: HookCommands,
216 },
217}
218
219pub fn run() -> anyhow::Result<()> {
220 let cli = Cli::parse();
221 crate::paths::ensure_layout();
224 let db_path = cli.db.unwrap_or_else(default_db);
225
226 if let Commands::Mcp = &cli.command {
227 return crate::mcp::run_server(db_path);
228 }
229
230 if let Commands::Install = &cli.command {
231 return crate::install::run_install();
232 }
233
234 if let Commands::Uninstall { yes, purge_data } = &cli.command {
235 return crate::install::run_uninstall(*yes, *purge_data);
236 }
237
238 if let Commands::Migrate = &cli.command {
239 let applied = crate::migrate::run_migrations(&db_path)?;
240 if applied.is_empty() {
241 println!("already at 4.14 — nothing to do");
242 } else {
243 for step in &applied {
244 println!(" applied: {step}");
245 }
246 println!("migration complete");
247 }
248 return Ok(());
249 }
250
251 if let Commands::Daemon { action } = &cli.command {
252 return crate::daemon::run_command(action, &db_path);
253 }
254
255 if let Commands::Backup { action } = &cli.command {
256 return crate::backup::run_command(action, &db_path);
257 }
258
259 if let Commands::Upgrade { version, check } = &cli.command {
260 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
261 }
262
263 if let Commands::Hook { action } = &cli.command {
264 return crate::hook::run_command(action, &db_path);
265 }
266
267 let kb = crate::open_kb(&db_path)?;
268
269 match cli.command {
270 Commands::Recall {
271 query,
272 budget,
273 top,
274 format,
275 include_sparks,
276 expand_deps,
277 allow_trim,
278 refine_mode,
279 source,
280 min_score,
281 } => {
282 let result = kb.recall(RecallParams {
283 query: &query,
284 budget,
285 trace: true,
286 include_sparks,
287 top,
288 source: &source,
289 expand_deps: &expand_deps,
290 allow_trim,
291 refine_mode: &refine_mode,
292 min_score,
293 })?;
294 match format.as_str() {
295 "json" => println!(
296 "{}",
297 serde_json::to_string_pretty(&json!({
298 "trace_id": result.trace_id,
299 "knowledge": result.knowledge,
300 "sparks": result.sparks,
301 "empty": result.empty,
302 }))?
303 ),
304 "prompt" => {
305 for chunk in &result.knowledge {
306 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
307 println!("{content}\n---");
308 }
309 println!("<!-- innate_trace_id: {} -->", result.trace_id);
311 println!(
312 "<!-- innate_selected: {} -->",
313 result
314 .knowledge
315 .iter()
316 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
317 .collect::<Vec<_>>()
318 .join(",")
319 );
320 }
321 _ => {
322 for chunk in &result.knowledge {
323 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
324 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
325 let conf = chunk
326 .get("confidence")
327 .and_then(|v| v.as_f64())
328 .unwrap_or(0.5);
329 println!("[{id}] (conf={conf:.2})\n{content}\n");
330 }
331 if result.empty {
332 println!("(no results)");
333 }
334 }
335 }
336 }
337 Commands::Record {
338 trace_id,
339 query,
340 outcome,
341 used,
342 used_attribution,
343 used_partial,
344 output,
345 output_summary,
346 nomination,
347 source,
348 feedback,
349 feedback_kind,
350 feedback_actor,
351 feedback_reason,
352 task_state,
353 priority,
354 } => {
355 let used_ids = used.as_deref().map(|raw| {
356 raw.split(',')
357 .map(str::trim)
358 .filter(|id| !id.is_empty())
359 .map(str::to_string)
360 .collect::<Vec<_>>()
361 });
362 let used_ref = used_ids.as_deref();
363 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
365 match feedback.as_deref() {
366 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
367 (used_ids.clone(), None)
368 }
369 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
370 (None, used_ids.clone())
371 }
372 Some("up") => (None, None), Some("down") => (None, None),
374 _ => (None, None),
375 };
376 let fb_up_ref = fb_up.as_deref();
377 let fb_down_ref = fb_down.as_deref();
378 kb.record(RecordParams {
379 trace_id: &trace_id,
380 query: query.as_deref(),
381 output: output.as_deref(),
382 output_summary: output_summary.as_deref(),
383 outcome: outcome.as_deref(),
384 used: used_ref,
385 used_attribution: &used_attribution,
386 used_complete: Some(!used_partial),
387 feedback_up: fb_up_ref,
388 feedback_down: fb_down_ref,
389 feedback_kind: &feedback_kind,
390 feedback_actor: feedback_actor.as_deref(),
391 feedback_reason: feedback_reason.as_deref(),
392 nomination: nomination.as_deref(),
393 priority,
394 task_state: task_state.as_deref(),
395 source: &source,
396 })?;
397 println!("recorded");
398 }
399 Commands::Add {
400 content,
401 kind,
402 trigger,
403 anti_trigger,
404 source,
405 skill_name,
406 depends_on,
407 dep_kind,
408 } => {
409 let content = if kind == "skill" {
411 let p = std::path::Path::new(&content);
412 if p.exists() && p.is_file() {
413 std::fs::read_to_string(p).map_err(|e| {
414 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
415 })?
416 } else {
417 content
418 }
419 } else {
420 content
421 };
422 let deps: Vec<(String, String)> = depends_on
423 .iter()
424 .map(|d| (d.clone(), dep_kind.clone()))
425 .collect();
426 let id = kb.add_with_deps(
427 &content,
428 &kind,
429 trigger.as_deref(),
430 anti_trigger.as_deref(),
431 &source,
432 skill_name.as_deref(),
433 &deps,
434 )?;
435 println!("{id}");
436 }
437 Commands::Spark { content, trigger } => {
438 let id = kb.spark(&content, trigger.as_deref(), None)?;
439 println!("{id}");
440 }
441 Commands::Evolve {
442 trigger,
443 rebuild_embeddings,
444 } => {
445 if rebuild_embeddings {
446 let rebuilt = kb.rebuild_embeddings()?;
447 let report = kb.evolve(&trigger)?;
448 println!(
449 "{}",
450 serde_json::to_string_pretty(&json!({
451 "rebuilt_embeddings": rebuilt,
452 "evolve": report
453 }))?
454 );
455 } else {
456 let report = kb.evolve(&trigger)?;
457 println!("{}", serde_json::to_string_pretty(&report)?);
458 }
459 }
460 Commands::Inspect { id } => match id.as_deref() {
461 None => {
462 let info = kb.inspect()?;
463 println!("{}", serde_json::to_string_pretty(&info)?);
464 }
465 Some(id) => {
466 let detail = kb.inspect_id(id)?;
467 println!("{}", serde_json::to_string_pretty(&detail)?);
468 }
469 },
470 Commands::Approve { chunk_id } => {
471 kb.approve(&chunk_id)?;
472 println!("approved");
473 }
474 Commands::Archive { chunk_id, reason } => {
475 kb.archive(&chunk_id, &reason)?;
476 println!("archived");
477 }
478 Commands::Invalidate { chunk_id, reason } => {
479 kb.invalidate(&chunk_id, &reason)?;
480 println!("invalidated");
481 }
482 Commands::Restore { chunk_id } => {
483 kb.restore(&chunk_id)?;
484 println!("restored");
485 }
486 Commands::MatureSpark { spark_id, to } => {
487 kb.mature_spark(&spark_id, &to)?;
488 println!("matured");
489 }
490 Commands::PromoteSpark { spark_id, to } => {
491 let id = kb.promote_spark(&spark_id, &to)?;
492 println!("{id}");
493 }
494 Commands::DropSpark { spark_id, reason } => {
495 kb.drop_spark(&spark_id, &reason)?;
496 println!("dropped");
497 }
498 Commands::Vacuum => {
499 let (before, after) = kb.storage.vacuum()?;
500 let mb = |b: i64| b as f64 / 1_048_576.0;
501 println!(
502 "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
503 mb(before),
504 mb(after),
505 mb(before - after)
506 );
507 }
508 Commands::Web {
509 bind,
510 port,
511 no_token,
512 allow_remote,
513 } => {
514 let loopback = crate::web::is_loopback(&bind);
515 if !loopback && !allow_remote {
516 anyhow::bail!(
517 "refusing to bind non-loopback address {bind} without --allow-remote \
518 (this exposes the knowledge base to the network)"
519 );
520 }
521 if !loopback && no_token {
522 anyhow::bail!(
523 "--no-token cannot be combined with a non-loopback bind: a network-exposed \
524 server must keep the auth token to gate reads and writes"
525 );
526 }
527 crate::web::serve(kb, &bind, port, !no_token)?;
528 }
529 Commands::Mcp
530 | Commands::Install
531 | Commands::Uninstall { .. }
532 | Commands::Migrate
533 | Commands::Upgrade { .. }
534 | Commands::Daemon { .. }
535 | Commands::Backup { .. }
536 | Commands::Hook { .. } => unreachable!(),
537 }
538 Ok(())
539}