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 Migrate,
138 Upgrade {
140 #[arg(long, value_name = "VERSION")]
142 version: Option<String>,
143 #[arg(long)]
145 check: bool,
146 },
147 Daemon {
149 #[command(subcommand)]
150 action: DaemonCommands,
151 },
152 Mcp,
154}
155
156#[derive(Subcommand)]
157pub enum DaemonCommands {
158 Start {
160 #[arg(long = "watch", value_name = "LOG_DIR")]
161 watch: Vec<std::path::PathBuf>,
162 #[arg(long, value_name = "PATH")]
163 pid_file: Option<std::path::PathBuf>,
164 #[arg(long, value_name = "PATH")]
165 state_db: Option<std::path::PathBuf>,
166 #[arg(long, value_name = "PATH")]
167 log_file: Option<std::path::PathBuf>,
168 },
169 Stop {
171 #[arg(long, value_name = "PATH")]
172 pid_file: Option<std::path::PathBuf>,
173 },
174 Status {
176 #[arg(long, value_name = "PATH")]
177 state_db: Option<std::path::PathBuf>,
178 #[arg(long, value_name = "PATH")]
179 pid_file: Option<std::path::PathBuf>,
180 },
181}
182
183pub fn run() -> anyhow::Result<()> {
184 let cli = Cli::parse();
185 let db_path = cli.db.unwrap_or_else(default_db);
186
187 if let Commands::Mcp = &cli.command {
188 return crate::mcp::run_server(db_path);
189 }
190
191 if let Commands::Install = &cli.command {
192 return crate::install::run_install();
193 }
194
195 if let Commands::Migrate = &cli.command {
196 let applied = crate::migrate::run_migrations(&db_path)?;
197 if applied.is_empty() {
198 println!("already at 4.5.1 — nothing to do");
199 } else {
200 for step in &applied {
201 println!(" applied: {step}");
202 }
203 println!("migration complete");
204 }
205 return Ok(());
206 }
207
208 if let Commands::Daemon { action } = &cli.command {
209 return run_daemon(action, &db_path);
210 }
211
212 if let Commands::Upgrade { version, check } = &cli.command {
213 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
214 }
215
216 let kb = KnowledgeBase::open(&db_path)?;
217
218 match cli.command {
219 Commands::Recall {
220 query,
221 budget,
222 top,
223 format,
224 include_sparks,
225 expand_deps,
226 allow_trim,
227 refine_mode,
228 source,
229 } => {
230 let result = kb.recall(
231 &query,
232 budget,
233 true,
234 include_sparks,
235 top,
236 &source,
237 &expand_deps,
238 allow_trim,
239 &refine_mode,
240 )?;
241 match format.as_str() {
242 "json" => println!(
243 "{}",
244 serde_json::to_string_pretty(&json!({
245 "trace_id": result.trace_id,
246 "knowledge": result.knowledge,
247 "sparks": result.sparks,
248 "empty": result.empty,
249 }))?
250 ),
251 "prompt" => {
252 for chunk in &result.knowledge {
253 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
254 println!("{content}\n---");
255 }
256 println!("<!-- innate_trace_id: {} -->", result.trace_id);
258 println!(
259 "<!-- innate_selected: {} -->",
260 result
261 .knowledge
262 .iter()
263 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
264 .collect::<Vec<_>>()
265 .join(",")
266 );
267 }
268 _ => {
269 for chunk in &result.knowledge {
270 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
271 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
272 let conf = chunk
273 .get("confidence")
274 .and_then(|v| v.as_f64())
275 .unwrap_or(0.5);
276 println!("[{id}] (conf={conf:.2})\n{content}\n");
277 }
278 if result.empty {
279 println!("(no results)");
280 }
281 }
282 }
283 }
284 Commands::Record {
285 trace_id,
286 query,
287 outcome,
288 used,
289 output_summary,
290 nomination,
291 source,
292 feedback,
293 priority,
294 } => {
295 let used_ref: Option<&[String]> = if used.is_empty() { None } else { Some(&used) };
296 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
298 match feedback.as_deref() {
299 Some("up") if !used.is_empty() => (Some(used.clone()), None),
300 Some("down") if !used.is_empty() => (None, Some(used.clone())),
301 Some("up") => (None, None), Some("down") => (None, None),
303 _ => (None, None),
304 };
305 let fb_up_ref = fb_up.as_deref();
306 let fb_down_ref = fb_down.as_deref();
307 kb.record(
308 &trace_id,
309 query.as_deref(),
310 None,
311 output_summary.as_deref(),
312 outcome.as_deref(),
313 used_ref,
314 fb_up_ref,
315 fb_down_ref,
316 nomination.as_deref(),
317 priority,
318 &source,
319 )?;
320 println!("recorded");
321 }
322 Commands::Add {
323 content,
324 kind,
325 trigger,
326 anti_trigger,
327 source,
328 skill_name,
329 } => {
330 let content = if kind == "skill" {
332 let p = std::path::Path::new(&content);
333 if p.exists() && p.is_file() {
334 std::fs::read_to_string(p).map_err(|e| {
335 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
336 })?
337 } else {
338 content
339 }
340 } else {
341 content
342 };
343 let id = kb.add(
344 &content,
345 &kind,
346 trigger.as_deref(),
347 anti_trigger.as_deref(),
348 &source,
349 skill_name.as_deref(),
350 )?;
351 println!("{id}");
352 }
353 Commands::Spark { content, trigger } => {
354 let id = kb.spark(&content, trigger.as_deref(), None)?;
355 println!("{id}");
356 }
357 Commands::Evolve {
358 trigger,
359 rebuild_embeddings,
360 } => {
361 if rebuild_embeddings {
362 let rebuilt = kb.rebuild_embeddings()?;
363 println!("rebuilt {rebuilt} embeddings");
364 } else {
365 let report = kb.evolve(&trigger)?;
366 println!("{}", serde_json::to_string_pretty(&report)?);
367 }
368 }
369 Commands::Inspect { id } => match id.as_deref() {
370 None => {
371 let info = kb.inspect()?;
372 println!("{}", serde_json::to_string_pretty(&info)?);
373 }
374 Some(id) => {
375 let detail = kb.inspect_id(id)?;
376 println!("{}", serde_json::to_string_pretty(&detail)?);
377 }
378 },
379 Commands::Approve { chunk_id } => {
380 kb.approve(&chunk_id)?;
381 println!("approved");
382 }
383 Commands::Archive { chunk_id, reason } => {
384 kb.archive(&chunk_id, &reason)?;
385 println!("archived");
386 }
387 Commands::Invalidate { chunk_id, reason } => {
388 kb.invalidate(&chunk_id, &reason)?;
389 println!("invalidated");
390 }
391 Commands::Restore { chunk_id } => {
392 kb.restore(&chunk_id)?;
393 println!("restored");
394 }
395 Commands::MatureSpark { spark_id, to } => {
396 kb.mature_spark(&spark_id, &to)?;
397 println!("matured");
398 }
399 Commands::PromoteSpark { spark_id, to } => {
400 let id = kb.promote_spark(&spark_id, &to)?;
401 println!("{id}");
402 }
403 Commands::DropSpark { spark_id, reason } => {
404 kb.drop_spark(&spark_id, &reason)?;
405 println!("dropped");
406 }
407 Commands::Mcp
408 | Commands::Install
409 | Commands::Migrate
410 | Commands::Upgrade { .. }
411 | Commands::Daemon { .. } => unreachable!(),
412 }
413 Ok(())
414}
415
416fn default_pid_file() -> std::path::PathBuf {
421 dirs_next::home_dir()
422 .unwrap_or_else(|| std::path::PathBuf::from("."))
423 .join(".innate")
424 .join("daemon.pid")
425}
426
427fn default_state_db() -> std::path::PathBuf {
428 dirs_next::home_dir()
429 .unwrap_or_else(|| std::path::PathBuf::from("."))
430 .join(".innate")
431 .join("daemon_state.sqlite")
432}
433
434fn default_log_file() -> std::path::PathBuf {
435 dirs_next::home_dir()
436 .unwrap_or_else(|| std::path::PathBuf::from("."))
437 .join(".innate")
438 .join("daemon.log")
439}
440
441fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
442 match action {
443 DaemonCommands::Start {
444 watch,
445 pid_file,
446 state_db,
447 log_file,
448 } => crate::daemon::start(
449 watch,
450 db_path,
451 pid_file.as_deref().unwrap_or(&default_pid_file()),
452 state_db.as_deref().unwrap_or(&default_state_db()),
453 log_file.as_deref().unwrap_or(&default_log_file()),
454 ),
455 DaemonCommands::Stop { pid_file } => {
456 crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
457 }
458 DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
459 state_db.as_deref().unwrap_or(&default_state_db()),
460 pid_file.as_deref().unwrap_or(&default_pid_file()),
461 ),
462 }
463}