1use std::path::PathBuf;
4
5use clap::{Parser, Subcommand};
6use serde_json::json;
7
8use crate::kb::KnowledgeBase;
9
10fn default_db() -> PathBuf {
11 dirs_next::home_dir()
12 .unwrap_or_else(|| PathBuf::from("."))
13 .join(".innate")
14 .join("personal.db")
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, value_delimiter = ',')]
61 used: Vec<String>,
62 #[arg(long)]
63 output_summary: Option<String>,
64 #[arg(long)]
65 nomination: Option<String>,
66 #[arg(long, default_value = "cli")]
67 source: String,
68 #[arg(long)]
70 feedback: Option<String>,
71 #[arg(long, default_value = "0")]
72 priority: i64,
73 },
74 Add {
76 content: String,
77 #[arg(long, default_value = "note")]
78 kind: String,
79 #[arg(long)]
80 trigger: Option<String>,
81 #[arg(long)]
82 anti_trigger: Option<String>,
83 #[arg(long, default_value = "chat")]
84 source: String,
85 #[arg(long)]
86 skill_name: Option<String>,
87 },
88 Spark {
90 content: String,
91 #[arg(long)]
92 trigger: Option<String>,
93 },
94 Evolve {
96 #[arg(long, default_value = "manual")]
97 trigger: String,
98 #[arg(long)]
100 rebuild_embeddings: bool,
101 },
102 Inspect { id: Option<String> },
104 Approve { chunk_id: String },
106 Archive {
108 chunk_id: String,
109 #[arg(long, default_value = "stale")]
110 reason: String,
111 },
112 Invalidate {
114 chunk_id: String,
115 #[arg(long, default_value = "")]
116 reason: String,
117 },
118 Restore { chunk_id: String },
120 MatureSpark { spark_id: String, to: String },
122 PromoteSpark {
124 spark_id: String,
125 #[arg(long, default_value = "note")]
126 to: String,
127 },
128 DropSpark {
130 spark_id: String,
131 #[arg(long, default_value = "")]
132 reason: String,
133 },
134 Install,
136 Uninstall {
138 #[arg(long, short = 'y')]
140 yes: bool,
141 #[arg(long)]
143 purge_data: bool,
144 },
145 Migrate,
147 Upgrade {
149 #[arg(long, value_name = "VERSION")]
151 version: Option<String>,
152 #[arg(long)]
154 check: bool,
155 },
156 Daemon {
158 #[command(subcommand)]
159 action: DaemonCommands,
160 },
161 Mcp,
163}
164
165#[derive(Subcommand)]
166pub enum DaemonCommands {
167 Start {
169 #[arg(long = "watch", value_name = "LOG_DIR")]
170 watch: Vec<std::path::PathBuf>,
171 #[arg(long, value_name = "PATH")]
172 pid_file: Option<std::path::PathBuf>,
173 #[arg(long, value_name = "PATH")]
174 state_db: Option<std::path::PathBuf>,
175 #[arg(long, value_name = "PATH")]
176 log_file: Option<std::path::PathBuf>,
177 },
178 Stop {
180 #[arg(long, value_name = "PATH")]
181 pid_file: Option<std::path::PathBuf>,
182 },
183 Status {
185 #[arg(long, value_name = "PATH")]
186 state_db: Option<std::path::PathBuf>,
187 #[arg(long, value_name = "PATH")]
188 pid_file: Option<std::path::PathBuf>,
189 },
190}
191
192pub fn run() -> anyhow::Result<()> {
193 let cli = Cli::parse();
194 let db_path = cli.db.unwrap_or_else(default_db);
195
196 if let Commands::Mcp = &cli.command {
197 return crate::mcp::run_server(db_path);
198 }
199
200 if let Commands::Install = &cli.command {
201 return crate::install::run_install();
202 }
203
204 if let Commands::Uninstall { yes, purge_data } = &cli.command {
205 return crate::install::run_uninstall(*yes, *purge_data);
206 }
207
208 if let Commands::Migrate = &cli.command {
209 let applied = crate::migrate::run_migrations(&db_path)?;
210 if applied.is_empty() {
211 println!("already at 4.5.1 — nothing to do");
212 } else {
213 for step in &applied {
214 println!(" applied: {step}");
215 }
216 println!("migration complete");
217 }
218 return Ok(());
219 }
220
221 if let Commands::Daemon { action } = &cli.command {
222 return run_daemon(action, &db_path);
223 }
224
225 if let Commands::Upgrade { version, check } = &cli.command {
226 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
227 }
228
229 let kb = KnowledgeBase::open(&db_path)?;
230
231 match cli.command {
232 Commands::Recall {
233 query,
234 budget,
235 top,
236 format,
237 include_sparks,
238 expand_deps,
239 allow_trim,
240 refine_mode,
241 source,
242 } => {
243 let result = kb.recall(
244 &query,
245 budget,
246 true,
247 include_sparks,
248 top,
249 &source,
250 &expand_deps,
251 allow_trim,
252 &refine_mode,
253 )?;
254 match format.as_str() {
255 "json" => println!(
256 "{}",
257 serde_json::to_string_pretty(&json!({
258 "trace_id": result.trace_id,
259 "knowledge": result.knowledge,
260 "sparks": result.sparks,
261 "empty": result.empty,
262 }))?
263 ),
264 "prompt" => {
265 for chunk in &result.knowledge {
266 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
267 println!("{content}\n---");
268 }
269 println!("<!-- innate_trace_id: {} -->", result.trace_id);
271 println!(
272 "<!-- innate_selected: {} -->",
273 result
274 .knowledge
275 .iter()
276 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
277 .collect::<Vec<_>>()
278 .join(",")
279 );
280 }
281 _ => {
282 for chunk in &result.knowledge {
283 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
284 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
285 let conf = chunk
286 .get("confidence")
287 .and_then(|v| v.as_f64())
288 .unwrap_or(0.5);
289 println!("[{id}] (conf={conf:.2})\n{content}\n");
290 }
291 if result.empty {
292 println!("(no results)");
293 }
294 }
295 }
296 }
297 Commands::Record {
298 trace_id,
299 query,
300 outcome,
301 used,
302 output_summary,
303 nomination,
304 source,
305 feedback,
306 priority,
307 } => {
308 let used_ref: Option<&[String]> = if used.is_empty() { None } else { Some(&used) };
309 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
311 match feedback.as_deref() {
312 Some("up") if !used.is_empty() => (Some(used.clone()), None),
313 Some("down") if !used.is_empty() => (None, Some(used.clone())),
314 Some("up") => (None, None), Some("down") => (None, None),
316 _ => (None, None),
317 };
318 let fb_up_ref = fb_up.as_deref();
319 let fb_down_ref = fb_down.as_deref();
320 kb.record(
321 &trace_id,
322 query.as_deref(),
323 None,
324 output_summary.as_deref(),
325 outcome.as_deref(),
326 used_ref,
327 fb_up_ref,
328 fb_down_ref,
329 nomination.as_deref(),
330 priority,
331 &source,
332 )?;
333 println!("recorded");
334 }
335 Commands::Add {
336 content,
337 kind,
338 trigger,
339 anti_trigger,
340 source,
341 skill_name,
342 } => {
343 let content = if kind == "skill" {
345 let p = std::path::Path::new(&content);
346 if p.exists() && p.is_file() {
347 std::fs::read_to_string(p).map_err(|e| {
348 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
349 })?
350 } else {
351 content
352 }
353 } else {
354 content
355 };
356 let id = kb.add(
357 &content,
358 &kind,
359 trigger.as_deref(),
360 anti_trigger.as_deref(),
361 &source,
362 skill_name.as_deref(),
363 )?;
364 println!("{id}");
365 }
366 Commands::Spark { content, trigger } => {
367 let id = kb.spark(&content, trigger.as_deref(), None)?;
368 println!("{id}");
369 }
370 Commands::Evolve {
371 trigger,
372 rebuild_embeddings,
373 } => {
374 if rebuild_embeddings {
375 let rebuilt = kb.rebuild_embeddings()?;
376 println!("rebuilt {rebuilt} embeddings");
377 } else {
378 let report = kb.evolve(&trigger)?;
379 println!("{}", serde_json::to_string_pretty(&report)?);
380 }
381 }
382 Commands::Inspect { id } => match id.as_deref() {
383 None => {
384 let info = kb.inspect()?;
385 println!("{}", serde_json::to_string_pretty(&info)?);
386 }
387 Some(id) => {
388 let detail = kb.inspect_id(id)?;
389 println!("{}", serde_json::to_string_pretty(&detail)?);
390 }
391 },
392 Commands::Approve { chunk_id } => {
393 kb.approve(&chunk_id)?;
394 println!("approved");
395 }
396 Commands::Archive { chunk_id, reason } => {
397 kb.archive(&chunk_id, &reason)?;
398 println!("archived");
399 }
400 Commands::Invalidate { chunk_id, reason } => {
401 kb.invalidate(&chunk_id, &reason)?;
402 println!("invalidated");
403 }
404 Commands::Restore { chunk_id } => {
405 kb.restore(&chunk_id)?;
406 println!("restored");
407 }
408 Commands::MatureSpark { spark_id, to } => {
409 kb.mature_spark(&spark_id, &to)?;
410 println!("matured");
411 }
412 Commands::PromoteSpark { spark_id, to } => {
413 let id = kb.promote_spark(&spark_id, &to)?;
414 println!("{id}");
415 }
416 Commands::DropSpark { spark_id, reason } => {
417 kb.drop_spark(&spark_id, &reason)?;
418 println!("dropped");
419 }
420 Commands::Mcp
421 | Commands::Install
422 | Commands::Uninstall { .. }
423 | Commands::Migrate
424 | Commands::Upgrade { .. }
425 | Commands::Daemon { .. } => unreachable!(),
426 }
427 Ok(())
428}
429
430fn default_pid_file() -> std::path::PathBuf {
435 dirs_next::home_dir()
436 .unwrap_or_else(|| std::path::PathBuf::from("."))
437 .join(".innate")
438 .join("daemon.pid")
439}
440
441fn default_state_db() -> std::path::PathBuf {
442 dirs_next::home_dir()
443 .unwrap_or_else(|| std::path::PathBuf::from("."))
444 .join(".innate")
445 .join("daemon_state.sqlite")
446}
447
448fn default_log_file() -> std::path::PathBuf {
449 dirs_next::home_dir()
450 .unwrap_or_else(|| std::path::PathBuf::from("."))
451 .join(".innate")
452 .join("daemon.log")
453}
454
455fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
456 match action {
457 DaemonCommands::Start {
458 watch,
459 pid_file,
460 state_db,
461 log_file,
462 } => crate::daemon::start(
463 watch,
464 db_path,
465 pid_file.as_deref().unwrap_or(&default_pid_file()),
466 state_db.as_deref().unwrap_or(&default_state_db()),
467 log_file.as_deref().unwrap_or(&default_log_file()),
468 ),
469 DaemonCommands::Stop { pid_file } => {
470 crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
471 }
472 DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
473 state_db.as_deref().unwrap_or(&default_state_db()),
474 pid_file.as_deref().unwrap_or(&default_pid_file()),
475 ),
476 }
477}