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