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 Install,
150 Uninstall {
152 #[arg(long, short = 'y')]
154 yes: bool,
155 #[arg(long)]
157 purge_data: bool,
158 },
159 Migrate,
161 Upgrade {
163 #[arg(long, value_name = "VERSION")]
165 version: Option<String>,
166 #[arg(long)]
168 check: bool,
169 },
170 Daemon {
172 #[command(subcommand)]
173 action: DaemonCommands,
174 },
175 Mcp,
177}
178
179#[derive(Subcommand)]
180pub enum DaemonCommands {
181 Start {
183 #[arg(long = "watch", value_name = "LOG_DIR")]
184 watch: Vec<std::path::PathBuf>,
185 #[arg(long, value_name = "PATH")]
186 pid_file: Option<std::path::PathBuf>,
187 #[arg(long, value_name = "PATH")]
188 state_db: Option<std::path::PathBuf>,
189 #[arg(long, value_name = "PATH")]
190 log_file: Option<std::path::PathBuf>,
191 },
192 Stop {
194 #[arg(long, value_name = "PATH")]
195 pid_file: Option<std::path::PathBuf>,
196 },
197 Status {
199 #[arg(long, value_name = "PATH")]
200 state_db: Option<std::path::PathBuf>,
201 #[arg(long, value_name = "PATH")]
202 pid_file: Option<std::path::PathBuf>,
203 },
204}
205
206pub fn run() -> anyhow::Result<()> {
207 let cli = Cli::parse();
208 let db_path = cli.db.unwrap_or_else(default_db);
209
210 if let Commands::Mcp = &cli.command {
211 return crate::mcp::run_server(db_path);
212 }
213
214 if let Commands::Install = &cli.command {
215 return crate::install::run_install();
216 }
217
218 if let Commands::Uninstall { yes, purge_data } = &cli.command {
219 return crate::install::run_uninstall(*yes, *purge_data);
220 }
221
222 if let Commands::Migrate = &cli.command {
223 let applied = crate::migrate::run_migrations(&db_path)?;
224 if applied.is_empty() {
225 println!("already at 4.13 — nothing to do");
226 } else {
227 for step in &applied {
228 println!(" applied: {step}");
229 }
230 println!("migration complete");
231 }
232 return Ok(());
233 }
234
235 if let Commands::Daemon { action } = &cli.command {
236 return run_daemon(action, &db_path);
237 }
238
239 if let Commands::Upgrade { version, check } = &cli.command {
240 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
241 }
242
243 let kb = crate::open_kb(&db_path)?;
244
245 match cli.command {
246 Commands::Recall {
247 query,
248 budget,
249 top,
250 format,
251 include_sparks,
252 expand_deps,
253 allow_trim,
254 refine_mode,
255 source,
256 } => {
257 let result = kb.recall(
258 &query,
259 budget,
260 true,
261 include_sparks,
262 top,
263 &source,
264 &expand_deps,
265 allow_trim,
266 &refine_mode,
267 )?;
268 match format.as_str() {
269 "json" => println!(
270 "{}",
271 serde_json::to_string_pretty(&json!({
272 "trace_id": result.trace_id,
273 "knowledge": result.knowledge,
274 "sparks": result.sparks,
275 "empty": result.empty,
276 }))?
277 ),
278 "prompt" => {
279 for chunk in &result.knowledge {
280 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
281 println!("{content}\n---");
282 }
283 println!("<!-- innate_trace_id: {} -->", result.trace_id);
285 println!(
286 "<!-- innate_selected: {} -->",
287 result
288 .knowledge
289 .iter()
290 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
291 .collect::<Vec<_>>()
292 .join(",")
293 );
294 }
295 _ => {
296 for chunk in &result.knowledge {
297 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
298 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
299 let conf = chunk
300 .get("confidence")
301 .and_then(|v| v.as_f64())
302 .unwrap_or(0.5);
303 println!("[{id}] (conf={conf:.2})\n{content}\n");
304 }
305 if result.empty {
306 println!("(no results)");
307 }
308 }
309 }
310 }
311 Commands::Record {
312 trace_id,
313 query,
314 outcome,
315 used,
316 used_attribution,
317 used_partial,
318 output,
319 output_summary,
320 nomination,
321 source,
322 feedback,
323 feedback_kind,
324 feedback_actor,
325 feedback_reason,
326 task_state,
327 priority,
328 } => {
329 let used_ids = used.as_deref().map(|raw| {
330 raw.split(',')
331 .map(str::trim)
332 .filter(|id| !id.is_empty())
333 .map(str::to_string)
334 .collect::<Vec<_>>()
335 });
336 let used_ref = used_ids.as_deref();
337 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
339 match feedback.as_deref() {
340 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
341 (used_ids.clone(), None)
342 }
343 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
344 (None, used_ids.clone())
345 }
346 Some("up") => (None, None), Some("down") => (None, None),
348 _ => (None, None),
349 };
350 let fb_up_ref = fb_up.as_deref();
351 let fb_down_ref = fb_down.as_deref();
352 kb.record_detailed(
353 &trace_id,
354 query.as_deref(),
355 output.as_deref(),
356 output_summary.as_deref(),
357 outcome.as_deref(),
358 used_ref,
359 &used_attribution,
360 !used_partial,
361 fb_up_ref,
362 fb_down_ref,
363 &feedback_kind,
364 feedback_actor.as_deref(),
365 feedback_reason.as_deref(),
366 nomination.as_deref(),
367 priority,
368 task_state.as_deref(),
369 &source,
370 )?;
371 println!("recorded");
372 }
373 Commands::Add {
374 content,
375 kind,
376 trigger,
377 anti_trigger,
378 source,
379 skill_name,
380 } => {
381 let content = if kind == "skill" {
383 let p = std::path::Path::new(&content);
384 if p.exists() && p.is_file() {
385 std::fs::read_to_string(p).map_err(|e| {
386 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
387 })?
388 } else {
389 content
390 }
391 } else {
392 content
393 };
394 let id = kb.add(
395 &content,
396 &kind,
397 trigger.as_deref(),
398 anti_trigger.as_deref(),
399 &source,
400 skill_name.as_deref(),
401 )?;
402 println!("{id}");
403 }
404 Commands::Spark { content, trigger } => {
405 let id = kb.spark(&content, trigger.as_deref(), None)?;
406 println!("{id}");
407 }
408 Commands::Evolve {
409 trigger,
410 rebuild_embeddings,
411 } => {
412 if rebuild_embeddings {
413 let rebuilt = kb.rebuild_embeddings()?;
414 let report = kb.evolve(&trigger)?;
415 println!(
416 "{}",
417 serde_json::to_string_pretty(&json!({
418 "rebuilt_embeddings": rebuilt,
419 "evolve": report
420 }))?
421 );
422 } else {
423 let report = kb.evolve(&trigger)?;
424 println!("{}", serde_json::to_string_pretty(&report)?);
425 }
426 }
427 Commands::Inspect { id } => match id.as_deref() {
428 None => {
429 let info = kb.inspect()?;
430 println!("{}", serde_json::to_string_pretty(&info)?);
431 }
432 Some(id) => {
433 let detail = kb.inspect_id(id)?;
434 println!("{}", serde_json::to_string_pretty(&detail)?);
435 }
436 },
437 Commands::Approve { chunk_id } => {
438 kb.approve(&chunk_id)?;
439 println!("approved");
440 }
441 Commands::Archive { chunk_id, reason } => {
442 kb.archive(&chunk_id, &reason)?;
443 println!("archived");
444 }
445 Commands::Invalidate { chunk_id, reason } => {
446 kb.invalidate(&chunk_id, &reason)?;
447 println!("invalidated");
448 }
449 Commands::Restore { chunk_id } => {
450 kb.restore(&chunk_id)?;
451 println!("restored");
452 }
453 Commands::MatureSpark { spark_id, to } => {
454 kb.mature_spark(&spark_id, &to)?;
455 println!("matured");
456 }
457 Commands::PromoteSpark { spark_id, to } => {
458 let id = kb.promote_spark(&spark_id, &to)?;
459 println!("{id}");
460 }
461 Commands::DropSpark { spark_id, reason } => {
462 kb.drop_spark(&spark_id, &reason)?;
463 println!("dropped");
464 }
465 Commands::Mcp
466 | Commands::Install
467 | Commands::Uninstall { .. }
468 | Commands::Migrate
469 | Commands::Upgrade { .. }
470 | Commands::Daemon { .. } => unreachable!(),
471 }
472 Ok(())
473}
474
475fn default_pid_file() -> std::path::PathBuf {
480 dirs_next::home_dir()
481 .unwrap_or_else(|| std::path::PathBuf::from("."))
482 .join(".innate")
483 .join("daemon.pid")
484}
485
486fn default_state_db() -> std::path::PathBuf {
487 dirs_next::home_dir()
488 .unwrap_or_else(|| std::path::PathBuf::from("."))
489 .join(".innate")
490 .join("daemon_state.sqlite")
491}
492
493fn default_log_file() -> std::path::PathBuf {
494 dirs_next::home_dir()
495 .unwrap_or_else(|| std::path::PathBuf::from("."))
496 .join(".innate")
497 .join("daemon.log")
498}
499
500fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
501 match action {
502 DaemonCommands::Start {
503 watch,
504 pid_file,
505 state_db,
506 log_file,
507 } => {
508 let effective_watch: Vec<std::path::PathBuf> = if !watch.is_empty() {
510 watch.clone()
511 } else {
512 let s = crate::settings::load();
513 crate::settings::resolved_watch_dirs(&s)
514 .into_iter()
515 .map(std::path::PathBuf::from)
516 .collect()
517 };
518 crate::daemon::start(
519 &effective_watch,
520 db_path,
521 pid_file.as_deref().unwrap_or(&default_pid_file()),
522 state_db.as_deref().unwrap_or(&default_state_db()),
523 log_file.as_deref().unwrap_or(&default_log_file()),
524 )
525 }
526 DaemonCommands::Stop { pid_file } => {
527 crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
528 }
529 DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
530 state_db.as_deref().unwrap_or(&default_state_db()),
531 pid_file.as_deref().unwrap_or(&default_pid_file()),
532 ),
533 }
534}