Skip to main content

innate_core/
cli.rs

1//! CLI commands — thin wrapper over KnowledgeBase.
2
3use 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    /// Search the knowledge base
28    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        /// Dependency expansion: false (default) | direct | closure
39        #[arg(long, default_value = "false")]
40        expand_deps: String,
41        /// Allow Refiner to trim blocks that don't fit the budget
42        #[arg(long)]
43        allow_trim: bool,
44        /// Refine mode written to usage_trace: off (default) | trim | adapt
45        #[arg(long, default_value = "off")]
46        refine_mode: String,
47        /// Event source written to usage_trace (mcp | sdk | cli | hook | daemon | augmented)
48        #[arg(long, default_value = "cli")]
49        source: String,
50    },
51    /// Close a trace with outcome
52    Record {
53        trace_id: String,
54        #[arg(long)]
55        query: Option<String>,
56        #[arg(long)]
57        outcome: Option<String>,
58        /// Comma-separated chunk ids. An explicit empty value means "known none".
59        #[arg(long)]
60        used: Option<String>,
61        #[arg(long, default_value = "explicit")]
62        used_attribution: String,
63        /// Treat --used as partial attribution; omitted selected chunks are not penalized.
64        #[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        /// Explicit feedback: up or down (applied to --used chunks if provided)
75        #[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 a knowledge chunk
89    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    /// Capture a spark (idea)
103    Spark {
104        content: String,
105        #[arg(long)]
106        trigger: Option<String>,
107    },
108    /// Distil logs + curate
109    Evolve {
110        #[arg(long, default_value = "manual")]
111        trigger: String,
112        /// Rebuild embeddings for chunks with embed_version=0 or < meta.embed_version
113        #[arg(long)]
114        rebuild_embeddings: bool,
115    },
116    /// Health check — no arg = library summary; chunk_id or trace_id = detail view
117    Inspect { id: Option<String> },
118    /// Approve a pending chunk
119    Approve { chunk_id: String },
120    /// Archive a chunk
121    Archive {
122        chunk_id: String,
123        #[arg(long, default_value = "stale")]
124        reason: String,
125    },
126    /// Invalidate a chunk
127    Invalidate {
128        chunk_id: String,
129        #[arg(long, default_value = "")]
130        reason: String,
131    },
132    /// Restore an archived chunk
133    Restore { chunk_id: String },
134    /// Mature a spark
135    MatureSpark { spark_id: String, to: String },
136    /// Promote a spark to knowledge
137    PromoteSpark {
138        spark_id: String,
139        #[arg(long, default_value = "note")]
140        to: String,
141    },
142    /// Drop a spark
143    DropSpark {
144        spark_id: String,
145        #[arg(long, default_value = "")]
146        reason: String,
147    },
148    /// Backup the database to Cloudflare R2
149    Backup {
150        #[command(subcommand)]
151        action: BackupCommands,
152    },
153    /// Interactive setup wizard — configure agents to use Innate MCP server
154    Install,
155    /// Remove Innate from all configured agents and PATH
156    Uninstall {
157        /// Skip confirmation prompts
158        #[arg(long, short = 'y')]
159        yes: bool,
160        /// Also delete knowledge data (~/.innate/). Cannot be undone.
161        #[arg(long)]
162        purge_data: bool,
163    },
164    /// Upgrade database schema to current version
165    Migrate,
166    /// Upgrade the innate binary to the latest (or specified) release
167    Upgrade {
168        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
169        #[arg(long, value_name = "VERSION")]
170        version: Option<String>,
171        /// Only report whether an upgrade is available; do not install
172        #[arg(long)]
173        check: bool,
174    },
175    /// Daemon control (Linux only)
176    Daemon {
177        #[command(subcommand)]
178        action: DaemonCommands,
179    },
180    /// Start MCP stdio server
181    Mcp,
182    /// Agent hook handlers (called by agent hooks; reads payload from stdin)
183    Hook {
184        #[command(subcommand)]
185        action: HookCommands,
186    },
187}
188
189#[derive(Subcommand)]
190pub enum BackupCommands {
191    /// Backup the database now (respects auto_backup_interval_hours check; use --force to skip)
192    Run {
193        /// Skip the 24-hour interval check and backup immediately
194        #[arg(long)]
195        force: bool,
196    },
197    /// Show last backup time and R2 config status
198    Status,
199    /// List all backups stored in R2
200    List,
201    /// Delete backups older than retention_days (keeps min_backups regardless)
202    Prune,
203}
204
205#[derive(Subcommand)]
206pub enum HookCommands {
207    /// Process a Claude Code Stop hook payload from stdin and record session events
208    Stop,
209}
210
211#[derive(Subcommand)]
212pub enum DaemonCommands {
213    /// Start the background log-watcher daemon
214    Start {
215        #[arg(long = "watch", value_name = "LOG_DIR")]
216        watch: Vec<std::path::PathBuf>,
217        #[arg(long, value_name = "PATH")]
218        pid_file: Option<std::path::PathBuf>,
219        #[arg(long, value_name = "PATH")]
220        state_db: Option<std::path::PathBuf>,
221        #[arg(long, value_name = "PATH")]
222        log_file: Option<std::path::PathBuf>,
223    },
224    /// Stop a running daemon
225    Stop {
226        #[arg(long, value_name = "PATH")]
227        pid_file: Option<std::path::PathBuf>,
228    },
229    /// Show daemon status
230    Status {
231        #[arg(long, value_name = "PATH")]
232        state_db: Option<std::path::PathBuf>,
233        #[arg(long, value_name = "PATH")]
234        pid_file: Option<std::path::PathBuf>,
235    },
236}
237
238pub fn run() -> anyhow::Result<()> {
239    let cli = Cli::parse();
240    let db_path = cli.db.unwrap_or_else(default_db);
241
242    if let Commands::Mcp = &cli.command {
243        return crate::mcp::run_server(db_path);
244    }
245
246    if let Commands::Install = &cli.command {
247        return crate::install::run_install();
248    }
249
250    if let Commands::Uninstall { yes, purge_data } = &cli.command {
251        return crate::install::run_uninstall(*yes, *purge_data);
252    }
253
254    if let Commands::Migrate = &cli.command {
255        let applied = crate::migrate::run_migrations(&db_path)?;
256        if applied.is_empty() {
257            println!("already at 4.14 — nothing to do");
258        } else {
259            for step in &applied {
260                println!("  applied: {step}");
261            }
262            println!("migration complete");
263        }
264        return Ok(());
265    }
266
267    if let Commands::Daemon { action } = &cli.command {
268        return run_daemon(action, &db_path);
269    }
270
271    if let Commands::Backup { action } = &cli.command {
272        return run_backup(action, &db_path);
273    }
274
275    if let Commands::Upgrade { version, check } = &cli.command {
276        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
277    }
278
279    if let Commands::Hook { action } = &cli.command {
280        return match action {
281            HookCommands::Stop => run_hook_stop(),
282        };
283    }
284
285    let kb = crate::open_kb(&db_path)?;
286
287    match cli.command {
288        Commands::Recall {
289            query,
290            budget,
291            top,
292            format,
293            include_sparks,
294            expand_deps,
295            allow_trim,
296            refine_mode,
297            source,
298        } => {
299            let result = kb.recall(
300                &query,
301                budget,
302                true,
303                include_sparks,
304                top,
305                &source,
306                &expand_deps,
307                allow_trim,
308                &refine_mode,
309            )?;
310            match format.as_str() {
311                "json" => println!(
312                    "{}",
313                    serde_json::to_string_pretty(&json!({
314                        "trace_id": result.trace_id,
315                        "knowledge": result.knowledge,
316                        "sparks": result.sparks,
317                        "empty": result.empty,
318                    }))?
319                ),
320                "prompt" => {
321                    for chunk in &result.knowledge {
322                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
323                        println!("{content}\n---");
324                    }
325                    // metadata at end (§九 CLI contract)
326                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
327                    println!(
328                        "<!-- innate_selected: {} -->",
329                        result
330                            .knowledge
331                            .iter()
332                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
333                            .collect::<Vec<_>>()
334                            .join(",")
335                    );
336                }
337                _ => {
338                    for chunk in &result.knowledge {
339                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
340                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
341                        let conf = chunk
342                            .get("confidence")
343                            .and_then(|v| v.as_f64())
344                            .unwrap_or(0.5);
345                        println!("[{id}] (conf={conf:.2})\n{content}\n");
346                    }
347                    if result.empty {
348                        println!("(no results)");
349                    }
350                }
351            }
352        }
353        Commands::Record {
354            trace_id,
355            query,
356            outcome,
357            used,
358            used_attribution,
359            used_partial,
360            output,
361            output_summary,
362            nomination,
363            source,
364            feedback,
365            feedback_kind,
366            feedback_actor,
367            feedback_reason,
368            task_state,
369            priority,
370        } => {
371            let used_ids = used.as_deref().map(|raw| {
372                raw.split(',')
373                    .map(str::trim)
374                    .filter(|id| !id.is_empty())
375                    .map(str::to_string)
376                    .collect::<Vec<_>>()
377            });
378            let used_ref = used_ids.as_deref();
379            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
380            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
381                match feedback.as_deref() {
382                    Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
383                        (used_ids.clone(), None)
384                    }
385                    Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
386                        (None, used_ids.clone())
387                    }
388                    Some("up") => (None, None), // no used chunks — ignore per design
389                    Some("down") => (None, None),
390                    _ => (None, None),
391                };
392            let fb_up_ref = fb_up.as_deref();
393            let fb_down_ref = fb_down.as_deref();
394            kb.record_detailed(
395                &trace_id,
396                query.as_deref(),
397                output.as_deref(),
398                output_summary.as_deref(),
399                outcome.as_deref(),
400                used_ref,
401                &used_attribution,
402                !used_partial,
403                fb_up_ref,
404                fb_down_ref,
405                &feedback_kind,
406                feedback_actor.as_deref(),
407                feedback_reason.as_deref(),
408                nomination.as_deref(),
409                priority,
410                task_state.as_deref(),
411                &source,
412            )?;
413            println!("recorded");
414        }
415        Commands::Add {
416            content,
417            kind,
418            trigger,
419            anti_trigger,
420            source,
421            skill_name,
422        } => {
423            // If kind=skill and content is a readable file path, load its content.
424            let content = if kind == "skill" {
425                let p = std::path::Path::new(&content);
426                if p.exists() && p.is_file() {
427                    std::fs::read_to_string(p).map_err(|e| {
428                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
429                    })?
430                } else {
431                    content
432                }
433            } else {
434                content
435            };
436            let id = kb.add(
437                &content,
438                &kind,
439                trigger.as_deref(),
440                anti_trigger.as_deref(),
441                &source,
442                skill_name.as_deref(),
443            )?;
444            println!("{id}");
445        }
446        Commands::Spark { content, trigger } => {
447            let id = kb.spark(&content, trigger.as_deref(), None)?;
448            println!("{id}");
449        }
450        Commands::Evolve {
451            trigger,
452            rebuild_embeddings,
453        } => {
454            if rebuild_embeddings {
455                let rebuilt = kb.rebuild_embeddings()?;
456                let report = kb.evolve(&trigger)?;
457                println!(
458                    "{}",
459                    serde_json::to_string_pretty(&json!({
460                        "rebuilt_embeddings": rebuilt,
461                        "evolve": report
462                    }))?
463                );
464            } else {
465                let report = kb.evolve(&trigger)?;
466                println!("{}", serde_json::to_string_pretty(&report)?);
467            }
468        }
469        Commands::Inspect { id } => match id.as_deref() {
470            None => {
471                let info = kb.inspect()?;
472                println!("{}", serde_json::to_string_pretty(&info)?);
473            }
474            Some(id) => {
475                let detail = kb.inspect_id(id)?;
476                println!("{}", serde_json::to_string_pretty(&detail)?);
477            }
478        },
479        Commands::Approve { chunk_id } => {
480            kb.approve(&chunk_id)?;
481            println!("approved");
482        }
483        Commands::Archive { chunk_id, reason } => {
484            kb.archive(&chunk_id, &reason)?;
485            println!("archived");
486        }
487        Commands::Invalidate { chunk_id, reason } => {
488            kb.invalidate(&chunk_id, &reason)?;
489            println!("invalidated");
490        }
491        Commands::Restore { chunk_id } => {
492            kb.restore(&chunk_id)?;
493            println!("restored");
494        }
495        Commands::MatureSpark { spark_id, to } => {
496            kb.mature_spark(&spark_id, &to)?;
497            println!("matured");
498        }
499        Commands::PromoteSpark { spark_id, to } => {
500            let id = kb.promote_spark(&spark_id, &to)?;
501            println!("{id}");
502        }
503        Commands::DropSpark { spark_id, reason } => {
504            kb.drop_spark(&spark_id, &reason)?;
505            println!("dropped");
506        }
507        Commands::Mcp
508        | Commands::Install
509        | Commands::Uninstall { .. }
510        | Commands::Migrate
511        | Commands::Upgrade { .. }
512        | Commands::Daemon { .. }
513        | Commands::Backup { .. }
514        | Commands::Hook { .. } => unreachable!(),
515    }
516    Ok(())
517}
518
519// ---------------------------------------------------------------------------
520// Daemon implementation
521// ---------------------------------------------------------------------------
522
523fn default_pid_file() -> std::path::PathBuf {
524    dirs_next::home_dir()
525        .unwrap_or_else(|| std::path::PathBuf::from("."))
526        .join(".innate")
527        .join("daemon.pid")
528}
529
530fn default_state_db() -> std::path::PathBuf {
531    dirs_next::home_dir()
532        .unwrap_or_else(|| std::path::PathBuf::from("."))
533        .join(".innate")
534        .join("daemon_state.sqlite")
535}
536
537fn default_log_file() -> std::path::PathBuf {
538    dirs_next::home_dir()
539        .unwrap_or_else(|| std::path::PathBuf::from("."))
540        .join(".innate")
541        .join("daemon.log")
542}
543
544fn run_daemon(action: &DaemonCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
545    match action {
546        DaemonCommands::Start {
547            watch,
548            pid_file,
549            state_db,
550            log_file,
551        } => {
552            // Merge CLI --watch dirs with settings watch_dirs when CLI provides none.
553            let effective_watch: Vec<std::path::PathBuf> = if !watch.is_empty() {
554                watch.clone()
555            } else {
556                let s = crate::settings::load();
557                crate::settings::resolved_watch_dirs(&s)
558                    .into_iter()
559                    .map(std::path::PathBuf::from)
560                    .collect()
561            };
562            crate::daemon::start(
563                &effective_watch,
564                db_path,
565                pid_file.as_deref().unwrap_or(&default_pid_file()),
566                state_db.as_deref().unwrap_or(&default_state_db()),
567                log_file.as_deref().unwrap_or(&default_log_file()),
568            )
569        }
570        DaemonCommands::Stop { pid_file } => {
571            crate::daemon::stop(pid_file.as_deref().unwrap_or(&default_pid_file()))
572        }
573        DaemonCommands::Status { state_db, pid_file } => crate::daemon::status(
574            state_db.as_deref().unwrap_or(&default_state_db()),
575            pid_file.as_deref().unwrap_or(&default_pid_file()),
576        ),
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Backup implementation
582// ---------------------------------------------------------------------------
583
584fn run_backup(action: &BackupCommands, db_path: &std::path::Path) -> anyhow::Result<()> {
585    use crate::backup::R2BackupService;
586
587    let settings = crate::settings::load();
588    let cfg = settings.backup.as_ref().ok_or_else(|| {
589        anyhow::anyhow!(
590            "No backup config found in ~/.innate/settings.json.\n\
591             Add a \"backup\" section with \"enable\": true and \"r2\" credentials to enable R2 backup."
592        )
593    })?;
594
595    // status works regardless of enable flag
596    if let BackupCommands::Status = action {
597        use crate::backup::R2BackupService;
598        let state = R2BackupService::last_backup_state();
599        println!("R2 backup enabled : {}", cfg.enable);
600        println!("R2 bucket         : {}", cfg.r2.as_ref().map(|r| r.bucket.as_str()).unwrap_or("-"));
601        println!("Last backup       : {}", state.last_backup_at.as_deref().unwrap_or("never"));
602        println!("Last backup key   : {}", state.last_backup_key.as_deref().unwrap_or("-"));
603        let due = R2BackupService::needs_backup(cfg.auto_backup_interval_hours);
604        println!("Backup due        : {}", if cfg.enable && due { "yes" } else if !cfg.enable { "disabled" } else { "no" });
605        println!("Interval (h)      : {}", cfg.auto_backup_interval_hours);
606        println!("Retention (days)  : {}", cfg.retention_days);
607        println!("Min backups       : {}", cfg.min_backups);
608        return Ok(());
609    }
610
611    if !cfg.enable {
612        anyhow::bail!(
613            "R2 backup is disabled (backup.enable = false).\n\
614             Set \"enable\": true in the backup section of ~/.innate/settings.json to activate."
615        );
616    }
617    let r2_cfg = cfg.r2.as_ref().ok_or_else(|| {
618        anyhow::anyhow!("backup.r2 not configured in ~/.innate/settings.json")
619    })?;
620
621    match action {
622        BackupCommands::Run { force } => {
623            if !force && !R2BackupService::needs_backup(cfg.auto_backup_interval_hours) {
624                let state = R2BackupService::last_backup_state();
625                println!(
626                    "backup not due yet (last: {}; interval: {}h). Use --force to override.",
627                    state.last_backup_at.as_deref().unwrap_or("never"),
628                    cfg.auto_backup_interval_hours
629                );
630                return Ok(());
631            }
632            println!("Starting backup to R2 bucket '{}'…", r2_cfg.bucket);
633            let svc = R2BackupService::from_config(r2_cfg)?;
634            let result = svc.backup_now(db_path, cfg.retention_days, cfg.min_backups)?;
635            println!("Backed up: {} ({} bytes)", result.key, result.size_bytes);
636            if !result.prune.deleted.is_empty() {
637                println!("Pruned {} old backup(s):", result.prune.deleted.len());
638                for k in &result.prune.deleted {
639                    println!("  - {k}");
640                }
641            }
642            if result.prune.protected_by_min > 0 {
643                println!(
644                    "  ({} old backup(s) kept to satisfy min_backups={})",
645                    result.prune.protected_by_min, cfg.min_backups
646                );
647            }
648            println!("Done. {} backup(s) remain in R2.", result.prune.kept);
649        }
650        BackupCommands::Status => unreachable!(), // handled above
651        BackupCommands::List => {
652            let svc = R2BackupService::from_config(r2_cfg)?;
653            let backups = svc.list_backups()?;
654            if backups.is_empty() {
655                println!("No backups found in R2.");
656            } else {
657                println!("{} backup(s):", backups.len());
658                for b in &backups {
659                    println!("  {} | {} | {} bytes", b.last_modified, b.key, b.size_bytes);
660                }
661            }
662        }
663        BackupCommands::Prune => {
664            let svc = R2BackupService::from_config(r2_cfg)?;
665            let result = svc.prune_old_backups(cfg.retention_days, cfg.min_backups)?;
666            if result.deleted.is_empty() {
667                println!("Nothing to prune ({} backup(s) kept).", result.kept);
668            } else {
669                println!("Deleted {} backup(s):", result.deleted.len());
670                for k in &result.deleted {
671                    println!("  - {k}");
672                }
673                if result.protected_by_min > 0 {
674                    println!(
675                        "  ({} old backup(s) kept to satisfy min_backups={})",
676                        result.protected_by_min, cfg.min_backups
677                    );
678                }
679                println!("{} backup(s) remain.", result.kept);
680            }
681        }
682    }
683    Ok(())
684}
685
686// ---------------------------------------------------------------------------
687// Hook implementation
688// ---------------------------------------------------------------------------
689
690fn extract_content_text(content: Option<&serde_json::Value>) -> String {
691    match content {
692        None => String::new(),
693        Some(serde_json::Value::String(s)) => s.clone(),
694        Some(serde_json::Value::Array(arr)) => arr
695            .iter()
696            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
697            .collect::<Vec<_>>()
698            .join(" "),
699        _ => String::new(),
700    }
701}
702
703fn run_hook_stop() -> anyhow::Result<()> {
704    use std::io::{Read, Write};
705
706    let mut input = String::new();
707    std::io::stdin().read_to_string(&mut input)?;
708
709    let data: serde_json::Value =
710        serde_json::from_str(&input).unwrap_or(serde_json::Value::Null);
711
712    let empty = vec![];
713    let transcript = data
714        .get("transcript")
715        .or_else(|| data.get("messages"))
716        .and_then(|v| v.as_array())
717        .unwrap_or(&empty);
718
719    let mut query = String::new();
720    let mut summary = String::new();
721
722    for m in transcript.iter().rev() {
723        let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("");
724        if query.is_empty() && role == "user" {
725            query = extract_content_text(m.get("content"))
726                .chars()
727                .take(200)
728                .collect();
729        }
730        if summary.is_empty() && role == "assistant" {
731            summary = extract_content_text(m.get("content"))
732                .chars()
733                .take(400)
734                .collect();
735        }
736        if !query.is_empty() && !summary.is_empty() {
737            break;
738        }
739    }
740
741    let mut events: Vec<serde_json::Value> = Vec::new();
742    if !query.is_empty() {
743        events.push(json!({"event_type": "session_start", "query": query.trim()}));
744    }
745    if !summary.is_empty() {
746        events.push(json!({"event_type": "tool_success", "output_summary": summary.trim(), "outcome": "ok"}));
747    }
748    events.push(json!({"event_type": "session_end"}));
749
750    let log_path = dirs_next::home_dir()
751        .unwrap_or_else(|| PathBuf::from("."))
752        .join(".innate")
753        .join("sessions")
754        .join("session.log");
755
756    if let Some(parent) = log_path.parent() {
757        std::fs::create_dir_all(parent)?;
758    }
759
760    let mut file = std::fs::OpenOptions::new()
761        .create(true)
762        .append(true)
763        .open(&log_path)?;
764
765    for event in &events {
766        writeln!(file, "{}", serde_json::to_string(event)?)?;
767    }
768
769    Ok(())
770}