use anyhow::Result;
use chrono::{Local, TimeZone};
use clap::{Parser, Subcommand};
use minutes_core::apple_speech::{
self, AppleSpeechBenchmarkArtifactPaths, AppleSpeechBenchmarkRequest,
};
use minutes_core::autoresearch::{
self, DecodeHintEvalArtifactPaths, DecodeHintEvalComparisonArtifactPaths,
DecodeHintEvalComparisonRequest, DecodeHintEvalOptions, DecodeHintEvalRequest,
};
use minutes_core::capture::RecordingIntent;
use minutes_core::config::VALID_PARAKEET_MODELS;
use minutes_core::parakeet;
use minutes_core::{CaptureMode, Config, ContentType};
use serde::Serialize;
mod dashboard;
mod demo_data;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
const PARAKEET_NATIVE_VAD_WEIGHTS: &[u8] =
include_bytes!("../assets/parakeet/silero_vad_v5.safetensors");
#[derive(Serialize)]
struct AutomationRunRecord {
kind: String,
status: String,
output_path: String,
delivery_target: Option<String>,
delivery_payload_path: Option<String>,
generated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonMeta {
schema_version: u32,
generated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonEnvelope<T: Serialize> {
ok: bool,
command: String,
data: T,
meta: JsonMeta,
}
#[derive(Serialize)]
struct ContextSummaryOutput {
session: Option<minutes_core::context_store::ContextSession>,
links: Vec<minutes_core::context_store::ContextLink>,
events: Vec<minutes_core::context_store::ContextEvent>,
top_apps: Vec<ContextCount>,
top_windows: Vec<ContextCount>,
window: ContextWindow,
}
#[derive(Serialize)]
struct ContextSearchOutput {
results: Vec<minutes_core::context_store::ContextEvent>,
}
#[derive(Serialize)]
struct ContextMomentOutput {
session: Option<minutes_core::context_store::ContextSession>,
links: Vec<minutes_core::context_store::ContextLink>,
events: Vec<minutes_core::context_store::ContextEvent>,
window: ContextWindow,
}
#[derive(Serialize)]
struct ContextWindow {
start: String,
end: String,
}
#[derive(Serialize)]
struct ContextCount {
name: String,
count: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
#[cfg(feature = "parakeet")]
struct ParakeetHelperEnvelope<T: Serialize> {
ok: bool,
command: String,
#[serde(flatten)]
transcript: T,
meta: JsonMeta,
}
fn json_meta() -> JsonMeta {
JsonMeta {
schema_version: 1,
generated_at: Local::now().to_rfc3339(),
}
}
fn json_envelope<T: Serialize>(command: &str, data: T) -> JsonEnvelope<T> {
JsonEnvelope {
ok: true,
command: command.into(),
data,
meta: json_meta(),
}
}
#[cfg(feature = "parakeet")]
fn parakeet_helper_envelope<T: Serialize>(
command: &str,
transcript: T,
) -> ParakeetHelperEnvelope<T> {
ParakeetHelperEnvelope {
ok: true,
command: command.into(),
transcript,
meta: json_meta(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InterruptAction {
Continue,
ForceExit(i32),
}
fn handle_graceful_interrupt_with_shutdown(
stop_flag: &std::sync::atomic::AtomicBool,
first_message: &str,
shutdown: impl Fn(),
) -> InterruptAction {
use std::sync::atomic::Ordering;
shutdown();
if stop_flag.load(Ordering::Relaxed) {
eprintln!("\nForce quit.");
InterruptAction::ForceExit(1)
} else {
eprintln!("\n{}", first_message);
stop_flag.store(true, Ordering::Relaxed);
InterruptAction::Continue
}
}
fn handle_graceful_interrupt(
stop_flag: &std::sync::atomic::AtomicBool,
first_message: &str,
) -> InterruptAction {
handle_graceful_interrupt_with_shutdown(stop_flag, first_message, || {
minutes_core::parakeet_sidecar::shutdown_global_parakeet_sidecar();
})
}
fn install_parakeet_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
minutes_core::parakeet_sidecar::shutdown_global_parakeet_sidecar();
previous(panic_info);
}));
}
#[derive(Parser)]
#[command(
name = "minutes",
version,
long_version = concat!(
env!("CARGO_PKG_VERSION"),
"\nhttps://github.com/silverstein/minutes/releases/tag/v",
env!("CARGO_PKG_VERSION"),
),
about,
long_about = None,
)]
struct Cli {
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Record {
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
context: Option<String>,
#[arg(long, default_value = "meeting", value_parser = ["meeting", "quick-thought"])]
mode: String,
#[arg(long, default_value = "auto", value_parser = ["auto", "memo", "room", "call"])]
intent: String,
#[arg(long)]
allow_degraded: bool,
#[arg(long, value_name = "REASON")]
skip_audio_probe: Option<String>,
#[arg(short, long)]
language: Option<String>,
#[arg(short = 'D', long)]
device: Option<String>,
#[arg(long)]
source: Vec<String>,
#[arg(long)]
call: bool,
#[arg(long, value_name = "WAV_FILE")]
diagnose: Option<PathBuf>,
#[arg(long)]
mute_mic: bool,
#[arg(long)]
template: Option<String>,
},
MicToggle {
#[arg(long, value_parser = ["on", "off"])]
state: Option<String>,
},
Note {
text: String,
#[arg(short, long)]
meeting: Option<PathBuf>,
},
Stop,
Extend,
#[command(hide = true)]
ProcessQueue,
#[command(hide = true)]
ParakeetHelper {
#[arg(long)]
binary: String,
#[arg(long)]
model_path: PathBuf,
#[arg(long)]
audio_path: PathBuf,
#[arg(long)]
vocab_path: PathBuf,
#[arg(long)]
model_id: String,
#[arg(long, default_value_t = false)]
gpu: bool,
#[arg(long, default_value_t = false)]
fp16: bool,
#[arg(long)]
vad_path: Option<PathBuf>,
#[arg(long, default_value_t = 0.5)]
vad_threshold: f32,
},
#[command(hide = true)]
ParakeetBenchmark {
#[arg(long)]
binary: String,
#[arg(long)]
model_path: PathBuf,
#[arg(long)]
audio_path: PathBuf,
#[arg(long)]
vocab_path: PathBuf,
#[arg(long)]
model_id: String,
#[arg(long, default_value_t = false)]
gpu: bool,
#[arg(long)]
vad_path: Option<PathBuf>,
#[arg(long, default_value_t = 0.5)]
vad_threshold: f32,
},
#[command(hide = true)]
PreflightRecord {
#[arg(long, default_value = "meeting", value_parser = ["meeting", "quick-thought"])]
mode: String,
#[arg(long, default_value = "auto", value_parser = ["auto", "memo", "room", "call"])]
intent: String,
#[arg(long)]
allow_degraded: bool,
#[arg(long)]
json: bool,
},
#[command(hide = true)]
Autoresearch {
#[command(subcommand)]
action: AutoresearchAction,
},
AppleSpeech {
#[command(subcommand)]
action: AppleSpeechAction,
},
Capabilities {
#[arg(long)]
json: bool,
},
Status,
Jobs {
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
#[arg(short, long, default_value = "10")]
limit: usize,
},
Paths {
#[arg(long)]
json: bool,
},
Storage {
#[arg(long)]
json: bool,
},
Cleanup {
#[arg(long)]
apply: bool,
#[arg(long, value_name = "DURATION")]
older_than: Option<String>,
#[arg(long)]
json: bool,
},
Search {
query: String,
#[arg(short = 't', long)]
content_type: Option<String>,
#[arg(short, long)]
since: Option<String>,
#[arg(short, long, default_value = "10")]
limit: usize,
#[arg(long)]
intents_only: bool,
#[arg(long, value_parser = ["action-item", "decision", "open-question", "commitment"])]
intent_kind: Option<String>,
#[arg(long)]
owner: Option<String>,
#[arg(long, default_value = "text", value_parser = ["text", "json"])]
format: String,
#[arg(long, conflicts_with = "no_sync")]
sync: bool,
#[arg(long, conflicts_with = "sync")]
no_sync: bool,
},
Actions {
#[arg(short, long)]
assignee: Option<String>,
},
Consistency {
#[arg(long)]
owner: Option<String>,
#[arg(long, default_value = "7")]
stale_after_days: i64,
},
Person {
name: String,
},
People {
#[arg(long)]
rebuild: bool,
#[arg(long)]
json: bool,
#[arg(short, long, default_value = "15")]
limit: usize,
},
Vocabulary {
#[command(subcommand)]
action: VocabularyAction,
},
Commitments {
#[arg(short, long)]
person: Option<String>,
#[arg(long)]
json: bool,
},
Research {
query: String,
#[arg(short = 't', long)]
content_type: Option<String>,
#[arg(short, long)]
since: Option<String>,
#[arg(short, long)]
attendee: Option<String>,
},
List {
#[arg(short, long, default_value = "10")]
limit: usize,
#[arg(short = 't', long)]
content_type: Option<String>,
#[arg(long, conflicts_with = "no_sync")]
sync: bool,
#[arg(long, conflicts_with = "sync")]
no_sync: bool,
},
Export {
#[arg(short = 't', long)]
content_type: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Ingest {
path: Option<PathBuf>,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
},
Clean {
meeting: String,
#[arg(long)]
apply: bool,
},
Process {
path: PathBuf,
#[arg(short = 't', long, default_value = "memo")]
content_type: String,
#[arg(short = 'n', long)]
note: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(short, long)]
language: Option<String>,
#[arg(long)]
template: Option<String>,
},
Template {
#[command(subcommand)]
cmd: TemplateCmd,
},
Watch {
dir: Option<PathBuf>,
#[arg(short, long)]
language: Option<String>,
},
Setup {
#[arg(short, long, default_value = "small")]
model: String,
#[arg(long)]
list: bool,
#[arg(long)]
diarization: bool,
#[arg(long)]
parakeet: bool,
#[arg(long, default_value = "tdt-600m")]
parakeet_model: String,
#[arg(long)]
demo: bool,
},
Qmd {
#[arg(value_parser = ["status", "register"])]
action: String,
#[arg(long, default_value = "minutes")]
collection: String,
},
Automate {
#[arg(value_parser = ["weekly-summary", "proactive-context"])]
kind: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_parser = ["slack-json", "email-markdown"])]
delivery_target: Option<String>,
#[arg(long)]
json: bool,
},
Dictate {
#[arg(long)]
stdout: bool,
#[arg(long)]
note_only: bool,
#[arg(short, long)]
language: Option<String>,
#[arg(short = 'D', long)]
device: Option<String>,
},
Devices,
Sources,
Service {
#[arg(value_parser = ["install", "uninstall", "restart", "status"])]
action: String,
},
Logs {
#[arg(long)]
errors: bool,
#[arg(short, long, default_value = "50")]
lines: usize,
},
Health {
#[arg(long)]
json: bool,
},
Demo {
#[arg(long)]
full: bool,
#[arg(long)]
clean: bool,
#[arg(long)]
query: bool,
},
Schema,
Get {
slug: String,
#[arg(long)]
json: bool,
#[arg(long)]
compact_json: bool,
},
Events {
#[arg(short, long, default_value = "50")]
limit: usize,
#[arg(long)]
event_type: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
follow: bool,
#[arg(long)]
since_seq: Option<u64>,
},
AgentAnnotate {
#[arg(long)]
agent_id: String,
#[arg(long = "tool")]
tools: Vec<String>,
#[arg(long, default_value = "commentary")]
subkind: String,
#[arg(long)]
meeting_id: Option<String>,
#[arg(long)]
meeting_path: Option<String>,
#[arg(long)]
span_start_ms: Option<u64>,
#[arg(long)]
span_end_ms: Option<u64>,
#[arg(long)]
body: String,
#[arg(long = "citation")]
citations: Vec<String>,
#[arg(long, default_value = "medium")]
confidence: String,
#[arg(long)]
provenance: Option<String>,
},
Insights {
#[arg(short, long)]
kind: Option<String>,
#[arg(short, long)]
confidence: Option<String>,
#[arg(short, long)]
participant: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(short, long, default_value = "50")]
limit: usize,
#[arg(short, long)]
actionable: bool,
},
Context {
#[command(subcommand)]
action: ContextAction,
},
Import {
from: String,
#[arg(short, long)]
dir: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
},
Vault {
#[command(subcommand)]
action: VaultAction,
},
Enroll {
#[arg(long)]
file: Option<PathBuf>,
#[arg(long, default_value = "10")]
duration: u64,
},
Voices {
#[arg(long)]
delete: bool,
#[arg(long)]
json: bool,
},
Live {
#[arg(short, long)]
language: Option<String>,
#[arg(short = 'D', long)]
device: Option<String>,
},
Transcript {
#[arg(long)]
since: Option<String>,
#[arg(long)]
status: bool,
#[arg(long, default_value = "json", value_parser = ["text", "json"])]
format: String,
},
Dashboard {
#[arg(short, long, default_value = "3141")]
port: u16,
#[arg(long)]
no_open: bool,
},
Delete {
meeting: String,
#[arg(long)]
with_audio: bool,
#[arg(long)]
force: bool,
},
Confirm {
#[arg(long)]
meeting: PathBuf,
#[arg(long)]
speaker: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
save_voice: bool,
},
}
#[derive(Subcommand)]
enum VocabularyAction {
List {
#[arg(long)]
json: bool,
},
Add {
#[arg(long, default_value = "term", value_parser = ["person", "organization", "project", "term", "acronym"])]
kind: String,
canonical: String,
#[arg(long = "alias")]
aliases: Vec<String>,
#[arg(long)]
json: bool,
},
Remove {
id: String,
#[arg(long)]
json: bool,
},
Suggest {
meeting: PathBuf,
#[arg(long)]
json: bool,
},
Rebuild {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum VaultAction {
Setup {
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(short, long, value_parser = ["symlink", "copy", "direct"])]
strategy: Option<String>,
#[arg(long)]
subdir: Option<String>,
},
Status,
Unlink,
Sync,
}
#[derive(Subcommand)]
enum AutoresearchAction {
#[command(name = "decode-hints")]
Run {
#[arg(long)]
corpus: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
json: bool,
},
#[command(name = "compare-decode-hints")]
Compare {
#[arg(long)]
left: PathBuf,
#[arg(long)]
right: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
#[command(name = "list-decode-hints")]
List {
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum AppleSpeechAction {
Capabilities {
#[arg(long)]
json: bool,
},
Benchmark {
#[arg(long)]
corpus: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum TemplateCmd {
List,
Show {
slug: String,
},
Validate {
path: PathBuf,
},
}
#[derive(Subcommand)]
enum ContextAction {
ActivitySummary {
#[arg(long)]
session: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
start: Option<String>,
#[arg(long)]
end: Option<String>,
#[arg(long)]
json: bool,
},
Search {
query: String,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
},
GetMoment {
#[arg(long)]
session: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
at: Option<String>,
#[arg(long, default_value = "10")]
before_minutes: i64,
#[arg(long, default_value = "10")]
after_minutes: i64,
#[arg(long)]
json: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let app_level = if cli.verbose { "debug" } else { "info" };
let default_filter = format!("{app_level},whisper_rs=warn,ggml=warn");
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.with_writer(std::io::stderr)
.init();
minutes_core::install_whisper_logging_hooks();
let mut config = Config::load();
install_parakeet_panic_hook();
minutes_core::logging::rotate_logs().ok();
let result = match cli.command {
Commands::Record {
title,
context,
mode,
intent,
allow_degraded,
skip_audio_probe,
language,
device,
source,
call,
diagnose,
mute_mic,
template,
} => {
if let Some(lang) = language {
config.transcription.language = Some(lang);
}
if let Some(slug) = template.as_deref() {
minutes_core::TemplateResolver::new()
.resolve(slug)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
resolve_recording_device_overrides(&mut config, &source, device, call)?;
if mute_mic {
minutes_core::streaming::set_mic_muted_with_sentinel(true);
eprintln!("[minutes] Starting with microphone muted (system audio only).");
}
if call && source.len() < 2 {
if let Some(loopback) = minutes_core::capture::detect_loopback_device() {
eprintln!(
"[minutes] Detected system audio device: {}\n\
Starting CLI dual-source call capture.\n\
{}\n\
If you intended a mic-only fallback instead, omit `--call` and choose one explicit input device.",
loopback,
desktop_call_capture_workaround()
);
} else {
eprintln!(
"[minutes] No system audio device detected.\n\
To capture call audio, install a loopback driver:\n\
macOS: brew install blackhole-2ch\n\
{}\n\
Without a loopback route, the CLI can only record a single input device.",
desktop_call_capture_workaround()
);
}
}
if let Some(wav_path) = diagnose {
cmd_diagnose(&wav_path, title.as_deref(), &config)
} else {
let effective_intent = if call && intent == "auto" {
"call"
} else {
&intent
};
cmd_record(
title,
context,
&mode,
effective_intent,
allow_degraded,
skip_audio_probe.as_deref(),
template,
&config,
)
}
}
Commands::Note { text, meeting } => cmd_note(&text, meeting.as_deref(), &config),
Commands::Stop => cmd_stop(&config),
Commands::Extend => {
if !minutes_core::pid::status().recording {
eprintln!("No active recording to extend.");
std::process::exit(1);
}
minutes_core::capture::write_extend_sentinel()?;
eprintln!("Recording extended — auto-stop timers reset.");
Ok(())
}
Commands::MicToggle { state } => cmd_mic_toggle(state.as_deref()),
Commands::ProcessQueue => cmd_process_queue(&config),
Commands::ParakeetHelper {
binary,
model_path,
audio_path,
vocab_path,
model_id,
gpu,
fp16,
vad_path,
vad_threshold,
} => cmd_parakeet_helper(
&binary,
&model_path,
&audio_path,
&vocab_path,
&model_id,
gpu,
fp16,
vad_path.as_deref(),
vad_threshold,
&config,
),
Commands::ParakeetBenchmark {
binary,
model_path,
audio_path,
vocab_path,
model_id,
gpu,
vad_path,
vad_threshold,
} => cmd_parakeet_benchmark(
&binary,
&model_path,
&audio_path,
&vocab_path,
&model_id,
gpu,
vad_path.as_deref(),
vad_threshold,
&config,
),
Commands::PreflightRecord {
mode,
intent,
allow_degraded,
json,
} => cmd_preflight_record(&mode, &intent, allow_degraded, json, &config),
Commands::Status => cmd_status(),
Commands::Jobs { all, json, limit } => cmd_jobs(all, json, limit),
Commands::Paths { json } => cmd_paths(json, &config),
Commands::Storage { json } => cmd_storage(json, &config),
Commands::Cleanup {
apply,
older_than,
json,
} => cmd_cleanup(apply, older_than.as_deref(), json, &config),
Commands::Autoresearch { action } => match action {
AutoresearchAction::Run {
corpus,
out,
engine,
json,
} => cmd_autoresearch_decode_hints(&corpus, out.as_deref(), engine.as_deref(), json),
AutoresearchAction::Compare {
left,
right,
out,
json,
} => cmd_autoresearch_compare_decode_hints(&left, &right, out.as_deref(), json),
AutoresearchAction::List { limit, json } => {
cmd_autoresearch_list_decode_hints(limit, json)
}
},
Commands::AppleSpeech { action } => match action {
AppleSpeechAction::Capabilities { json } => cmd_apple_speech_capabilities(json),
AppleSpeechAction::Benchmark { corpus, out, json } => {
cmd_apple_speech_benchmark(&corpus, out.as_deref(), json, &config)
}
},
Commands::Capabilities { json } => cmd_capabilities(json),
Commands::Search {
query,
content_type,
since,
limit,
intents_only,
intent_kind,
owner,
format,
sync,
no_sync,
} => cmd_search(
&query,
content_type,
since,
limit,
intents_only,
intent_kind,
owner,
&format,
resolve_sync_mode(sync, no_sync),
&config,
),
Commands::Actions { assignee } => cmd_actions(assignee.as_deref(), &config),
Commands::Consistency {
owner,
stale_after_days,
} => cmd_consistency(owner.as_deref(), stale_after_days, &config),
Commands::Person { name } => cmd_person(&name, &config),
Commands::People {
rebuild,
json,
limit,
} => cmd_people(rebuild, json, limit, &config),
Commands::Vocabulary { action } => cmd_vocabulary(action, &config),
Commands::Commitments { person, json } => cmd_commitments(person.as_deref(), json, &config),
Commands::Research {
query,
content_type,
since,
attendee,
} => cmd_research(&query, content_type, since, attendee, &config),
Commands::List {
limit,
content_type,
sync,
no_sync,
} => cmd_list(
limit,
content_type,
resolve_sync_mode(sync, no_sync),
&config,
),
Commands::Export {
content_type,
output,
} => cmd_export(content_type, output, &config),
Commands::Ingest { path, all, dry_run } => cmd_ingest(path, all, dry_run, &config),
Commands::Clean { meeting, apply } => cmd_clean(&meeting, apply, &config),
Commands::Process {
path,
content_type,
note,
title,
language,
template,
} => {
if let Some(lang) = language {
config.transcription.language = Some(lang);
}
if let Some(ref n) = note {
minutes_core::notes::save_context(n)?;
}
let resolved_template = match template.as_deref() {
Some(slug) => Some(
minutes_core::TemplateResolver::new()
.resolve(slug)
.map_err(|e| anyhow::anyhow!("{}", e))?,
),
None => None,
};
let result = cmd_process(
&path,
&content_type,
title.as_deref(),
resolved_template.as_ref(),
&config,
);
if note.is_some() {
minutes_core::notes::cleanup();
}
result
}
Commands::Template { cmd } => cmd_template(cmd),
Commands::Watch { dir, language } => {
if let Some(lang) = language {
config.transcription.language = Some(lang);
}
cmd_watch(dir.as_deref(), &config)
}
Commands::Dictate {
stdout,
note_only,
language,
device,
} => {
if let Some(lang) = language {
config.transcription.language = Some(lang);
}
if let Some(dev) = device {
config.recording.device = Some(dev);
}
cmd_dictate(stdout, note_only, &config)
}
Commands::Devices => cmd_devices(),
Commands::Sources => cmd_sources(),
Commands::Setup {
model,
list,
diarization,
parakeet,
parakeet_model,
demo,
} => {
if demo {
cmd_setup_demo()
} else if parakeet {
cmd_setup_parakeet(¶keet_model)
} else {
cmd_setup(&model, list, diarization)
}
}
Commands::Qmd { action, collection } => cmd_qmd(&action, &collection, &config),
Commands::Automate {
kind,
output,
delivery_target,
json,
} => cmd_automate(&kind, output, delivery_target.as_deref(), json, &config),
Commands::Service { action } => {
#[cfg(target_os = "macos")]
{
cmd_service(&action)
}
#[cfg(target_os = "linux")]
{
cmd_service_linux(&action)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = action;
eprintln!("On Windows, use Task Scheduler to schedule:");
eprintln!(" minutes watch (always running)");
eprintln!(" minutes automate weekly-summary --json (weekly)");
eprintln!(" minutes automate proactive-context --json (daily)");
Ok(())
}
}
Commands::Logs { errors, lines } => cmd_logs(errors, lines),
Commands::Health { json } => cmd_health(json),
Commands::Demo { full, clean, query } => {
if clean {
let removed = demo_data::clean_demo_meetings(&config.output_dir)?;
if removed > 0 {
eprintln!("\nRemoved {} demo meeting(s).", removed);
if full {
eprintln!();
cmd_demo_full(&config)?;
}
} else {
eprintln!("No demo meetings found to remove.");
if full {
cmd_demo_full(&config)?;
}
}
Ok(())
} else if query {
demo_data::query_demo(&config.output_dir)
} else if full {
cmd_demo_full(&config)
} else {
cmd_demo(&config)
}
}
Commands::Schema => cmd_schema(),
Commands::Get {
slug,
json,
compact_json,
} => cmd_get(&slug, json, compact_json, &config),
Commands::Events {
limit,
event_type,
since,
follow,
since_seq,
} => cmd_events(limit, event_type, since, follow, since_seq, &config),
Commands::AgentAnnotate {
agent_id,
tools,
subkind,
meeting_id,
meeting_path,
span_start_ms,
span_end_ms,
body,
citations,
confidence,
provenance,
} => cmd_agent_annotate(
agent_id,
tools,
subkind,
meeting_id,
meeting_path,
span_start_ms,
span_end_ms,
body,
citations,
confidence,
provenance,
),
Commands::Insights {
kind,
confidence,
participant,
since,
limit,
actionable,
} => cmd_insights(kind, confidence, participant, since, limit, actionable),
Commands::Context { action } => cmd_context(action),
Commands::Import { from, dir, dry_run } => {
cmd_import(&from, dir.as_deref(), dry_run, &config)
}
Commands::Vault { action } => match action {
VaultAction::Setup {
path,
strategy,
subdir,
} => cmd_vault_setup(path, strategy, subdir, config),
VaultAction::Status => cmd_vault_status(&config),
VaultAction::Unlink => cmd_vault_unlink(config),
VaultAction::Sync => cmd_vault_sync(&config),
},
Commands::Enroll { file, duration } => cmd_enroll(file.as_deref(), duration, &config),
Commands::Voices { delete, json } => cmd_voices(delete, json),
Commands::Delete {
meeting,
with_audio,
force,
} => cmd_delete(&meeting, with_audio, force, &config),
Commands::Confirm {
meeting,
speaker,
name,
save_voice,
} => cmd_confirm(
&meeting,
speaker.as_deref(),
name.as_deref(),
save_voice,
&config,
),
Commands::Live { language, device } => {
if let Some(lang) = language {
config.transcription.language = Some(lang);
}
if let Some(dev) = device {
config.recording.device = Some(dev);
}
cmd_live(&config)
}
Commands::Transcript {
since,
status,
format,
} => cmd_transcript(since.as_deref(), status, &format),
Commands::Dashboard { port, no_open } => dashboard::serve(&config, port, !no_open),
};
minutes_core::parakeet_sidecar::shutdown_global_parakeet_sidecar();
result
}
fn cmd_note(text: &str, meeting: Option<&Path>, config: &Config) -> Result<()> {
if let Some(meeting_path) = meeting {
minutes_core::notes::validate_meeting_path(meeting_path, &config.output_dir)
.map_err(|e| anyhow::anyhow!("{}", e))?;
minutes_core::notes::annotate_meeting(meeting_path, text)
.map_err(|e| anyhow::anyhow!("{}", e))?;
eprintln!("Note added to {}", meeting_path.display());
} else {
match minutes_core::notes::add_note(text) {
Ok(line) => eprintln!("{}", line),
Err(e) => anyhow::bail!("{}", e),
}
}
Ok(())
}
fn capture_mode_from_str(mode: &str) -> Result<CaptureMode> {
match mode {
"meeting" => Ok(CaptureMode::Meeting),
"quick-thought" => Ok(CaptureMode::QuickThought),
other => anyhow::bail!(
"unknown recording mode: {}. Use 'meeting' or 'quick-thought'.",
other
),
}
}
fn parse_recording_intent(intent: &str) -> Result<Option<RecordingIntent>> {
match intent {
"auto" => Ok(None),
"memo" => Ok(Some(RecordingIntent::Memo)),
"room" => Ok(Some(RecordingIntent::Room)),
"call" => Ok(Some(RecordingIntent::Call)),
other => anyhow::bail!(
"unknown recording intent: {}. Use auto, memo, room, or call.",
other
),
}
}
fn cleanup_live_capture_state() {
minutes_core::pid::remove().ok();
minutes_core::pid::clear_recording_metadata().ok();
minutes_core::notes::cleanup();
}
fn desktop_call_capture_workaround() -> &'static str {
"For native dual-source call capture, use the Minutes desktop app. The published Homebrew cask/update feed is Apple Silicon-only right now, but Intel Macs on macOS 15+ can still build the desktop app from source (see README \"Desktop app\")."
}
fn normalize_source_override(source: Option<&str>) -> Option<String> {
match source.map(str::trim) {
Some("") | None => None,
Some(value) if value.eq_ignore_ascii_case("default") => None,
Some(value) => minutes_core::capture::canonicalize_input_device_setting(value),
}
}
fn resolve_recording_device_overrides(
config: &mut Config,
source: &[String],
device: Option<String>,
call: bool,
) -> Result<()> {
if source.len() >= 2 {
let voice = normalize_source_override(source.first().map(String::as_str));
let call_source = normalize_source_override(source.get(1).map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("dual-source capture requires a call/system source"))?;
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice,
call: Some(call_source),
});
config.recording.device = None;
return Ok(());
}
if call {
if let Some(loopback) = minutes_core::capture::detect_loopback_device() {
let voice = source
.first()
.map(String::as_str)
.and_then(|value| normalize_source_override(Some(value)))
.or(device.clone());
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice,
call: Some(loopback),
});
config.recording.device = None;
return Ok(());
}
}
if !source.is_empty() {
config.recording.sources = None;
config.recording.device = normalize_source_override(source.first().map(String::as_str));
return Ok(());
}
if let Some(dev) = device {
config.recording.sources = None;
config.recording.device = minutes_core::capture::canonicalize_input_device_setting(&dev);
return Ok(());
}
if let Some(sources) = config.recording.sources.clone() {
match (sources.voice.as_deref(), sources.call.as_deref()) {
(Some(voice), Some(call)) => {
config.recording.device = None;
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice: normalize_source_override(Some(voice)),
call: Some(call.to_string()),
});
}
(Some(voice), None) => {
config.recording.sources = None;
config.recording.device = normalize_source_override(Some(voice));
}
(None, Some(call)) => {
config.recording.device = None;
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice: None,
call: Some(call.to_string()),
});
}
(None, None) => {
config.recording.sources = None;
}
}
}
Ok(())
}
fn cmd_preflight_record(
mode: &str,
intent: &str,
allow_degraded: bool,
json: bool,
config: &Config,
) -> Result<()> {
let capture_mode = capture_mode_from_str(mode)?;
let requested_intent = parse_recording_intent(intent)?;
let preflight = minutes_core::capture::preflight_recording(
capture_mode,
requested_intent,
allow_degraded,
config,
)
.map_err(|error| anyhow::anyhow!("{}", error))?;
if json {
println!("{}", serde_json::to_string_pretty(&preflight)?);
} else if let Some(reason) = &preflight.blocking_reason {
anyhow::bail!("{}", reason);
} else {
println!(
"{} intent ready on '{}'.",
preflight.intent.as_str(),
preflight.input_device
);
for warning in &preflight.warnings {
eprintln!("warning: {}", warning);
}
}
Ok(())
}
fn check_meeting_system_audio_probe(
capture_mode: CaptureMode,
skip_audio_probe: Option<&str>,
config: &Config,
) -> Result<Option<minutes_core::markdown::RecordingHealth>> {
if capture_mode != CaptureMode::Meeting {
return Ok(None);
}
if minutes_core::capture::resolve_system_audio_probe_device(config)
.map_err(|error| anyhow::anyhow!("{}", error))?
.is_none()
{
if skip_audio_probe.is_some() {
anyhow::bail!("--skip-audio-probe was provided, but no system-audio source is configured for this recording");
}
return Ok(None);
}
if let Some(reason) = skip_audio_probe {
let reason = reason.trim();
if reason.is_empty() {
anyhow::bail!("--skip-audio-probe requires a non-empty reason");
}
eprintln!(
"[minutes] System-audio readiness probe skipped for this recording: {}",
reason
);
return Ok(Some(
minutes_core::health::recording_health_for_skipped_system_audio_probe(reason),
));
}
match minutes_core::health::probe_system_audio_capture(config)
.map_err(|error| anyhow::anyhow!("{}", error))?
{
None => Ok(None),
Some((route, result)) if result.failure_kind.is_none() => {
if let Some(device) = route.device_name.as_deref() {
eprintln!(
"[minutes] System-audio readiness probe passed on '{}'.",
device
);
}
Ok(None)
}
Some((route, result)) => {
let health = minutes_core::health::recording_health_for_system_audio_probe_failure(
Some(&route),
&result,
);
let detail = health
.capture_warnings
.first()
.map(|warning| warning.message.as_str())
.unwrap_or("System-audio readiness probe failed.");
anyhow::bail!(
"{} Use --skip-audio-probe \"<reason>\" for this run only if you intentionally want to record despite this degraded system-audio signal.",
detail
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_record(
title: Option<String>,
context: Option<String>,
mode: &str,
intent: &str,
allow_degraded: bool,
skip_audio_probe: Option<&str>,
template_slug: Option<String>,
config: &Config,
) -> Result<()> {
config.ensure_dirs()?;
let capture_mode = capture_mode_from_str(mode)?;
let requested_intent = parse_recording_intent(intent)?;
let preflight = minutes_core::capture::preflight_recording(
capture_mode,
requested_intent,
allow_degraded,
config,
)
.map_err(|error| anyhow::anyhow!("{}", error))?;
if let Some(reason) = &preflight.blocking_reason {
anyhow::bail!("{}", reason);
}
for warning in &preflight.warnings {
eprintln!("[minutes] {}", warning);
}
let startup_recording_health =
check_meeting_system_audio_probe(capture_mode, skip_audio_probe, config)?;
let lt_pid = minutes_core::pid::live_transcript_pid_path();
if minutes_core::pid::inspect_pid_file(<_pid).is_active() {
anyhow::bail!("live transcript in progress — run `minutes stop` first");
}
let recording_started_at = Local::now();
minutes_core::pid::create().map_err(|e| anyhow::anyhow!("{}", e))?;
let context_session_id = minutes_core::desktop_context::maybe_start_capture_session(
&config.desktop_context,
capture_mode,
title.clone(),
recording_started_at,
);
minutes_core::pid::write_recording_metadata_with_context(
capture_mode,
context_session_id.as_deref(),
)
.ok();
let _desktop_context_collector = context_session_id.as_ref().and_then(|session_id| {
match minutes_core::desktop_context::DesktopContextCollector::start(
session_id.clone(),
minutes_core::desktop_context::DesktopContextSessionKind::Recording,
config.desktop_context.clone(),
) {
Ok(collector) => Some(collector),
Err(error) => {
tracing::warn!(error = %error, "desktop context collector unavailable for CLI recording");
None
}
}
});
minutes_core::notes::save_recording_start()?;
if let Some(ref ctx) = context {
minutes_core::notes::save_context(ctx)?;
eprintln!("Context saved: {}", ctx);
}
match capture_mode {
CaptureMode::Meeting => {
eprintln!("Recording meeting... (press Ctrl-C or run `minutes stop` to finish)");
eprintln!(" Tip: add notes with `minutes note \"your note\"` in another terminal");
}
CaptureMode::QuickThought => {
eprintln!("Recording quick thought... (press Ctrl-C or run `minutes stop` to finish)");
eprintln!(" Tip: speak one idea clearly — it will save as a normal memo artifact");
}
CaptureMode::Dictation => {
eprintln!("Use `minutes dictate` for dictation mode.");
}
CaptureMode::LiveTranscript => {
eprintln!("Use `minutes live` for live transcript mode.");
}
}
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = std::sync::Arc::clone(&stop_flag);
ctrlc::set_handler(move || {
if let InterruptAction::ForceExit(code) = handle_graceful_interrupt(
&stop_clone,
"Stopping recording... (Ctrl+C again to force quit)",
) {
std::process::exit(code);
}
})?;
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGTERM, libc::SIG_IGN);
}
let wav_path = minutes_core::pid::current_wav_path();
minutes_core::capture::record_to_wav_with_lifecycle(
&wav_path,
stop_flag,
config,
Some(minutes_core::capture::RecordingStartedContext {
session_id: context_session_id.clone(),
source: "capture".into(),
capabilities: vec![
"audio.capture".into(),
"live.utterance.final".into(),
format!("mode.{}", capture_mode.noun().replace(' ', "-")),
format!("intent.{}", preflight.intent.as_str()),
],
}),
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let recording_finished_at = Local::now();
let user_notes = minutes_core::notes::read_notes();
let pre_context = minutes_core::notes::read_context();
let calendar_event = None;
let queued = (|| -> Result<(minutes_core::jobs::ProcessingJob, String)> {
let job = minutes_core::jobs::queue_live_capture_with_recording_health(
capture_mode,
title.clone(),
&wav_path,
user_notes,
pre_context,
Some(recording_started_at),
Some(recording_finished_at),
context_session_id.clone(),
calendar_event,
template_slug.clone(),
startup_recording_health.clone(),
)?;
let queued_result = serde_json::to_string_pretty(&serde_json::json!({
"status": "queued",
"job_id": job.id,
"title": job.title,
"mode": mode,
}))?;
if let Err(error) = std::fs::write(minutes_core::pid::last_result_path(), &queued_result) {
tracing::warn!(error = %error, "failed to persist queued result summary");
}
minutes_core::pid::set_processing_status(
job.stage.as_deref(),
Some(capture_mode),
job.title.as_deref(),
Some(&job.id),
minutes_core::jobs::active_job_count(),
)
.ok();
spawn_queue_worker()?;
Ok((job, queued_result))
})();
if let Err(error) = &queued {
if let Some(session_id) = context_session_id.as_deref() {
if let Err(mark_error) = minutes_core::context_store::mark_capture_session_failed(
session_id,
Some(recording_finished_at),
&error.to_string(),
None,
) {
tracing::warn!(
session_id,
error = %mark_error,
"failed to mark context session after queue error"
);
}
}
}
cleanup_live_capture_state();
let (job, queued_result) = queued?;
eprintln!(
"Queued {} processing{}.",
capture_mode.noun(),
job.title
.as_ref()
.map(|title| format!(" for {}", title))
.unwrap_or_default()
);
println!("{}", queued_result);
Ok(())
}
fn spawn_queue_worker() -> Result<()> {
if minutes_core::jobs::worker_active() {
return Ok(());
}
let exe = std::env::current_exe()?;
let child = std::process::Command::new(exe)
.arg("process-queue")
.env(
"RUST_LOG",
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
let _ = child.id();
Ok(())
}
fn cmd_mic_toggle(force_state: Option<&str>) -> Result<()> {
let new_state = match force_state {
Some("on") => minutes_core::streaming::set_mic_muted_with_sentinel(true),
Some("off") => minutes_core::streaming::set_mic_muted_with_sentinel(false),
_ => minutes_core::streaming::toggle_mic_mute_with_sentinel(),
};
if new_state {
println!("mic muted — system audio still capturing");
} else {
println!("mic unmuted");
}
if !minutes_core::pid::status().recording {
eprintln!(
"[minutes] No active recording — the sentinel is set and will take effect on the next dual-source `minutes record`."
);
}
Ok(())
}
fn cmd_stop(config: &Config) -> Result<()> {
match minutes_core::pid::check_recording() {
Ok(Some(pid)) => {
let capture_mode = minutes_core::pid::read_recording_metadata()
.map(|meta| meta.mode)
.unwrap_or(CaptureMode::Meeting);
eprintln!("Stopping recording (PID {})...", pid);
minutes_core::pid::write_stop_sentinel()
.map_err(|e| anyhow::anyhow!("failed to write stop sentinel: {}", e))?;
#[cfg(unix)]
{
if minutes_core::desktop_control::desktop_app_owns_pid(pid) {
tracing::info!(
pid,
"recording is owned by the desktop app; using sentinel-only stop"
);
} else {
let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
if rc != 0 {
let err = std::io::Error::last_os_error();
tracing::warn!(
"SIGTERM failed (PID {}): {} — sentinel file will stop recording",
pid,
err
);
}
}
}
let timeout = std::time::Duration::from_secs(120);
let start = std::time::Instant::now();
let pid_path = minutes_core::pid::pid_path();
eprint!("Processing {}", capture_mode.noun());
while pid_path.exists() && start.elapsed() < timeout {
std::thread::sleep(std::time::Duration::from_secs(1));
eprint!(".");
}
eprintln!();
if pid_path.exists() {
anyhow::bail!("recording process did not stop within 120 seconds");
}
let result_path = minutes_core::pid::last_result_path();
if result_path.exists() {
let result = std::fs::read_to_string(&result_path)?;
println!("{}", result);
std::fs::remove_file(&result_path).ok();
if let Err(e) = minutes_core::graph::rebuild_index(config) {
tracing::warn!(error = %e, "graph index rebuild failed (non-fatal)");
}
} else {
let active_jobs = minutes_core::jobs::active_jobs();
if let Some(job) = active_jobs.first() {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "queued",
"job_id": job.id,
"title": job.title,
"mode": job.mode,
}))?
);
} else {
eprintln!("Recording stopped but no result file found.");
}
}
Ok(())
}
Ok(None) => {
let lt_pid_path = minutes_core::pid::live_transcript_pid_path();
let lt_state = minutes_core::pid::inspect_pid_file(<_pid_path);
if lt_state.is_active() {
match lt_state.pid() {
Some(pid) => eprintln!("Stopping live transcript (PID {})...", pid),
None => eprintln!("Stopping live transcript..."),
}
minutes_core::pid::write_stop_sentinel()
.map_err(|e| anyhow::anyhow!("failed to write stop sentinel: {}", e))?;
#[cfg(unix)]
if let Some(pid) = lt_state.pid() {
let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
if rc != 0 {
tracing::warn!("SIGTERM failed for live transcript PID {}", pid);
}
}
let start = std::time::Instant::now();
eprint!("Finalizing live transcript");
while lt_pid_path.exists() && start.elapsed() < std::time::Duration::from_secs(30) {
std::thread::sleep(std::time::Duration::from_millis(500));
eprint!(".");
}
eprintln!();
if lt_pid_path.exists() {
anyhow::bail!("live transcript process did not stop within 30 seconds");
}
eprintln!("Live transcript stopped.");
Ok(())
} else {
eprintln!("No recording or live transcript in progress.");
Ok(())
}
}
Err(e) => Err(anyhow::anyhow!("{}", e)),
}
}
fn cmd_process_queue(config: &Config) -> Result<()> {
minutes_core::jobs::process_pending_jobs(config, |_| {})?;
Ok(())
}
fn cmd_status() -> Result<()> {
let status = minutes_core::pid::status();
let json = serde_json::to_string_pretty(&status)?;
println!("{}", json);
Ok(())
}
fn cmd_jobs(include_terminal: bool, json_mode: bool, limit: usize) -> Result<()> {
let jobs = minutes_core::jobs::display_jobs(Some(limit), include_terminal);
if json_mode {
println!("{}", serde_json::to_string_pretty(&jobs)?);
return Ok(());
}
if jobs.is_empty() {
println!("No processing jobs.");
return Ok(());
}
for job in jobs {
let mode = match job.mode {
CaptureMode::Meeting => "meeting",
CaptureMode::QuickThought => "quick thought",
CaptureMode::Dictation => "dictation",
CaptureMode::LiveTranscript => "live transcript",
};
let title = job.title.unwrap_or_else(|| match job.mode {
CaptureMode::Meeting => "Meeting recording".into(),
CaptureMode::QuickThought => "Quick thought".into(),
CaptureMode::Dictation => "Dictation".into(),
CaptureMode::LiveTranscript => "Live transcript".into(),
});
let state = match job.state {
minutes_core::jobs::JobState::Queued => "queued",
minutes_core::jobs::JobState::Transcribing => "transcribing",
minutes_core::jobs::JobState::TranscriptOnly => "transcript-ready",
minutes_core::jobs::JobState::Diarizing => "diarizing",
minutes_core::jobs::JobState::Summarizing => "summarizing",
minutes_core::jobs::JobState::Saving => "saving",
minutes_core::jobs::JobState::NeedsReview => "needs-review",
minutes_core::jobs::JobState::Complete => "complete",
minutes_core::jobs::JobState::Failed => "failed",
};
println!("{} {} {}", job.id, state, title);
println!(" mode: {}", mode);
if let Some(stage) = job.stage {
println!(" stage: {}", stage);
}
if let Some(path) = job.output_path {
println!(" output: {}", path);
}
if let Some(words) = job.word_count {
println!(" words: {}", words);
}
if let Some(error) = job.error {
println!(" error: {}", error);
}
println!(" created: {}", job.created_at.to_rfc3339());
println!(" audio: {}", job.audio_path);
println!();
}
Ok(())
}
fn automation_runs_dir() -> PathBuf {
Config::minutes_dir().join("automation-runs")
}
fn build_weekly_summary_markdown(config: &Config) -> Result<String> {
let since = (Local::now() - chrono::Duration::days(7)).to_rfc3339();
let filters = minutes_core::search::SearchFilters {
content_type: None,
since: Some(since),
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let meetings = minutes_core::search::search("", config, &filters)?;
let consistency = minutes_core::search::consistency_report(config, None, 7)?;
let open_actions = minutes_core::search::find_open_actions(config, None)?;
let recent_titles = if meetings.is_empty() {
"- No meetings or memos in the last 7 days.".to_string()
} else {
meetings
.iter()
.take(6)
.map(|meeting| format!("- {} ({})", meeting.title, meeting.date))
.collect::<Vec<_>>()
.join("\n")
};
let decision_conflicts = if consistency.decision_conflicts.is_empty() {
"- No conflicting decision arcs detected.".to_string()
} else {
consistency
.decision_conflicts
.iter()
.take(5)
.map(|conflict| format!("- {} -> {}", conflict.topic, conflict.latest.what))
.collect::<Vec<_>>()
.join("\n")
};
let stale_commitments = if consistency.stale_commitments.is_empty() {
"- No stale commitments detected.".to_string()
} else {
consistency
.stale_commitments
.iter()
.take(5)
.map(|item| {
format!(
"- {}{}",
item.entry.what,
item.entry
.who
.as_ref()
.map(|who| format!(" ({who})"))
.unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n")
};
let open_actions_block = if open_actions.is_empty() {
"- No open action items found.".to_string()
} else {
open_actions
.iter()
.take(6)
.map(|item| {
format!(
"- {}: {}{}",
item.assignee,
item.task,
item.due
.as_ref()
.map(|due| format!(" (due {due})"))
.unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n")
};
Ok(format!(
"# Weekly Summary\n\n## Volume\n\n- {} meeting or memo artifact(s) in the last 7 days.\n\n## Recent Meetings\n\n{}\n\n## Decision Arcs\n\n{}\n\n## Stale Commitments\n\n{}\n\n## Open Actions\n\n{}\n",
meetings.len(),
recent_titles,
decision_conflicts,
stale_commitments,
open_actions_block
))
}
fn build_proactive_context_markdown(config: &Config) -> Result<String> {
let since = (Local::now() - chrono::Duration::days(7)).to_rfc3339();
let filters = minutes_core::search::SearchFilters {
content_type: None,
since: Some(since),
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let recent_results = minutes_core::search::search("", config, &filters)?;
let recent_meetings = recent_results
.iter()
.filter(|item| item.content_type != "memo")
.take(4)
.map(|item| format!("- {} ({})", item.title, item.date))
.collect::<Vec<_>>();
let recent_memos = recent_results
.iter()
.filter(|item| item.content_type == "memo")
.take(4)
.map(|item| format!("- {} ({})", item.title, item.date))
.collect::<Vec<_>>();
let consistency = minutes_core::search::consistency_report(config, None, 7)?;
let stale = consistency
.stale_commitments
.iter()
.take(4)
.map(|item| {
format!(
"- {}{}",
item.entry.what,
item.entry
.who
.as_ref()
.map(|who| format!(" ({who})"))
.unwrap_or_default()
)
})
.collect::<Vec<_>>();
Ok(format!(
"# Proactive Context\n\n## Recent Meetings\n\n{}\n\n## Recent Memos\n\n{}\n\n## Stale Commitments\n\n{}\n",
if recent_meetings.is_empty() { "- No recent meetings.".to_string() } else { recent_meetings.join("\n") },
if recent_memos.is_empty() { "- No recent memos.".to_string() } else { recent_memos.join("\n") },
if stale.is_empty() { "- No stale commitments.".to_string() } else { stale.join("\n") },
))
}
fn build_delivery_payload(
kind: &str,
target: &str,
source_path: &Path,
markdown: &str,
) -> Result<String> {
let source = source_path.display().to_string();
match target {
"slack-json" => Ok(serde_json::to_string_pretty(&serde_json::json!({
"delivery_target": "slack-json",
"kind": kind,
"source_artifact": source,
"mode": "draft-only",
"text": markdown,
}))?),
"email-markdown" => Ok(format!(
"# Email Draft Payload\n\n- delivery_target: email-markdown\n- kind: {kind}\n- source_artifact: {source}\n- mode: draft-only\n\n## Body\n\n{markdown}"
)),
other => anyhow::bail!("unsupported delivery target: {}", other),
}
}
fn cmd_automate(
kind: &str,
output: Option<PathBuf>,
delivery_target: Option<&str>,
json: bool,
config: &Config,
) -> Result<()> {
let markdown = match kind {
"weekly-summary" => build_weekly_summary_markdown(config)?,
"proactive-context" => build_proactive_context_markdown(config)?,
other => anyhow::bail!("unsupported automation kind: {}", other),
};
let output_path = output.unwrap_or_else(|| {
automation_runs_dir().join(format!(
"{}-{}.md",
Local::now().format("%Y-%m-%d-%H%M%S"),
kind
))
});
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&output_path, markdown)?;
let delivery_payload_path = if let Some(target) = delivery_target {
let ext = if target == "slack-json" {
"delivery.json"
} else {
"delivery.md"
};
let payload_path = output_path.with_extension(ext);
let payload = build_delivery_payload(
kind,
target,
&output_path,
&std::fs::read_to_string(&output_path)?,
)?;
std::fs::write(&payload_path, payload)?;
Some(payload_path)
} else {
None
};
let record = AutomationRunRecord {
kind: kind.to_string(),
status: "ok".into(),
output_path: output_path.display().to_string(),
delivery_target: delivery_target.map(str::to_string),
delivery_payload_path: delivery_payload_path
.as_ref()
.map(|path| path.display().to_string()),
generated_at: Local::now().to_rfc3339(),
};
let run_record_path = output_path.with_extension("json");
std::fs::write(&run_record_path, serde_json::to_string_pretty(&record)?)?;
if json {
println!("{}", serde_json::to_string_pretty(&record)?);
} else {
eprintln!("Automation run complete: {}", kind);
eprintln!(" markdown: {}", output_path.display());
eprintln!(" record: {}", run_record_path.display());
if let Some(ref payload_path) = delivery_payload_path {
eprintln!(" delivery payload: {}", payload_path.display());
}
println!("{}", serde_json::to_string_pretty(&record)?);
}
Ok(())
}
#[derive(Serialize)]
struct PathsReport {
config_path: PathBuf,
minutes_dir: PathBuf,
output_dir: PathBuf,
}
fn cmd_paths(json: bool, config: &Config) -> Result<()> {
let report = PathsReport {
config_path: Config::config_path(),
minutes_dir: Config::minutes_dir(),
output_dir: config.output_dir.clone(),
};
if json {
let envelope = json_envelope("minutes paths", report);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
println!("config_path: {}", report.config_path.display());
println!("minutes_dir: {}", report.minutes_dir.display());
println!("output_dir: {}", report.output_dir.display());
}
Ok(())
}
#[derive(Serialize)]
struct CleanupError {
path: PathBuf,
error: String,
}
#[derive(Serialize)]
struct CleanupReport {
plan: minutes_core::retention::RetentionPlan,
applied: bool,
removed: Vec<PathBuf>,
errors: Vec<CleanupError>,
}
fn cmd_storage(json: bool, config: &Config) -> Result<()> {
let plan = minutes_core::retention::preview_audio_retention(config, Local::now());
if json {
let envelope = json_envelope("minutes storage", plan);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
print_storage_summary(&plan, config);
}
Ok(())
}
fn cmd_cleanup(apply: bool, older_than: Option<&str>, json: bool, config: &Config) -> Result<()> {
let mut effective_config = config.clone();
if let Some(value) = older_than {
effective_config.retention.successful_audio_days = parse_retention_days(value)?;
}
let plan = minutes_core::retention::preview_audio_retention(&effective_config, Local::now());
let mut report = CleanupReport {
plan,
applied: apply,
removed: Vec::new(),
errors: Vec::new(),
};
if apply {
for item in
report.plan.items.iter().filter(|item| {
item.action == minutes_core::retention::RetentionAction::DeleteCandidate
})
{
match std::fs::remove_file(&item.path) {
Ok(()) => report.removed.push(item.path.clone()),
Err(error) => report.errors.push(CleanupError {
path: item.path.clone(),
error: error.to_string(),
}),
}
}
}
if json {
let envelope = json_envelope("minutes cleanup", report);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
print_cleanup_summary(&report, &effective_config);
}
Ok(())
}
fn print_storage_summary(plan: &minutes_core::retention::RetentionPlan, config: &Config) {
println!("Minutes storage");
println!(" output_dir: {}", plan.output_dir.display());
println!(
" raw audio: {} across {} file(s)",
format_bytes(plan.totals.raw_audio_bytes),
plan.totals.raw_audio_files
);
println!(
" cleanup candidates: {} across {} file(s)",
format_bytes(plan.totals.delete_candidate_bytes),
plan.totals.delete_candidate_files
);
println!(
" policy: successful audio {}d, failed/needs-review audio {}d, pinned audio kept",
config.retention.successful_audio_days, config.retention.failed_audio_days
);
}
fn print_cleanup_summary(report: &CleanupReport, config: &Config) {
if report.applied {
println!("Minutes cleanup applied");
println!(" removed: {} file(s)", report.removed.len());
if !report.errors.is_empty() {
println!(" errors: {} file(s)", report.errors.len());
}
} else {
println!("Minutes cleanup preview");
println!(" no files deleted; pass --apply to remove candidates");
}
println!(
" candidates: {} across {} file(s)",
format_bytes(report.plan.totals.delete_candidate_bytes),
report.plan.totals.delete_candidate_files
);
println!(
" policy: successful audio {}d, failed/needs-review audio {}d",
config.retention.successful_audio_days, config.retention.failed_audio_days
);
}
fn parse_retention_days(value: &str) -> Result<u32> {
let trimmed = value.trim().to_ascii_lowercase();
let digits = trimmed
.strip_suffix("days")
.or_else(|| trimmed.strip_suffix("day"))
.or_else(|| trimmed.strip_suffix('d'))
.unwrap_or(&trimmed)
.trim();
let days = digits
.parse::<u32>()
.map_err(|_| anyhow::anyhow!("invalid duration '{}'; use values like 14d or 30d", value))?;
Ok(days)
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[unit])
} else {
format!("{:.1} {}", value, UNITS[unit])
}
}
fn owner_display(
who: Option<&str>,
who_original: Option<&str>,
who_provenance: Option<&str>,
) -> String {
let owner = who.unwrap_or("unassigned");
match (who_original, who_provenance) {
(Some(original), Some(provenance)) if original != owner => {
format!("{owner} ({provenance}: {original})")
}
_ => owner.to_string(),
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_sync_mode(sync: bool, no_sync: bool) -> minutes_core::search_index::SyncMode {
if sync {
minutes_core::search_index::SyncMode::Force
} else if no_sync {
minutes_core::search_index::SyncMode::Skip
} else {
minutes_core::search_index::SyncMode::Auto
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_search(
query: &str,
content_type: Option<String>,
since: Option<String>,
limit: usize,
intents_only: bool,
intent_kind: Option<String>,
owner: Option<String>,
format: &str,
sync_mode: minutes_core::search_index::SyncMode,
config: &Config,
) -> Result<()> {
let json_mode = format == "json";
let filters = minutes_core::search::SearchFilters {
content_type,
since,
attendee: None,
intent_kind: intent_kind.as_deref().map(parse_intent_kind).transpose()?,
owner,
recorded_by: None,
};
if intents_only {
let results = minutes_core::search::search_intents(query, config, &filters)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let limited: Vec<_> = results.into_iter().take(limit).collect();
if limited.is_empty() {
if json_mode {
} else {
eprintln!("No intent records found for \"{}\"", query);
println!("[]");
}
return Ok(());
}
if json_mode {
for result in &limited {
println!("{}", serde_json::to_string(result)?);
}
} else {
for result in &limited {
let who = owner_display(
result.who.as_deref(),
result.who_original.as_deref(),
result.who_provenance.as_deref(),
);
let due = result.by_date.as_deref().unwrap_or("no due date");
eprintln!(
"\n{} — {} [{}]",
result.date, result.title, result.content_type
);
eprintln!(
" {:?}: {} (@{}, {}, {})",
result.kind, result.what, who, result.status, due
);
eprintln!(" {}", result.path.display());
}
let json = serde_json::to_string_pretty(&limited)?;
println!("{}", json);
}
return Ok(());
}
let results = minutes_core::search::search_with_mode(query, config, &filters, sync_mode)?;
let limited: Vec<_> = results.into_iter().take(limit).collect();
if limited.is_empty() {
if json_mode {
} else {
eprintln!("No results found for \"{}\"", query);
println!("[]");
}
return Ok(());
}
if json_mode {
for result in &limited {
println!("{}", serde_json::to_string(result)?);
}
} else {
for result in &limited {
eprintln!(
"\n{} — {} [{}]",
result.date, result.title, result.content_type
);
if !result.snippet.is_empty() {
eprintln!(" {}", result.snippet);
}
eprintln!(" {}", result.path.display());
}
let json = serde_json::to_string_pretty(&limited)?;
println!("{}", json);
}
Ok(())
}
fn cmd_actions(assignee: Option<&str>, config: &Config) -> Result<()> {
let results = minutes_core::search::find_open_actions(config, assignee)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if results.is_empty() {
eprintln!("No open action items found.");
println!("[]");
return Ok(());
}
eprintln!("Open action items ({}):", results.len());
for item in &results {
let due = item.due.as_deref().unwrap_or("no due date");
eprintln!(" @{}: {} ({})", item.assignee, item.task, due);
eprintln!(" from: {} — {}", item.meeting_date, item.meeting_title);
}
let json = serde_json::to_string_pretty(&results)?;
println!("{}", json);
Ok(())
}
fn cmd_list(
limit: usize,
content_type: Option<String>,
sync_mode: minutes_core::search_index::SyncMode,
config: &Config,
) -> Result<()> {
cmd_search(
"",
content_type,
None,
limit,
false,
None,
None,
"text",
sync_mode,
config,
)
}
fn cmd_export(
content_type: Option<String>,
output: Option<PathBuf>,
config: &Config,
) -> Result<()> {
let filters = minutes_core::search::SearchFilters {
content_type,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = minutes_core::search::search("", config, &filters)?;
let mut wtr: Box<dyn std::io::Write> = if let Some(ref path) = output {
Box::new(std::fs::File::create(path)?)
} else {
Box::new(std::io::stdout())
};
let mut csv_wtr = csv::Writer::from_writer(&mut wtr);
csv_wtr.write_record(["date", "title", "type", "duration", "path"])?;
for result in &results {
let content = std::fs::read_to_string(&result.path).unwrap_or_default();
let (fm_str, _) = minutes_core::markdown::split_frontmatter(&content);
let duration =
minutes_core::markdown::extract_field(fm_str, "duration").unwrap_or_default();
csv_wtr.write_record([
&result.date,
&result.title,
&result.content_type,
&duration,
&result.path.display().to_string(),
])?;
}
csv_wtr.flush()?;
let count = results.len();
if let Some(ref path) = output {
eprintln!("Exported {} meetings to {}", count, path.display());
} else {
eprintln!("Exported {} meetings", count);
}
Ok(())
}
fn cmd_consistency(owner: Option<&str>, stale_after_days: i64, config: &Config) -> Result<()> {
let report = minutes_core::search::consistency_report(config, owner, stale_after_days)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if report.decision_conflicts.is_empty() && report.stale_commitments.is_empty() {
eprintln!("No consistency issues found.");
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
if !report.decision_conflicts.is_empty() {
eprintln!("Decision conflicts ({}):", report.decision_conflicts.len());
for conflict in &report.decision_conflicts {
eprintln!(" topic: {}", conflict.topic);
eprintln!(
" latest: {} — {}",
conflict.latest.title, conflict.latest.what
);
for previous in &conflict.previous {
eprintln!(" previous: {} — {}", previous.title, previous.what);
}
eprintln!(" {}", conflict.latest.path.display());
}
}
if !report.stale_commitments.is_empty() {
eprintln!("\nStale commitments ({}):", report.stale_commitments.len());
for stale in &report.stale_commitments {
let who = owner_display(
stale.entry.who.as_deref(),
stale.entry.who_original.as_deref(),
stale.entry.who_provenance.as_deref(),
);
let due = stale.entry.by_date.as_deref().unwrap_or("no due date");
let reasons = stale.reasons.join(", ");
eprintln!(
" {:?}: {} (@{}, {}, {} days old, {} meetings since)",
stale.kind, stale.entry.what, who, due, stale.age_days, stale.meetings_since
);
eprintln!(" why: {}", reasons);
if let Some(follow_up) = &stale.latest_follow_up {
eprintln!(
" latest follow-up: {} — {}",
follow_up.date, follow_up.title
);
}
eprintln!(" from: {} — {}", stale.entry.date, stale.entry.title);
eprintln!(" {}", stale.entry.path.display());
}
}
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn cmd_person(name: &str, config: &Config) -> Result<()> {
let profile =
minutes_core::search::person_profile(config, name).map_err(|e| anyhow::anyhow!("{}", e))?;
if profile.recent_meetings.is_empty()
&& profile.open_intents.is_empty()
&& profile.recent_decisions.is_empty()
{
eprintln!("No profile data found for {}.", name);
println!("{}", serde_json::to_string_pretty(&profile)?);
return Ok(());
}
eprintln!("Profile for {}:", profile.name);
if !profile.top_topics.is_empty() {
eprintln!(
" Top topics: {}",
profile
.top_topics
.iter()
.map(|topic| format!("{} ({})", topic.topic, topic.count))
.collect::<Vec<_>>()
.join(", ")
);
}
if !profile.open_intents.is_empty() {
eprintln!(" Open commitments/actions: {}", profile.open_intents.len());
}
if !profile.recent_decisions.is_empty() {
eprintln!(" Recent decisions: {}", profile.recent_decisions.len());
}
if !profile.recent_meetings.is_empty() {
eprintln!(" Recent meetings:");
for meeting in &profile.recent_meetings {
eprintln!(" {} — {}", meeting.date, meeting.title);
}
}
println!("{}", serde_json::to_string_pretty(&profile)?);
Ok(())
}
fn cmd_people(rebuild: bool, json: bool, limit: usize, config: &Config) -> Result<()> {
use minutes_core::graph;
if rebuild || !graph::db_path().exists() {
eprintln!("Building relationship index...");
let stats = graph::rebuild_index(config).map_err(|e| anyhow::anyhow!("{}", e))?;
eprintln!(
"Index rebuilt: {} people, {} meetings, {} commitments in {}ms",
stats.people_count, stats.meeting_count, stats.commitment_count, stats.rebuild_ms
);
if !stats.alias_suggestions.is_empty() {
eprintln!("\nPossible duplicates:");
for alias in &stats.alias_suggestions {
eprintln!(
" {} ↔ {} ({} shared meetings)",
alias.name_a, alias.name_b, alias.shared_meetings
);
}
}
eprintln!();
}
let all_people = graph::relationship_map(config).map_err(|e| anyhow::anyhow!("{}", e))?;
let people: Vec<_> = all_people.into_iter().take(limit).collect();
if json {
println!("{}", serde_json::to_string_pretty(&people)?);
return Ok(());
}
if people.is_empty() {
eprintln!(
"No people found. Record some meetings first, then run: minutes people --rebuild"
);
return Ok(());
}
eprintln!("TOP CONTACTS (by relationship score)");
for person in people.iter().take(limit) {
let status = if person.losing_touch {
"\x1b[33m⚠ losing touch\x1b[0m"
} else if person.open_commitments > 0 {
&format!(
"{} open commitment{}",
person.open_commitments,
if person.open_commitments != 1 {
"s"
} else {
""
}
)
} else {
"\x1b[32m✓ all clear\x1b[0m"
};
let last = if person.days_since < 1.0 {
"today".to_string()
} else if person.days_since < 2.0 {
"yesterday".to_string()
} else {
format!("{}d ago", person.days_since as i64)
};
eprintln!(
" {:<20} {} meeting{} last: {:<12} {}",
person.name,
person.meeting_count,
if person.meeting_count != 1 { "s" } else { " " },
last,
status
);
}
let commitments =
graph::query_commitments(config, None).map_err(|e| anyhow::anyhow!("{}", e))?;
let stale: Vec<_> = commitments.iter().filter(|c| c.status == "stale").collect();
if !stale.is_empty() {
eprintln!("\nSTALE COMMITMENTS");
for c in &stale {
let who = c.person_name.as_deref().unwrap_or("unknown");
eprintln!(
" • {} (assigned: {}, due: {})",
c.text,
who,
c.due_date.as_deref().unwrap_or("no date")
);
}
}
let losing: Vec<_> = people.iter().filter(|p| p.losing_touch).collect();
if !losing.is_empty() {
eprintln!("\nLOSING TOUCH");
for person in &losing {
eprintln!(
" {} — {} meetings total, last seen {}d ago",
person.name, person.meeting_count, person.days_since as i64
);
}
}
println!("{}", serde_json::to_string_pretty(&people)?);
Ok(())
}
#[derive(Serialize)]
struct VocabularyMutationOutput {
path: String,
entries: Vec<minutes_core::vocabulary::VocabularyEntry>,
note: String,
}
#[derive(Serialize)]
struct VocabularyRemoveOutput {
path: String,
removed: bool,
entries: Vec<minutes_core::vocabulary::VocabularyEntry>,
}
#[derive(Serialize)]
struct VocabularySuggestion {
canonical: String,
kind: String,
aliases: Vec<String>,
reason: String,
count: usize,
}
fn cmd_vocabulary(action: VocabularyAction, config: &Config) -> Result<()> {
match action {
VocabularyAction::List { json } => cmd_vocabulary_list(json),
VocabularyAction::Add {
kind,
canonical,
aliases,
json,
} => cmd_vocabulary_add(&kind, &canonical, aliases, json),
VocabularyAction::Remove { id, json } => cmd_vocabulary_remove(&id, json),
VocabularyAction::Suggest { meeting, json } => cmd_vocabulary_suggest(&meeting, json),
VocabularyAction::Rebuild { json } => cmd_vocabulary_rebuild(json, config),
}
}
fn cmd_vocabulary_list(json: bool) -> Result<()> {
let path = minutes_core::vocabulary::default_path();
let store = minutes_core::vocabulary::load().map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
println!("{}", serde_json::to_string_pretty(&store)?);
return Ok(());
}
if store.entries.is_empty() {
eprintln!("No vocabulary entries yet.");
eprintln!(
"Add one with: minutes vocabulary add --kind person \"Elijah Potter\" --alias Elijah"
);
eprintln!("Vocabulary file: {}", path.display());
return Ok(());
}
eprintln!("Vocabulary entries ({}):", store.entries.len());
for entry in &store.entries {
let aliases = if entry.aliases.is_empty() {
String::new()
} else {
format!(" aliases: {}", entry.aliases.join(", "))
};
eprintln!(
" {} [{}] {}{}",
entry.id,
vocabulary_kind_label(entry.kind),
entry.canonical,
aliases
);
}
eprintln!("Vocabulary file: {}", path.display());
Ok(())
}
fn cmd_vocabulary_add(kind: &str, canonical: &str, aliases: Vec<String>, json: bool) -> Result<()> {
let path = minutes_core::vocabulary::default_path();
let mut store = minutes_core::vocabulary::load().map_err(|e| anyhow::anyhow!("{}", e))?;
let now = Local::now().to_rfc3339();
store
.entries
.push(minutes_core::vocabulary::VocabularyEntry {
kind: parse_vocabulary_kind(kind)?,
canonical: canonical.to_string(),
aliases,
priority: minutes_core::vocabulary::VocabularyPriority::Normal,
source: minutes_core::vocabulary::VocabularySource::Manual,
created_at: Some(now.clone()),
updated_at: Some(now),
..minutes_core::vocabulary::VocabularyEntry::default()
});
let store = store.normalized().map_err(|e| anyhow::anyhow!("{}", e))?;
minutes_core::vocabulary::save_at(&path, &store).map_err(|e| anyhow::anyhow!("{}", e))?;
let output = VocabularyMutationOutput {
path: path.display().to_string(),
entries: store.entries,
note: "Saved. Future transcripts, search, and graph rebuilds can use this vocabulary; existing raw transcripts stay unchanged.".into(),
};
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!("Saved vocabulary entry for \"{}\".", canonical.trim());
eprintln!("Future transcripts/search/graph rebuilds can use it.");
eprintln!("Existing raw transcripts stay unchanged.");
}
Ok(())
}
fn cmd_vocabulary_remove(id: &str, json: bool) -> Result<()> {
let path = minutes_core::vocabulary::default_path();
let mut store = minutes_core::vocabulary::load().map_err(|e| anyhow::anyhow!("{}", e))?;
let before = store.entries.len();
store.entries.retain(|entry| entry.id != id);
let removed = store.entries.len() != before;
let store = store.normalized().map_err(|e| anyhow::anyhow!("{}", e))?;
minutes_core::vocabulary::save_at(&path, &store).map_err(|e| anyhow::anyhow!("{}", e))?;
let output = VocabularyRemoveOutput {
path: path.display().to_string(),
removed,
entries: store.entries,
};
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
} else if removed {
eprintln!("Removed vocabulary entry: {}", id);
eprintln!("Existing raw transcripts stay unchanged.");
} else {
eprintln!("No vocabulary entry found with id: {}", id);
}
Ok(())
}
fn cmd_vocabulary_suggest(meeting: &Path, json: bool) -> Result<()> {
let content = std::fs::read_to_string(meeting)
.map_err(|e| anyhow::anyhow!("could not read {}: {}", meeting.display(), e))?;
let (frontmatter, body) = minutes_core::markdown::split_frontmatter(&content);
let store = minutes_core::vocabulary::load().unwrap_or_default();
let suggestions = vocabulary_suggestions_from_meeting(frontmatter, body, &store);
if json {
println!("{}", serde_json::to_string_pretty(&suggestions)?);
return Ok(());
}
if suggestions.is_empty() {
eprintln!("No vocabulary suggestions found for {}.", meeting.display());
return Ok(());
}
eprintln!("Vocabulary suggestions for {}:", meeting.display());
for suggestion in &suggestions {
eprintln!(
" {} [{}] — {} (count: {})",
suggestion.canonical, suggestion.kind, suggestion.reason, suggestion.count
);
}
eprintln!("Suggestions are not applied automatically. Add one with:");
eprintln!(" minutes vocabulary add --kind person \"Name\" --alias Alias");
Ok(())
}
fn cmd_vocabulary_rebuild(json: bool, config: &Config) -> Result<()> {
let stats = minutes_core::graph::rebuild_index(config).map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
println!("{}", serde_json::to_string_pretty(&stats)?);
} else {
eprintln!(
"Rebuilt graph with vocabulary context: {} people, {} meetings, {} commitments in {}ms",
stats.people_count, stats.meeting_count, stats.commitment_count, stats.rebuild_ms
);
eprintln!("Existing raw transcripts stay unchanged.");
}
Ok(())
}
fn vocabulary_suggestions_from_meeting(
frontmatter: &str,
body: &str,
store: &minutes_core::vocabulary::VocabularyStore,
) -> Vec<VocabularySuggestion> {
let mut known = std::collections::HashSet::new();
for entry in &store.entries {
for form in entry.surface_forms() {
known.insert(vocabulary_key(&form));
}
}
let mut suggestions = Vec::new();
if let Ok(frontmatter) =
serde_yaml::from_str::<minutes_core::markdown::Frontmatter>(frontmatter)
{
for attendee in frontmatter.normalized_attendees() {
let canonical = clean_vocabulary_attendee_suggestion(&attendee);
if canonical.is_empty() || !known.insert(vocabulary_key(&canonical)) {
continue;
}
suggestions.push(VocabularySuggestion {
canonical: canonical.clone(),
kind: "person".into(),
aliases: vec![],
reason: "attendee not in vocabulary".into(),
count: 1,
});
for token in canonical.split_whitespace() {
known.insert(vocabulary_key(token));
}
}
}
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for phrase in proper_noun_phrases(body) {
if known.contains(&vocabulary_key(&phrase)) {
continue;
}
*counts.entry(phrase).or_default() += 1;
}
let mut repeated = counts
.into_iter()
.filter(|(phrase, count)| *count >= 2 && phrase.len() >= 4)
.collect::<Vec<_>>();
repeated.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (phrase, count) in repeated.into_iter().take(10) {
suggestions.push(VocabularySuggestion {
canonical: phrase,
kind: "term".into(),
aliases: vec![],
reason: "repeated capitalized phrase in transcript".into(),
count,
});
}
suggestions.truncate(20);
suggestions
}
fn proper_noun_phrases(body: &str) -> Vec<String> {
let mut phrases = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
{
continue;
}
for sentence in trimmed.split(['.', '!', '?', ';', ':']) {
let mut current = Vec::new();
for (index, token) in sentence.split_whitespace().enumerate() {
let cleaned = token.trim_matches(|c: char| !c.is_alphanumeric());
let looks_like_nameish = cleaned.len() >= 2
&& cleaned.chars().next().is_some_and(|ch| ch.is_uppercase())
&& !cleaned.chars().all(|ch| ch.is_uppercase())
&& !is_vocabulary_noise_token(cleaned)
&& !is_vocabulary_sentence_starter(cleaned, index);
if looks_like_nameish {
current.push(cleaned.to_string());
continue;
}
if !current.is_empty() {
phrases.push(current.join(" "));
current.clear();
}
}
if !current.is_empty() {
phrases.push(current.join(" "));
}
}
}
phrases
}
fn clean_vocabulary_attendee_suggestion(value: &str) -> String {
let without_parenthetical = value
.split_once(" (")
.map(|(name, _)| name)
.unwrap_or(value)
.trim();
without_parenthetical
.trim_matches(|c: char| c == '-' || c == ',' || c == ';')
.trim()
.to_string()
}
fn is_vocabulary_noise_token(token: &str) -> bool {
token.starts_with("SPEAKER_")
|| matches!(
token,
"Actually"
| "Absolutely"
| "Awesome"
| "Basically"
| "Because"
| "Cool"
| "Does"
| "Exactly"
| "Friday"
| "Good"
| "Great"
| "Have"
| "Hello"
| "He's"
| "Hey"
| "I"
| "I'll"
| "I'm"
| "I've"
| "It's"
| "Let's"
| "Like"
| "Make"
| "Monday"
| "No"
| "Okay"
| "Right"
| "Saturday"
| "She's"
| "Sunday"
| "Sure"
| "They"
| "Thanks"
| "Thank"
| "That"
| "That's"
| "There"
| "There's"
| "Then"
| "Thursday"
| "Tuesday"
| "This"
| "Totally"
| "Wednesday"
| "Well"
| "We're"
| "What"
| "Why"
| "Your"
| "Yeah"
| "Yep"
| "You're"
)
}
fn is_vocabulary_sentence_starter(token: &str, index: usize) -> bool {
index == 0
&& matches!(
token,
"A" | "An"
| "And"
| "But"
| "I"
| "It"
| "Later"
| "Meeting"
| "Next"
| "So"
| "That"
| "The"
| "Then"
| "These"
| "This"
| "Today"
| "Tomorrow"
| "Transcript"
| "We"
| "Yesterday"
)
}
fn parse_vocabulary_kind(kind: &str) -> Result<minutes_core::vocabulary::VocabularyKind> {
match kind {
"person" => Ok(minutes_core::vocabulary::VocabularyKind::Person),
"organization" => Ok(minutes_core::vocabulary::VocabularyKind::Organization),
"project" => Ok(minutes_core::vocabulary::VocabularyKind::Project),
"term" => Ok(minutes_core::vocabulary::VocabularyKind::Term),
"acronym" => Ok(minutes_core::vocabulary::VocabularyKind::Acronym),
other => Err(anyhow::anyhow!("unknown vocabulary kind: {}", other)),
}
}
fn vocabulary_kind_label(kind: minutes_core::vocabulary::VocabularyKind) -> &'static str {
match kind {
minutes_core::vocabulary::VocabularyKind::Person => "person",
minutes_core::vocabulary::VocabularyKind::Organization => "organization",
minutes_core::vocabulary::VocabularyKind::Project => "project",
minutes_core::vocabulary::VocabularyKind::Term => "term",
minutes_core::vocabulary::VocabularyKind::Acronym => "acronym",
}
}
fn vocabulary_key(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_ascii_lowercase()
}
fn cmd_commitments(person: Option<&str>, json: bool, config: &Config) -> Result<()> {
use minutes_core::graph;
if !graph::db_path().exists() {
eprintln!("Building relationship index...");
graph::rebuild_index(config).map_err(|e| anyhow::anyhow!("{}", e))?;
}
let commitments =
graph::query_commitments(config, person).map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
println!("{}", serde_json::to_string_pretty(&commitments)?);
return Ok(());
}
if commitments.is_empty() {
let scope = person.map(|p| format!(" for {}", p)).unwrap_or_default();
eprintln!("No open commitments found{}.", scope);
return Ok(());
}
let stale: Vec<_> = commitments.iter().filter(|c| c.status == "stale").collect();
let open: Vec<_> = commitments.iter().filter(|c| c.status == "open").collect();
if !stale.is_empty() {
eprintln!("STALE ({} overdue)", stale.len());
for c in &stale {
let who = c.person_name.as_deref().unwrap_or("unassigned");
eprintln!(
" \x1b[33m⚠\x1b[0m {} \x1b[2m({}; due: {}; from: {})\x1b[0m",
c.text,
who,
c.due_date.as_deref().unwrap_or("no date"),
c.meeting_title,
);
}
}
if !open.is_empty() {
if !stale.is_empty() {
eprintln!();
}
eprintln!("OPEN ({})", open.len());
for c in &open {
let who = c.person_name.as_deref().unwrap_or("unassigned");
eprintln!(
" · {} \x1b[2m({}; from: {})\x1b[0m",
c.text, who, c.meeting_title
);
}
}
Ok(())
}
fn cmd_research(
query: &str,
content_type: Option<String>,
since: Option<String>,
attendee: Option<String>,
config: &Config,
) -> Result<()> {
let filters = minutes_core::search::SearchFilters {
content_type,
since,
attendee,
intent_kind: None,
owner: None,
recorded_by: None,
};
let report = minutes_core::search::cross_meeting_research(query, config, &filters)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if report.related_decisions.is_empty()
&& report.related_open_intents.is_empty()
&& report.recent_meetings.is_empty()
{
eprintln!("No cross-meeting results found for {}.", query);
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
eprintln!("Cross-meeting research for {}:", query);
if !report.related_topics.is_empty() {
eprintln!(
" Related topics: {}",
report
.related_topics
.iter()
.map(|topic| format!("{} ({})", topic.topic, topic.count))
.collect::<Vec<_>>()
.join(", ")
);
}
if !report.related_decisions.is_empty() {
eprintln!(" Recent decisions:");
for decision in &report.related_decisions {
eprintln!(" {} — {}", decision.date, decision.what);
}
}
if !report.related_open_intents.is_empty() {
eprintln!(" Open follow-ups:");
for intent in &report.related_open_intents {
let owner = owner_display(
intent.who.as_deref(),
intent.who_original.as_deref(),
intent.who_provenance.as_deref(),
);
let due = intent.by_date.as_deref().unwrap_or("no due date");
eprintln!(
" {:?}: {} (@{}, {})",
intent.kind, intent.what, owner, due
);
}
}
if !report.recent_meetings.is_empty() {
eprintln!(" Matching meetings:");
for meeting in &report.recent_meetings {
eprintln!(" {} — {}", meeting.date, meeting.title);
}
}
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn parse_intent_kind(kind: &str) -> Result<minutes_core::markdown::IntentKind> {
match kind {
"action-item" => Ok(minutes_core::markdown::IntentKind::ActionItem),
"decision" => Ok(minutes_core::markdown::IntentKind::Decision),
"open-question" => Ok(minutes_core::markdown::IntentKind::OpenQuestion),
"commitment" => Ok(minutes_core::markdown::IntentKind::Commitment),
other => anyhow::bail!(
"unknown intent kind: {}. Use action-item, decision, open-question, or commitment.",
other
),
}
}
fn cmd_ingest(path: Option<PathBuf>, all: bool, dry_run: bool, config: &Config) -> Result<()> {
if !config.knowledge.enabled || config.knowledge.path.as_os_str().is_empty() {
eprintln!("Knowledge base is not configured.");
eprintln!("Add this to ~/.config/minutes/config.toml:\n");
eprintln!("[knowledge]");
eprintln!("enabled = true");
eprintln!("path = \"/path/to/your/knowledge/base\"");
eprintln!("adapter = \"wiki\" # or \"para\", \"obsidian\"");
return Ok(());
}
let files: Vec<PathBuf> = if all {
let mut found = Vec::new();
for entry_result in walkdir::WalkDir::new(&config.output_dir)
.max_depth(2)
.into_iter()
{
let entry = match entry_result {
Ok(e) => e,
Err(e) => {
eprintln!(" WARN: {}", e);
continue;
}
};
let p = entry.path();
if p.extension().is_some_and(|ext| ext == "md")
&& !p.starts_with(config.output_dir.join("memos"))
{
found.push(p.to_path_buf());
}
}
found.sort();
found
} else if let Some(ref p) = path {
if !p.exists() {
anyhow::bail!("File not found: {}", p.display());
}
vec![p.clone()]
} else {
eprintln!("Usage: minutes ingest <path> or minutes ingest --all");
return Ok(());
};
eprintln!(
"Ingesting {} meeting(s) into knowledge base at {}",
files.len(),
config.knowledge.path.display()
);
if dry_run {
eprintln!("(dry run — no files will be written)\n");
}
let mut total_written = 0usize;
let mut total_skipped = 0usize;
let mut total_people = std::collections::HashSet::new();
let mut errors = 0usize;
for file in &files {
let filename = file.file_name().unwrap_or_default().to_string_lossy();
if dry_run {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
eprintln!(" SKIP {}: {}", filename, e);
errors += 1;
continue;
}
};
let (fm_str, _body) = minutes_core::markdown::split_frontmatter(&content);
if fm_str.is_empty() {
eprintln!(" SKIP {}: no frontmatter", filename);
continue;
}
let fm: minutes_core::markdown::Frontmatter = match serde_yaml::from_str(fm_str) {
Ok(f) => f,
Err(e) => {
eprintln!(" SKIP {}: {}", filename, e);
errors += 1;
continue;
}
};
let facts = minutes_core::knowledge_extract::extract_from_frontmatter(
&fm,
&file.display().to_string(),
);
let fact_count: usize = facts.iter().map(|pf| pf.facts.len()).sum();
let people: Vec<&str> = facts.iter().map(|pf| pf.name.as_str()).collect();
if fact_count > 0 {
eprintln!(
" {} — {} fact(s) for: {}",
filename,
fact_count,
people.join(", ")
);
}
total_written += fact_count;
} else {
match minutes_core::knowledge::ingest_file(file, config) {
Ok(result) => {
if result.facts_written > 0 {
eprintln!(
" {} — {} written, {} skipped — {}",
filename,
result.facts_written,
result.facts_skipped,
result.people_updated.join(", ")
);
}
total_written += result.facts_written;
total_skipped += result.facts_skipped;
for p in result.people_updated {
total_people.insert(p);
}
}
Err(e) => {
eprintln!(" SKIP {}: {}", filename, e);
errors += 1;
}
}
}
}
eprintln!(
"\nDone. {} fact(s) written, {} skipped, {} error(s), {} people updated.",
total_written,
total_skipped,
errors,
total_people.len()
);
Ok(())
}
fn cmd_clean(meeting: &str, apply: bool, config: &Config) -> Result<()> {
let meetings_dir = &config.output_dir;
let files: Vec<std::path::PathBuf> = if meeting == "all" {
let mut found = Vec::new();
if meetings_dir.exists() {
for entry in std::fs::read_dir(meetings_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
found.push(path);
}
}
}
let memos_dir = meetings_dir.join("memos");
if memos_dir.exists() {
for entry in std::fs::read_dir(&memos_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "md") {
found.push(path);
}
}
}
found.sort();
found
} else {
let path = std::path::PathBuf::from(meeting);
if path.exists() {
let canonical = path.canonicalize()?;
let meetings_canonical = meetings_dir
.canonicalize()
.unwrap_or_else(|_| meetings_dir.clone());
if !canonical.starts_with(&meetings_canonical) {
anyhow::bail!(
"path {} is outside the meetings directory ({})",
path.display(),
meetings_dir.display()
);
}
vec![canonical]
} else {
let filters = minutes_core::search::SearchFilters {
content_type: None,
since: None,
attendee: None,
intent_kind: None,
owner: None,
recorded_by: None,
};
let results = minutes_core::search::search(meeting, config, &filters)?;
if results.is_empty() {
anyhow::bail!("no meeting found matching: {}", meeting);
}
eprintln!(" Matched: {}", results[0].path.display());
vec![results[0].path.clone()]
}
};
if files.is_empty() {
eprintln!("No meeting files found.");
return Ok(());
}
let mut total_cleaned = 0;
let mut total_lines_removed = 0;
for path in &files {
let content = std::fs::read_to_string(path)?;
let (fm, body) = minutes_core::markdown::split_frontmatter(&content);
let transcript_marker = "\n## Transcript";
if let Some(transcript_start) = body.find(transcript_marker) {
let heading_start = transcript_start + 1; let transcript_offset = heading_start + "## Transcript".len();
let before_transcript = &body[..heading_start];
let transcript_text = body[transcript_offset..].trim_start_matches('\n');
let (transcript_part, after_transcript) =
if let Some(next_section) = transcript_text.find("\n## ") {
(
&transcript_text[..next_section],
Some(&transcript_text[next_section..]),
)
} else {
(transcript_text, None)
};
let (cleaned, stats) = minutes_core::transcribe::clean_transcript(transcript_part);
if stats.lines_removed > 0 {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if apply {
let mut new_content = String::new();
if !fm.is_empty() {
new_content.push_str("---\n");
new_content.push_str(fm.trim_start_matches('\n'));
if !fm.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str("---\n");
}
new_content.push_str(before_transcript.trim_start_matches('\n'));
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str("\n## Transcript\n\n");
new_content.push_str(&cleaned);
if let Some(after) = after_transcript {
new_content.push_str(after);
}
new_content.push('\n');
let backup = path.with_extension("md.bak");
std::fs::copy(path, &backup)?;
let tmp_path = path.with_extension("md.tmp");
std::fs::write(&tmp_path, &new_content)?;
std::fs::rename(&tmp_path, path)?;
eprintln!(
" Cleaned {} — removed {} lines ({} → {})",
filename,
stats.lines_removed,
stats.original_lines,
stats.after_trailing_trim
);
} else {
eprintln!(
" {} — would remove {} lines ({} → {})",
filename,
stats.lines_removed,
stats.original_lines,
stats.after_trailing_trim
);
}
total_cleaned += 1;
total_lines_removed += stats.lines_removed;
}
}
}
eprintln!();
if total_cleaned == 0 {
eprintln!(
"All {} meetings are clean — no hallucination loops detected.",
files.len()
);
} else if apply {
eprintln!(
"Cleaned {} meeting(s), removed {} total lines of hallucinated repetition.",
total_cleaned, total_lines_removed
);
} else {
eprintln!(
"Found {} meeting(s) with hallucinated repetition ({} lines to remove).",
total_cleaned, total_lines_removed
);
eprintln!("Run with --apply to fix them.");
}
Ok(())
}
fn cmd_process(
path: &Path,
content_type: &str,
title: Option<&str>,
template: Option<&minutes_core::Template>,
config: &Config,
) -> Result<()> {
if !path.exists() {
anyhow::bail!("file not found: {}", path.display());
}
let ct = match content_type {
"meeting" => ContentType::Meeting,
"memo" => ContentType::Memo,
other => anyhow::bail!("unknown content type: {}. Use 'meeting' or 'memo'.", other),
};
config.ensure_dirs()?;
let result = minutes_core::pipeline::process_with_template(
path,
ct,
title,
config,
None,
template,
|_| {},
)?;
eprintln!("Saved: {}", result.path.display());
if let Err(e) = minutes_core::graph::rebuild_index(config) {
tracing::warn!(error = %e, "graph index rebuild failed (non-fatal)");
}
let json = serde_json::to_string_pretty(&serde_json::json!({
"status": "done",
"file": result.path.display().to_string(),
"title": result.title,
"words": result.word_count,
}))?;
println!("{}", json);
Ok(())
}
fn cmd_template(cmd: TemplateCmd) -> Result<()> {
let resolver = minutes_core::TemplateResolver::new();
match cmd {
TemplateCmd::List => {
let listings = resolver.list();
if listings.is_empty() {
eprintln!("No templates installed.");
return Ok(());
}
let slug_width = listings
.iter()
.map(|l| l.slug.len())
.max()
.unwrap_or(8)
.max(8);
let source_width = 8; println!(
"{slug:slug_w$} {src:src_w$} DESCRIPTION",
slug = "SLUG",
src = "SOURCE",
slug_w = slug_width,
src_w = source_width,
);
for listing in listings {
println!(
"{:slug_w$} {:src_w$} {}",
listing.slug,
listing.source.as_str(),
listing.description,
slug_w = slug_width,
src_w = source_width,
);
}
Ok(())
}
TemplateCmd::Show { slug } => {
let template = resolver
.resolve(&slug)
.map_err(|e| anyhow::anyhow!("{}", e))?;
match template.path.as_ref() {
Some(path) => {
let body = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("could not read {}: {}", path.display(), e))?;
print!("{}", body);
}
None => {
let yaml = serde_yaml::to_string(&template.frontmatter)
.map_err(|e| anyhow::anyhow!("could not render template: {}", e))?;
println!("---\n{}---\n", yaml);
print!("{}", template.body);
}
}
Ok(())
}
TemplateCmd::Validate { path } => {
if !path.exists() {
anyhow::bail!("file not found: {}", path.display());
}
let template =
minutes_core::Template::load_file(&path, minutes_core::TemplateSource::Project)
.map_err(|e| anyhow::anyhow!("{}", e))?;
println!(
"OK: template '{}' ({} v{})",
template.frontmatter.slug, template.frontmatter.name, template.frontmatter.version
);
Ok(())
}
}
}
fn cmd_diagnose(path: &Path, title: Option<&str>, config: &Config) -> Result<()> {
if !path.exists() {
anyhow::bail!("file not found: {}", path.display());
}
config.ensure_dirs()?;
eprintln!("=== Diagnose: {} ===", path.display());
eprintln!();
eprintln!("--- Diarization ---");
let diarize_outcome = minutes_core::diarize::diarize_with_context(
path,
config,
minutes_core::diarize::DiarizationContext {
purpose: minutes_core::diarize::DiarizationPurpose::Auxiliary,
transcript_windows: None,
},
);
let diarization_embeddings = match &diarize_outcome {
minutes_core::diarize::DiarizationOutcome::Result(result) => {
eprintln!(" Speakers: {}", result.num_speakers);
for seg in &result.segments {
eprintln!(" [{} {:.1}s–{:.1}s]", seg.speaker, seg.start, seg.end);
}
for (label, emb) in &result.speaker_embeddings {
let rms = (emb.iter().map(|v| v * v).sum::<f32>() / emb.len() as f32).sqrt();
eprintln!(" {}: {} dims, rms={:.2}", label, emb.len(), rms);
}
result.speaker_embeddings.clone()
}
minutes_core::diarize::DiarizationOutcome::Skipped { reason } => {
eprintln!(
" Diarization skipped because capture health was degraded: {:?}",
reason.failure_kind
);
eprintln!(
" Source: {:?}, confidence: {:?}",
reason.capture_source, reason.diagnostic_confidence
);
std::collections::HashMap::new()
}
minutes_core::diarize::DiarizationOutcome::NotConfigured => {
eprintln!(" No diarization result (disabled or failed).");
std::collections::HashMap::new()
}
};
eprintln!();
eprintln!("--- Voice Matching ---");
if config.voice.enabled && !diarization_embeddings.is_empty() {
let profiles = minutes_core::voice::open_db()
.ok()
.and_then(|conn| minutes_core::voice::load_all_with_embeddings(&conn).ok())
.unwrap_or_default();
if profiles.is_empty() {
eprintln!(" No enrolled voice profiles. Run `minutes enroll` first.");
} else {
eprintln!(" Enrolled profiles: {}", profiles.len());
for p in &profiles {
eprintln!(" {} ({})", p.name, p.person_slug);
}
let threshold = config.voice.match_threshold;
eprintln!(" Threshold: {:.2}", threshold);
eprintln!();
for (label, emb) in &diarization_embeddings {
eprintln!(" {} vs enrolled profiles:", label);
for p in &profiles {
let sim = minutes_core::voice::cosine_similarity(emb, &p.embedding);
let marker = if sim > threshold { " ✓ MATCH" } else { "" };
eprintln!(" → {} : sim={:.4}{}", p.name, sim, marker);
}
}
}
} else if !config.voice.enabled {
eprintln!(" Voice matching disabled.");
} else {
eprintln!(" No speaker embeddings to match against.");
}
eprintln!();
eprintln!("--- Pipeline ---");
let result = minutes_core::process(path, ContentType::Meeting, title, config)?;
eprintln!(" Output: {}", result.path.display());
eprintln!(" Title: {}", result.title);
eprintln!(" Words: {}", result.word_count);
eprintln!();
let content = std::fs::read_to_string(&result.path)?;
println!("{}", content);
Ok(())
}
#[cfg(feature = "parakeet")]
#[allow(clippy::too_many_arguments)]
fn cmd_parakeet_helper(
binary: &str,
model_path: &Path,
audio_path: &Path,
vocab_path: &Path,
model_id: &str,
gpu: bool,
fp16: bool,
vad_path: Option<&Path>,
vad_threshold: f32,
config: &Config,
) -> Result<()> {
let resolved_binary = minutes_core::parakeet::resolve_parakeet_binary(
binary,
minutes_core::parakeet::ResolveParakeetBinaryMode::WarnAndFallback,
)
.map_err(anyhow::Error::msg)?;
let config = if fp16 {
let mut overridden = config.clone();
overridden.transcription.parakeet_fp16 = true;
std::borrow::Cow::Owned(overridden)
} else {
std::borrow::Cow::Borrowed(config)
};
let parsed = minutes_core::transcribe::run_parakeet_cli_structured(
resolved_binary
.to_str()
.ok_or_else(|| anyhow::anyhow!("resolved parakeet binary path is not valid UTF-8"))?,
model_path,
audio_path,
vocab_path,
model_id,
gpu,
vad_path,
vad_threshold,
&config,
&minutes_core::transcribe::DecodeHints::default(),
)?;
let envelope = parakeet_helper_envelope("minutes parakeet-helper", parsed);
println!("{}", serde_json::to_string(&envelope)?);
Ok(())
}
#[cfg(not(feature = "parakeet"))]
#[allow(clippy::too_many_arguments)]
fn cmd_parakeet_helper(
_binary: &str,
_model_path: &Path,
_audio_path: &Path,
_vocab_path: &Path,
_model_id: &str,
_gpu: bool,
_fp16: bool,
_vad_path: Option<&Path>,
_vad_threshold: f32,
_config: &Config,
) -> Result<()> {
anyhow::bail!(
"Parakeet helper is not compiled in. Rebuild with `cargo build --features parakeet`."
);
}
#[cfg(feature = "parakeet")]
#[allow(clippy::too_many_arguments)]
fn cmd_parakeet_benchmark(
binary: &str,
model_path: &Path,
audio_path: &Path,
vocab_path: &Path,
model_id: &str,
gpu: bool,
vad_path: Option<&Path>,
vad_threshold: f32,
config: &Config,
) -> Result<()> {
let helper_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("minutes"));
let report = minutes_core::transcription_coordinator::benchmark_parakeet(
&helper_bin,
binary,
model_path,
audio_path,
vocab_path,
model_id,
gpu,
vad_path,
vad_threshold,
config,
)
.map_err(anyhow::Error::msg)?;
let envelope = json_envelope("minutes parakeet-benchmark", report);
println!("{}", serde_json::to_string_pretty(&envelope)?);
Ok(())
}
#[cfg(not(feature = "parakeet"))]
#[allow(clippy::too_many_arguments)]
fn cmd_parakeet_benchmark(
_binary: &str,
_model_path: &Path,
_audio_path: &Path,
_vocab_path: &Path,
_model_id: &str,
_gpu: bool,
_vad_path: Option<&Path>,
_vad_threshold: f32,
_config: &Config,
) -> Result<()> {
anyhow::bail!(
"Parakeet benchmark is not compiled in. Rebuild with `cargo build --features parakeet`."
);
}
fn cmd_autoresearch_decode_hints(
corpus: &Path,
output_root: Option<&Path>,
engine: Option<&str>,
json: bool,
) -> Result<()> {
let options = DecodeHintEvalOptions {
engine_override: engine.map(|value| value.to_string()),
};
let report = autoresearch::run_decode_hint_eval_corpus(corpus, &options)?;
let request = DecodeHintEvalRequest {
command: "minutes autoresearch decode-hints".into(),
generated_at: Local::now().to_rfc3339(),
corpus_path: corpus.to_path_buf(),
output_root: output_root
.map(Path::to_path_buf)
.unwrap_or_else(autoresearch::default_research_root),
git_commit: current_git_commit(),
options,
};
let artifacts = autoresearch::write_decode_hint_eval_artifacts(&request, &report)?;
let failed = !report.failure_messages.is_empty();
if json {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AutoresearchDecodeHintsOutput {
report: minutes_core::autoresearch::DecodeHintEvalReport,
artifacts: DecodeHintEvalArtifactPaths,
}
let envelope = json_envelope(
"minutes autoresearch decode-hints",
AutoresearchDecodeHintsOutput {
report,
artifacts: artifacts.clone(),
},
);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
println!(
"{}",
render_decode_hints_plaintext_summary(&report, &artifacts.run_dir, failed)
);
}
if failed {
anyhow::bail!(
"decode hint eval failed; see {}",
artifacts.summary_md.display()
);
}
Ok(())
}
fn render_decode_hints_plaintext_summary(
report: &minutes_core::autoresearch::DecodeHintEvalReport,
artifact_dir: &Path,
failed: bool,
) -> String {
let verdict = if failed {
"FAIL"
} else if report.totals.cases_allowed_failures > 0 {
"PASS WITH ALLOWED FAILURES"
} else {
"PASS"
};
let mut lines = vec![
format!("Decode hint eval: {verdict}"),
format!("Cases: {}", report.totals.cases_total),
format!("Passed: {}", report.totals.cases_passed),
format!("Failed: {}", report.totals.cases_failed),
format!("Allowed failures: {}", report.totals.cases_allowed_failures),
format!("Artifacts: {}", artifact_dir.display()),
];
if failed {
lines.push(String::new());
lines.push("Failure messages:".into());
for failure in &report.failure_messages {
lines.push(format!("- {failure}"));
}
} else if report.totals.cases_allowed_failures > 0 {
lines.push(String::new());
lines.push("Allowed-failure cases:".into());
for case in report
.cases
.iter()
.filter(|case| !case.allowed_failure_reasons.is_empty())
{
lines.push(format!(
"- {}: {}",
case.id,
case.allowed_failure_reasons.join("; ")
));
}
}
lines.join("\n")
}
fn cmd_autoresearch_compare_decode_hints(
left: &Path,
right: &Path,
output_root: Option<&Path>,
json: bool,
) -> Result<()> {
let report = autoresearch::compare_decode_hint_eval_reports(left, right)?;
let request = DecodeHintEvalComparisonRequest {
command: "minutes autoresearch compare-decode-hints".into(),
generated_at: Local::now().to_rfc3339(),
left_path: left.to_path_buf(),
right_path: right.to_path_buf(),
output_root: output_root
.map(Path::to_path_buf)
.unwrap_or_else(autoresearch::default_comparison_root),
};
let artifacts = autoresearch::write_decode_hint_eval_comparison_artifacts(&request, &report)?;
if json {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AutoresearchDecodeHintsComparisonOutput {
report: minutes_core::autoresearch::DecodeHintEvalComparisonReport,
artifacts: DecodeHintEvalComparisonArtifactPaths,
}
let envelope = json_envelope(
"minutes autoresearch compare-decode-hints",
AutoresearchDecodeHintsComparisonOutput {
report,
artifacts: artifacts.clone(),
},
);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
println!("Decode hint eval comparison");
println!("Shared cases: {}", report.totals.shared_cases);
println!("Added cases: {}", report.totals.added_cases);
println!("Removed cases: {}", report.totals.removed_cases);
println!("Improved cases: {}", report.totals.improved_cases);
println!("Regressed cases: {}", report.totals.regressed_cases);
println!("Newly passing: {}", report.totals.newly_passing_cases);
println!("Newly failing: {}", report.totals.newly_failing_cases);
println!("Artifacts: {}", artifacts.run_dir.display());
}
Ok(())
}
fn cmd_autoresearch_list_decode_hints(limit: usize, json: bool) -> Result<()> {
let runs = autoresearch::list_decode_hint_runs(limit)?;
if json {
let envelope = json_envelope("minutes autoresearch list-decode-hints", runs);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else if runs.is_empty() {
println!("No decode-hint research runs found.");
} else {
println!("Recent decode-hint research runs");
for run in runs {
println!(
"- {} [{}] {} cases, {} failed, {} improved, {} regressed, {} newly passing, {} newly failing",
run.generated_at,
run.kind,
run.cases_total,
run.cases_failed,
run.improved_cases,
run.regressed_cases,
run.newly_passing_cases,
run.newly_failing_cases
);
println!(" status: {}", run.status);
println!(" source: {}", run.source_path.display());
println!(" dir: {}", run.run_dir.display());
println!(" summary: {}", run.summary_path.display());
}
}
Ok(())
}
#[derive(Serialize)]
struct CapabilityReport {
version: String,
api_version: u32,
features: std::collections::BTreeMap<String, bool>,
}
fn build_capability_report() -> CapabilityReport {
let mut features = std::collections::BTreeMap::new();
features.insert("activity_summary".into(), true);
features.insert("search_context".into(), true);
features.insert("get_moment".into(), true);
features.insert("add_note".into(), true);
features.insert("confirm_speaker".into(), true);
features.insert("consistency_report".into(), true);
features.insert("events_since_seq".into(), true);
features.insert("get_meeting".into(), true);
features.insert("get_meeting_insights".into(), true);
features.insert("get_person_profile".into(), true);
features.insert("get_status".into(), true);
features.insert("ingest_meeting".into(), true);
features.insert("knowledge_status".into(), true);
features.insert("list_meetings".into(), true);
features.insert("list_processing_jobs".into(), true);
features.insert("list_voices".into(), true);
features.insert("open_dashboard".into(), true);
features.insert("process_audio".into(), true);
features.insert("qmd_collection_status".into(), true);
features.insert("read_live_transcript".into(), true);
features.insert("register_qmd_collection".into(), true);
features.insert("relationship_map".into(), true);
features.insert("research_topic".into(), true);
features.insert("search_meetings".into(), true);
features.insert("start_dictation".into(), true);
features.insert("start_live_transcript".into(), true);
features.insert("start_recording".into(), true);
features.insert("stop_dictation".into(), true);
features.insert("stop_recording".into(), true);
features.insert("track_commitments".into(), true);
features.insert("parakeet".into(), cfg!(feature = "parakeet"));
features.insert("diarize".into(), cfg!(feature = "diarize"));
features.insert("setup_demo".into(), true);
CapabilityReport {
version: env!("CARGO_PKG_VERSION").to_string(),
api_version: 1,
features,
}
}
fn cmd_capabilities(json: bool) -> Result<()> {
let report = build_capability_report();
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
println!("Minutes CLI capabilities");
println!(" version: {}", report.version);
println!(" api_version: {}", report.api_version);
println!(" features:");
for (name, supported) in &report.features {
let marker = if *supported { "yes" } else { "no" };
println!(" {}: {}", name, marker);
}
Ok(())
}
fn cmd_apple_speech_capabilities(json: bool) -> Result<()> {
let report = apple_speech::probe_capabilities()?;
if json {
let envelope = json_envelope("minutes apple-speech capabilities", report);
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
println!("Apple speech capability probe");
println!("OS: {}", report.os_version);
println!("Runtime supported: {}", report.runtime_supported);
println!(
"SpeechTranscriber available: {}",
report
.speech_transcriber
.is_available
.map(|value| value.to_string())
.unwrap_or_else(|| "n/a".into())
);
println!(
"SpeechTranscriber asset status: {}",
report.speech_transcriber.asset_status
);
println!(
"DictationTranscriber asset status: {}",
report.dictation_transcriber.asset_status
);
if !report.speech_transcriber.installed_locales.is_empty() {
println!(
"SpeechTranscriber installed locales: {}",
report.speech_transcriber.installed_locales.join(", ")
);
}
if !report.dictation_transcriber.installed_locales.is_empty() {
println!(
"DictationTranscriber installed locales: {}",
report.dictation_transcriber.installed_locales.join(", ")
);
}
if !report.notes.is_empty() {
println!("Notes:");
for note in &report.notes {
println!("- {}", note);
}
}
Ok(())
}
fn cmd_apple_speech_benchmark(
corpus: &Path,
output_root: Option<&Path>,
json: bool,
config: &Config,
) -> Result<()> {
let report = apple_speech::run_benchmark_corpus(corpus, config)?;
let request = AppleSpeechBenchmarkRequest {
command: "minutes apple-speech benchmark".into(),
generated_at: Local::now().to_rfc3339(),
corpus_path: corpus.to_path_buf(),
output_root: output_root
.map(Path::to_path_buf)
.unwrap_or_else(apple_speech::default_research_root),
configured_engine: config.transcription.engine.clone(),
};
let artifacts = apple_speech::write_benchmark_artifacts(&request, &report)?;
if json {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AppleSpeechBenchmarkOutput {
report: minutes_core::apple_speech::AppleSpeechBenchmarkReport,
artifacts: AppleSpeechBenchmarkArtifactPaths,
}
let envelope = json_envelope(
"minutes apple-speech benchmark",
AppleSpeechBenchmarkOutput {
report,
artifacts: artifacts.clone(),
},
);
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
println!("Apple speech benchmark complete");
println!("Cases: {}", report.cases.len());
println!("Artifacts: {}", artifacts.run_dir.display());
println!(
"SpeechTranscriber avg elapsed: {} ms",
report
.totals
.speech_transcriber
.average_elapsed_ms
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "n/a".into())
);
println!(
"DictationTranscriber avg elapsed: {} ms",
report
.totals
.dictation_transcriber
.average_elapsed_ms
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "n/a".into())
);
println!(
"Whisper avg elapsed: {} ms",
report
.totals
.whisper
.average_elapsed_ms
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "n/a".into())
);
println!(
"Parakeet avg elapsed: {} ms",
report
.totals
.parakeet
.average_elapsed_ms
.map(|value| format!("{value:.2}"))
.unwrap_or_else(|| "n/a".into())
);
Ok(())
}
fn current_git_commit() -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn cmd_watch(dir: Option<&Path>, config: &Config) -> Result<()> {
config.ensure_dirs()?;
ctrlc::set_handler(move || {
eprintln!("\nStopping watcher...");
minutes_core::parakeet_sidecar::shutdown_global_parakeet_sidecar();
let lock_path = minutes_core::watch::lock_path();
std::fs::remove_file(&lock_path).ok();
std::process::exit(0);
})?;
minutes_core::watch::run(dir, config).map_err(|e| anyhow::anyhow!("{}", e))
}
fn cmd_devices() -> Result<()> {
let devices = minutes_core::capture::list_input_devices();
if devices.is_empty() {
eprintln!("No audio input devices found.");
} else {
eprintln!("Audio input devices:");
for d in &devices {
eprintln!(" {}", d);
}
let json = serde_json::to_string_pretty(&devices)?;
println!("{}", json);
}
#[cfg(target_os = "macos")]
eprintln!("\nTip: Install BlackHole for system audio capture: brew install blackhole-2ch");
#[cfg(target_os = "windows")]
eprintln!("\nTip: Install VB-CABLE for system audio capture: https://vb-audio.com/Cable/");
#[cfg(target_os = "linux")]
eprintln!(
"\nTip: System audio capture works automatically when PipeWire or PulseAudio is running. \
Run `minutes sources` for the categorized view."
);
Ok(())
}
fn cmd_sources() -> Result<()> {
use minutes_core::capture::{list_devices_categorized, DeviceCategory};
let devices = list_devices_categorized();
if devices.is_empty() {
eprintln!("No audio input devices found.");
return Ok(());
}
let mics: Vec<_> = devices
.iter()
.filter(|d| d.category == DeviceCategory::Microphone)
.collect();
let system: Vec<_> = devices
.iter()
.filter(|d| d.category == DeviceCategory::SystemAudio)
.collect();
let virtual_devs: Vec<_> = devices
.iter()
.filter(|d| d.category == DeviceCategory::Virtual)
.collect();
eprintln!("Microphones:");
for d in &mics {
let marker = if d.is_default { "* " } else { " " };
eprintln!(
" {}{} ({}Hz, {} ch)",
marker, d.name, d.sample_rate, d.channels
);
}
if !system.is_empty() {
eprintln!("\nSystem Audio:");
for d in &system {
eprintln!(" {} ({}Hz, {} ch)", d.name, d.sample_rate, d.channels);
}
} else {
eprintln!("\nSystem Audio:");
eprintln!(" (none detected)");
#[cfg(target_os = "macos")]
eprintln!(" Install a loopback driver: brew install blackhole-2ch");
#[cfg(target_os = "linux")]
eprintln!(
" On PipeWire, your speakers/headphones are the system-audio sources.\n \
On PulseAudio, look for source names ending in `.monitor`.\n \
If neither shows up here, check `wpctl status` or `pactl list sinks`."
);
eprintln!(" Or use the Minutes desktop app for native call capture (no driver needed).");
}
if !virtual_devs.is_empty() {
eprintln!("\nVirtual Devices:");
for d in &virtual_devs {
eprintln!(" {} ({}Hz, {} ch)", d.name, d.sample_rate, d.channels);
}
}
let json_devices: Vec<serde_json::Value> = devices
.iter()
.map(|d| {
serde_json::json!({
"name": d.name,
"category": format!("{:?}", d.category),
"sample_rate": d.sample_rate,
"channels": d.channels,
"is_default": d.is_default,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_devices)?);
Ok(())
}
fn cmd_setup(model: &str, list: bool, diarization: bool) -> Result<()> {
if list {
eprintln!("Available whisper models:");
eprintln!(" tiny 75 MB (fastest, lowest quality)");
eprintln!(" base 142 MB");
eprintln!(" small 466 MB (recommended default)");
eprintln!(" medium 1.5 GB");
eprintln!(" large-v3 3.1 GB (best quality, slower)");
eprintln!();
eprintln!("Speaker diarization:");
eprintln!(" --diarization 34 MB (pyannote-rs: segmentation + speaker embedding)");
eprintln!();
eprintln!("Parakeet models (alternative engine, --parakeet):");
eprintln!(" tdt-ctc-110m ~220 MB (English, fast)");
eprintln!(
" tdt-600m ~1.2 GB (multilingual v3, 25 EU languages, best quality, default)"
);
return Ok(());
}
if diarization {
return cmd_setup_diarization();
}
let valid_models = ["tiny", "base", "small", "medium", "large-v3"];
if !valid_models.contains(&model) {
anyhow::bail!(
"unknown model: {}. Available: {}",
model,
valid_models.join(", ")
);
}
let config = Config::default();
let model_dir = &config.transcription.model_path;
std::fs::create_dir_all(model_dir)?;
let dest = model_dir.join(format!("ggml-{}.bin", model));
let expected_min_bytes = minutes_core::transcribe::expected_whisper_model_size_bytes(model);
let mb = |bytes: u64| bytes as f64 / 1_048_576.0;
let keep_existing = if dest.exists() {
let actual = std::fs::metadata(&dest)?.len();
match expected_min_bytes {
Some(min_bytes) if actual < min_bytes => {
eprintln!(
"Model file at {} is {:.0} MB but the {} model should be at least {:.0} MB.",
dest.display(),
mb(actual),
model,
mb(min_bytes),
);
eprintln!(
"Looks truncated, probably an interrupted download. Removing and refetching."
);
std::fs::remove_file(&dest)?;
false
}
_ => {
eprintln!(
"Model already downloaded: {} ({:.0} MB)",
dest.display(),
mb(actual),
);
true
}
}
} else {
false
};
if !keep_existing {
let url = format!(
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{}.bin",
model
);
eprintln!("Downloading whisper model: {} ...", model);
download_file(&url, &dest)?;
if let Some(min_bytes) = expected_min_bytes {
let actual = std::fs::metadata(&dest)?.len();
if actual < min_bytes {
let _ = std::fs::remove_file(&dest);
anyhow::bail!(
"downloaded model is {:.0} MB but expected at least {:.0} MB for {}; the file was truncated and has been removed. Try running `minutes setup --model {}` again on a stable connection.",
mb(actual),
mb(min_bytes),
model,
model,
);
}
}
eprintln!("\nTo use this model, add to ~/.config/minutes/config.toml:");
eprintln!(" [transcription]");
eprintln!(" model = \"{}\"", model);
}
let vad_dest = model_dir.join("ggml-silero-v6.2.0.bin");
if !vad_dest.exists() {
let vad_url =
"https://huggingface.co/ggml-org/whisper-vad/resolve/main/ggml-silero-v6.2.0.bin";
eprintln!("Downloading Silero VAD model (~885 KB) ...");
if let Err(e) = download_file(vad_url, &vad_dest) {
eprintln!(
"Warning: VAD model download failed ({}). Transcription will still work \
but may produce loops on non-English audio.",
e
);
}
}
#[cfg(feature = "vad-ort")]
{
let onnx_dest = model_dir.join("silero-vad-v6.2.0.onnx");
if !onnx_dest.exists() {
let onnx_url = "https://github.com/snakers4/silero-vad/raw/v6.0/src/silero_vad/data/silero_vad.onnx";
eprintln!("Downloading Silero VAD ONNX from snakers4 v6.0 tag (~2.3 MB) ...");
if let Err(e) = download_file(onnx_url, &onnx_dest) {
eprintln!(
"Warning: Silero ONNX download failed ({}). The streaming VAD engine \
will not be available; recordings will continue using whisper-rs's \
bundled Silero.",
e
);
}
}
}
let devices = minutes_core::capture::list_input_devices();
if !devices.is_empty() {
eprintln!("\nAvailable audio input devices:");
for d in &devices {
eprintln!(" {}", d);
}
}
Ok(())
}
fn cmd_setup_demo() -> Result<()> {
let demo_dir = Config::minutes_dir().join("demo");
let install = demo_data::install_mcp_demo_fixtures(&demo_dir)?;
if install.updated_fixtures == 0 {
eprintln!(
"Demo corpus already ready at: {}",
install.demo_dir.display()
);
} else {
eprintln!(
"Demo corpus ready at: {} ({} fixture meetings)",
install.demo_dir.display(),
install.total_fixtures
);
}
eprintln!("Use it with MCP or any agent client by pointing MEETINGS_DIR at that folder:");
eprintln!();
eprintln!(" {{");
eprintln!(" \"mcpServers\": {{");
eprintln!(" \"minutes-demo\": {{");
eprintln!(" \"command\": \"npx\",");
eprintln!(" \"args\": [\"minutes-mcp\"],");
eprintln!(
" \"env\": {{ \"MEETINGS_DIR\": \"{}\" }}",
install.demo_dir.display()
);
eprintln!(" }}");
eprintln!(" }}");
eprintln!(" }}");
eprintln!();
eprintln!("Try asking your agent:");
eprintln!(" - List the meetings in this corpus.");
eprintln!(" - What did we decide about pricing? Which decision is current?");
eprintln!(" - What got killed in the last product prioritization meeting?");
eprintln!(" - What action items are still open, and who owns each?");
eprintln!(" - Summarize the Northwind customer thread.");
Ok(())
}
fn cmd_setup_diarization() -> Result<()> {
use minutes_core::diarize;
let config = Config::load();
let emb_info = diarize::embedding_model_for_config(&config);
let model_dir = &config.diarization.model_path;
std::fs::create_dir_all(model_dir)?;
eprintln!(
"Embedding model: {} ({})",
config.diarization.embedding_model, emb_info.filename
);
let models: [(&str, &str, &str); 2] = [
(
diarize::SEGMENTATION_MODEL,
diarize::SEGMENTATION_MODEL_URL,
"segmentation",
),
(emb_info.filename, emb_info.url, "speaker embedding"),
];
let mut all_exist = true;
for (filename, url, label) in &models {
let dest = model_dir.join(filename);
if dest.exists() {
let size = std::fs::metadata(&dest)?.len();
eprintln!(
"Already downloaded: {} ({:.1} MB)",
filename,
size as f64 / 1_048_576.0
);
} else {
all_exist = false;
eprintln!("Downloading {} model: {} ...", label, filename);
download_file(url, &dest)?;
}
}
if all_exist {
eprintln!("\nAll diarization models are installed.");
} else {
eprintln!("\nDiarization models installed.");
}
eprintln!("\nTo enable speaker diarization, add to ~/.config/minutes/config.toml:");
eprintln!(" [diarization]");
eprintln!(" engine = \"pyannote-rs\"");
eprintln!(" # embedding_model = \"cam++-lm\" # or \"cam++\" for the lighter original");
Ok(())
}
fn cmd_setup_parakeet(model: &str) -> Result<()> {
let valid_models = VALID_PARAKEET_MODELS;
if !valid_models.contains(&model) {
anyhow::bail!(
"unknown parakeet model: {}. Available: {}",
model,
valid_models.join(", ")
);
}
let config = Config::default();
let model_dir = parakeet::install_dir(&config, model);
std::fs::create_dir_all(&model_dir)?;
let dest_model = model_dir.join(parakeet::default_model_filename(model));
let dest_vocab_name = parakeet::default_tokenizer_filename(model);
let dest_vocab = model_dir.join(&dest_vocab_name);
let native_vad_dest = parakeet::installs_root(&config).join("silero_vad_v5.safetensors");
let hf_repo = match model {
"tdt-ctc-110m" => "nvidia/parakeet-tdt_ctc-110m",
"tdt-600m" => "nvidia/parakeet-tdt-0.6b-v3",
_ => unreachable!(),
};
let model_exists = dest_model.exists();
let vocab_exists = dest_vocab.exists();
if model_exists && vocab_exists {
let size = std::fs::metadata(&dest_model)?.len();
eprintln!(
"Model already set up: {} ({:.0} MB)",
dest_model.display(),
size as f64 / 1_048_576.0
);
eprintln!("Vocab file: {}", dest_vocab.display());
if let Ok(metadata_path) =
parakeet::write_install_metadata(&config, model, &dest_model, &dest_vocab)
{
eprintln!("Metadata file: {}", metadata_path.display());
}
} else {
eprintln!("Parakeet model setup: {}", model);
eprintln!();
eprintln!("Parakeet models require a one-time conversion from NVIDIA's .nemo format.");
eprintln!("Follow these steps:");
eprintln!();
eprintln!(" Install directory:");
eprintln!(" {}", model_dir.display());
eprintln!();
eprintln!(" Step 1: Clone parakeet.cpp");
eprintln!(" git clone https://github.com/Frikallo/parakeet.cpp");
eprintln!(" cd parakeet.cpp");
eprintln!();
eprintln!(" Step 2: Download the .nemo model from HuggingFace");
eprintln!(
" hf download {} --include '*.nemo' --local-dir .",
hf_repo
);
eprintln!();
eprintln!(" Step 3: Convert to safetensors");
let convert_model_arg = match model {
"tdt-ctc-110m" => "110m-tdt-ctc",
"tdt-600m" => "600m-tdt",
_ => unreachable!(),
};
eprintln!(
" python scripts/convert_nemo.py *.nemo -o {} --model {}",
dest_model.display(),
convert_model_arg
);
eprintln!();
eprintln!(" Step 4: Extract the SentencePiece tokenizer vocab");
eprintln!(" tar xf *.nemo --wildcards --no-anchored '*tokenizer.vocab'");
eprintln!(" cp *_tokenizer.vocab {}", dest_vocab.display());
eprintln!();
eprintln!(" Step 5: Build and install the parakeet binary");
eprintln!(" mkdir build && cd build && cmake .. && make -j");
eprintln!(" cp parakeet /usr/local/bin/");
if model_exists {
eprintln!();
eprintln!(
"Note: model file already present at {}",
dest_model.display()
);
}
if vocab_exists {
eprintln!(
"Note: vocab file already present at {}",
dest_vocab.display()
);
}
}
if !native_vad_dest.exists() {
if let Some(parent) = native_vad_dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&native_vad_dest, PARAKEET_NATIVE_VAD_WEIGHTS)?;
let size = std::fs::metadata(&native_vad_dest)?.len();
eprintln!(
"Installed native Parakeet VAD weights: {} ({:.1} MB)",
native_vad_dest.display(),
size as f64 / 1_048_576.0
);
} else {
let size = std::fs::metadata(&native_vad_dest)?.len();
eprintln!(
"Native Parakeet VAD weights already installed: {} ({:.1} MB)",
native_vad_dest.display(),
size as f64 / 1_048_576.0
);
}
eprintln!();
match minutes_core::parakeet::resolve_parakeet_binary(
"parakeet",
minutes_core::parakeet::ResolveParakeetBinaryMode::WarnAndFallback,
) {
Ok(path) => {
eprintln!("Resolved parakeet binary: {}", path.display());
}
Err(_) => {
eprintln!("Warning: no working `parakeet` binary was found.");
eprintln!("See: https://github.com/Frikallo/parakeet.cpp");
}
}
eprintln!();
eprintln!("To use parakeet, add to ~/.config/minutes/config.toml:");
eprintln!(" [transcription]");
eprintln!(" engine = \"parakeet\"");
eprintln!(" parakeet_model = \"{}\"", model);
eprintln!(" parakeet_vocab = \"{}\"", dest_vocab_name);
eprintln!(
" parakeet_binary = \"<absolute path to parakeet, e.g. /Users/you/.local/bin/parakeet>\""
);
if !cfg!(feature = "parakeet") {
eprintln!();
eprintln!("WARNING: this minutes binary was compiled WITHOUT the parakeet feature.");
eprintln!("The model and helper binary above are installed, but the runtime will fall");
eprintln!("back to whisper regardless of the config keys you just set. To actually use");
eprintln!("parakeet, rebuild the CLI with the feature enabled, e.g.:");
eprintln!();
eprintln!(" cargo install --path crates/cli --features parakeet --root ~/.cargo --force");
eprintln!();
eprintln!("The downloadable DMG and tagged CLI release binaries do include parakeet.");
eprintln!("The Homebrew Formula CLI (`brew install silverstein/tap/minutes`) and bare");
eprintln!("`cargo install minutes-cli` runs are the install paths that omit it.");
}
Ok(())
}
fn download_file(url: &str, dest: &std::path::Path) -> Result<()> {
eprintln!(" From: {}", url);
eprintln!(" To: {}", dest.display());
let response = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("download failed: {}. Check your internet connection.", e))?;
let content_length = response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
let mut reader = response.into_body().into_reader();
let tmp_dest = dest.with_extension("partial");
let mut file = std::fs::File::create(&tmp_dest)?;
let mut downloaded: u64 = 0;
let mut buf = vec![0u8; 64 * 1024];
let mut last_report = std::time::Instant::now();
loop {
let n = std::io::Read::read(&mut reader, &mut buf)?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n])?;
downloaded += n as u64;
if last_report.elapsed().as_millis() > 500 {
if let Some(total) = content_length {
eprint!(
"\r {:.0} / {:.0} MB ({:.0}%)",
downloaded as f64 / 1_048_576.0,
total as f64 / 1_048_576.0,
downloaded as f64 / total as f64 * 100.0
);
} else {
eprint!("\r {:.0} MB downloaded", downloaded as f64 / 1_048_576.0);
}
last_report = std::time::Instant::now();
}
}
eprintln!();
drop(file);
std::fs::rename(&tmp_dest, dest).map_err(|e| {
std::fs::remove_file(&tmp_dest).ok();
anyhow::anyhow!("failed to save model: {}", e)
})?;
let size = std::fs::metadata(dest)?.len();
eprintln!(" Done! Saved ({:.1} MB)", size as f64 / 1_048_576.0);
Ok(())
}
#[derive(Debug, Clone, Serialize)]
struct QmdCollectionInfo {
name: String,
path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
struct QmdStatusReport {
qmd_available: bool,
output_dir: PathBuf,
target_collection: String,
registered: bool,
matching_collections: Vec<QmdCollectionInfo>,
config_engine: String,
config_collection: Option<String>,
}
fn parse_qmd_collection_names(stdout: &str) -> Vec<String> {
let mut collections = Vec::new();
for line in stdout.lines() {
if let Some((name, _)) = line.split_once(" (qmd://") {
collections.push(name.trim().to_string());
}
}
collections
}
fn parse_qmd_collection_path(stdout: &str) -> Option<PathBuf> {
stdout
.lines()
.find_map(|line| line.trim_start().strip_prefix("Path:"))
.map(|path| PathBuf::from(path.trim()))
}
fn normalize_path_for_compare(path: &Path) -> PathBuf {
if path.exists() {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
}
}
fn content_type_path_matches(output_dir: &Path, candidate: &Path) -> bool {
normalize_path_for_compare(output_dir) == normalize_path_for_compare(candidate)
}
fn qmd_status_report(collection: &str, config: &Config) -> Result<QmdStatusReport> {
let output_dir = normalize_path_for_compare(&config.output_dir);
let output = match std::process::Command::new("qmd")
.args(["collection", "list"])
.output()
{
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(QmdStatusReport {
qmd_available: false,
output_dir,
target_collection: collection.to_string(),
registered: false,
matching_collections: Vec::new(),
config_engine: config.search.engine.clone(),
config_collection: config.search.qmd_collection.clone(),
});
}
Err(error) => return Err(error.into()),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
anyhow::bail!("{}", if !stderr.is_empty() { stderr } else { stdout });
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut matching_collections = Vec::new();
for candidate_name in parse_qmd_collection_names(&stdout) {
let show_output = std::process::Command::new("qmd")
.args(["collection", "show", &candidate_name])
.output()?;
if !show_output.status.success() {
continue;
}
let show_stdout = String::from_utf8_lossy(&show_output.stdout);
if let Some(path) = parse_qmd_collection_path(&show_stdout) {
let candidate = QmdCollectionInfo {
name: candidate_name,
path,
};
if content_type_path_matches(&output_dir, &candidate.path) {
matching_collections.push(candidate);
}
}
}
let registered = matching_collections
.iter()
.any(|candidate| candidate.name == collection);
Ok(QmdStatusReport {
qmd_available: true,
output_dir,
target_collection: collection.to_string(),
registered,
matching_collections,
config_engine: config.search.engine.clone(),
config_collection: config.search.qmd_collection.clone(),
})
}
fn cmd_qmd(action: &str, collection: &str, config: &Config) -> Result<()> {
match action {
"status" => {
let report = qmd_status_report(collection, config)?;
if !report.qmd_available {
eprintln!("QMD is not installed or not on PATH.");
eprintln!(
"Install qmd, then run: minutes qmd register --collection {}",
collection
);
} else if report.registered {
eprintln!(
"QMD collection '{}' already indexes {}",
collection,
report.output_dir.display()
);
} else if report.matching_collections.is_empty() {
eprintln!("{} is not indexed in QMD yet.", report.output_dir.display());
eprintln!("Run: minutes qmd register --collection {}", collection);
} else {
eprintln!(
"{} is already indexed in QMD under: {}",
report.output_dir.display(),
report
.matching_collections
.iter()
.map(|candidate| candidate.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
eprintln!("Run: minutes qmd register --collection {}", collection);
}
if report.config_engine != "qmd"
|| report.config_collection.as_deref() != Some(collection)
{
eprintln!("\nTo opt into QMD search, add to ~/.config/minutes/config.toml:");
eprintln!(" [search]");
eprintln!(" engine = \"qmd\"");
eprintln!(" qmd_collection = \"{}\"", collection);
}
println!("{}", serde_json::to_string_pretty(&report)?);
}
"register" => {
config.ensure_dirs()?;
let initial = qmd_status_report(collection, config)?;
if !initial.qmd_available {
anyhow::bail!(
"qmd is not installed or not on PATH. Install qmd, then rerun this command."
);
}
if initial.registered {
eprintln!(
"QMD collection '{}' already indexes {}",
collection,
initial.output_dir.display()
);
println!("{}", serde_json::to_string_pretty(&initial)?);
return Ok(());
}
let output = std::process::Command::new("qmd")
.arg("collection")
.arg("add")
.arg(&config.output_dir)
.arg("--name")
.arg(collection)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
anyhow::bail!("{}", if !stderr.is_empty() { stderr } else { stdout });
}
let report = qmd_status_report(collection, config)?;
eprintln!(
"Registered {} as QMD collection '{}'.",
report.output_dir.display(),
collection
);
eprintln!(
"Run `qmd update -c {}` or `qmd embed` as needed to refresh the collection.",
collection
);
if report.config_engine != "qmd"
|| report.config_collection.as_deref() != Some(collection)
{
eprintln!("\nTo opt into QMD search, add to ~/.config/minutes/config.toml:");
eprintln!(" [search]");
eprintln!(" engine = \"qmd\"");
eprintln!(" qmd_collection = \"{}\"", collection);
}
println!("{}", serde_json::to_string_pretty(&report)?);
}
_ => anyhow::bail!("Unknown qmd action: {}. Use status or register.", action),
}
Ok(())
}
#[cfg(target_os = "macos")]
fn cmd_service(action: &str) -> Result<()> {
let minutes_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("minutes"));
let home = dirs::home_dir().unwrap_or_default();
let log_dir = Config::minutes_dir().join("logs");
let agents_dir = home.join("Library/LaunchAgents");
let bin_str = minutes_bin.display().to_string();
let home_str = home.display().to_string();
let log_dir_str = log_dir.display().to_string();
let path_env = format!(
"{h}/.local/bin:{h}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
h = home_str
);
let agents: Vec<(&str, String)> = vec![
(
"dev.getminutes.watcher",
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.getminutes.watcher</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
<string>watch</string>
</array>
<key>WorkingDirectory</key>
<string>{home}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
<key>PATH</key>
<string>{path}</string>
</dict>
<key>StandardOutPath</key>
<string>{logs}/watcher.log</string>
<key>StandardErrorPath</key>
<string>{logs}/watcher.log</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>Nice</key>
<integer>5</integer>
<key>ThrottleInterval</key>
<integer>10</integer>
</dict>
</plist>"#,
bin = bin_str,
home = home_str,
path = path_env,
logs = log_dir_str,
),
),
(
"dev.getminutes.weekly-summary",
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.getminutes.weekly-summary</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
<string>automate</string>
<string>weekly-summary</string>
<string>--json</string>
</array>
<key>WorkingDirectory</key>
<string>{home}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
<key>PATH</key>
<string>{path}</string>
</dict>
<key>StandardOutPath</key>
<string>{logs}/weekly-summary.log</string>
<key>StandardErrorPath</key>
<string>{logs}/weekly-summary.log</string>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>0</integer>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>Nice</key>
<integer>10</integer>
</dict>
</plist>"#,
bin = bin_str,
home = home_str,
path = path_env,
logs = log_dir_str,
),
),
(
"dev.getminutes.proactive-context",
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.getminutes.proactive-context</string>
<key>ProgramArguments</key>
<array>
<string>{bin}</string>
<string>automate</string>
<string>proactive-context</string>
<string>--json</string>
</array>
<key>WorkingDirectory</key>
<string>{home}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{home}</string>
<key>PATH</key>
<string>{path}</string>
</dict>
<key>StandardOutPath</key>
<string>{logs}/proactive-context.log</string>
<key>StandardErrorPath</key>
<string>{logs}/proactive-context.log</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>8</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>Nice</key>
<integer>10</integer>
</dict>
</plist>"#,
bin = bin_str,
home = home_str,
path = path_env,
logs = log_dir_str,
),
),
];
match action {
"install" => {
std::fs::create_dir_all(&log_dir)?;
std::fs::create_dir_all(&agents_dir)?;
let legacy = agents_dir.join("dev.getminutes.weekly-lint.plist");
if legacy.exists() {
let _ = std::process::Command::new("launchctl")
.args(["unload", &legacy.to_string_lossy()])
.status();
let _ = std::fs::remove_file(&legacy);
eprintln!(
"Removed legacy dev.getminutes.weekly-lint (replaced by weekly-summary)."
);
}
for (label, plist) in &agents {
let dest = agents_dir.join(format!("{}.plist", label));
let was_loaded = dest.exists()
&& std::process::Command::new("launchctl")
.args(["list", label])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if was_loaded {
let _ = std::process::Command::new("launchctl")
.args(["unload", &dest.to_string_lossy()])
.status();
}
std::fs::write(&dest, plist)?;
let status = std::process::Command::new("launchctl")
.args(["load", "-w", &dest.to_string_lossy()])
.status()?;
let verb = if was_loaded { "reloaded" } else { "installed" };
if status.success() {
eprintln!(" {} {}", verb, label);
} else {
eprintln!(" FAILED {}", label);
}
}
eprintln!();
eprintln!("All services installed.");
eprintln!(" Binary: {}", minutes_bin.display());
eprintln!(" Logs: {}", log_dir.display());
eprintln!(" Watcher auto-starts on login; weekly-summary runs Sundays 7pm;");
eprintln!(" proactive-context runs daily at 8am.");
}
"uninstall" => {
let mut removed = 0;
for (label, _) in &agents {
let dest = agents_dir.join(format!("{}.plist", label));
if dest.exists() {
let _ = std::process::Command::new("launchctl")
.args(["unload", &dest.to_string_lossy()])
.status();
std::fs::remove_file(&dest)?;
eprintln!(" removed {}", label);
removed += 1;
}
}
if removed == 0 {
eprintln!("No services installed.");
} else {
eprintln!("Uninstalled {} service(s).", removed);
}
}
"restart" => {
let uid = unsafe { libc::getuid() };
for (label, _) in &agents {
let dest = agents_dir.join(format!("{}.plist", label));
if !dest.exists() {
continue;
}
let target = format!("gui/{}/{}", uid, label);
let kicked = std::process::Command::new("launchctl")
.args(["kickstart", "-k", &target])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !kicked {
let _ = std::process::Command::new("launchctl")
.args(["unload", &dest.to_string_lossy()])
.status();
let _ = std::process::Command::new("launchctl")
.args(["load", "-w", &dest.to_string_lossy()])
.status();
}
eprintln!(" restarted {}", label);
}
}
"status" => {
for (label, _) in &agents {
let output = std::process::Command::new("launchctl")
.args(["list", label])
.output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let pid = stdout
.lines()
.find(|l| l.contains("PID"))
.map(|l| l.trim().to_string())
.unwrap_or_default();
eprintln!(" running {} {}", label, pid);
}
_ => {
let dest = agents_dir.join(format!("{}.plist", label));
if dest.exists() {
eprintln!(
" stopped {} (plist exists, try: minutes service install)",
label
);
} else {
eprintln!(" missing {}", label);
}
}
}
}
}
_ => anyhow::bail!(
"Unknown action: {}. Use install, uninstall, restart, or status.",
action
),
}
Ok(())
}
#[cfg(target_os = "linux")]
fn cmd_service_linux(action: &str) -> Result<()> {
let minutes_bin = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("minutes"));
let home = dirs::home_dir().unwrap_or_default();
let bin_str = minutes_bin.display().to_string();
let home_str = home.display().to_string();
let systemd_dir = home.join(".config/systemd/user");
let path_env = format!(
"{h}/.local/bin:{h}/.cargo/bin:/usr/local/bin:/usr/bin:/bin",
h = home_str
);
let units: Vec<(&str, String, Option<String>)> = vec![
(
"minutes-watcher",
format!(
"[Unit]\nDescription=Minutes voice memo watcher\n\n[Service]\nType=simple\nExecStart={bin} watch\nRestart=on-failure\nRestartSec=10\nNice=5\nEnvironment=PATH={path}\n\n[Install]\nWantedBy=default.target\n",
bin = bin_str, path = path_env
),
None,
),
(
"minutes-weekly-summary",
format!(
"[Unit]\nDescription=Minutes weekly summary\n\n[Service]\nType=oneshot\nExecStart={bin} automate weekly-summary --json\nEnvironment=PATH={path}\n",
bin = bin_str, path = path_env
),
Some("[Unit]\nDescription=Minutes weekly summary timer\n\n[Timer]\nOnCalendar=Sun 19:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n".to_string()),
),
(
"minutes-proactive-context",
format!(
"[Unit]\nDescription=Minutes proactive context\n\n[Service]\nType=oneshot\nExecStart={bin} automate proactive-context --json\nEnvironment=PATH={path}\n",
bin = bin_str, path = path_env
),
Some("[Unit]\nDescription=Minutes proactive context timer\n\n[Timer]\nOnCalendar=*-*-* 08:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n".to_string()),
),
];
match action {
"install" => {
std::fs::create_dir_all(&systemd_dir)?;
for (name, service, timer) in &units {
let svc_path = systemd_dir.join(format!("{}.service", name));
std::fs::write(&svc_path, service)?;
eprintln!(" wrote {}.service", name);
if let Some(timer_content) = timer {
let timer_path = systemd_dir.join(format!("{}.timer", name));
std::fs::write(&timer_path, timer_content)?;
eprintln!(" wrote {}.timer", name);
}
}
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
for (name, _, timer) in &units {
let target = if timer.is_some() {
format!("{}.timer", name)
} else {
format!("{}.service", name)
};
let _ = std::process::Command::new("systemctl")
.args(["--user", "enable", "--now", &target])
.status();
eprintln!(" enabled {}", target);
}
eprintln!();
eprintln!("All services installed.");
eprintln!(" Binary: {}", minutes_bin.display());
eprintln!(" Units: {}", systemd_dir.display());
}
"uninstall" => {
for (name, _, timer) in &units {
let targets: Vec<String> = if timer.is_some() {
vec![format!("{}.timer", name), format!("{}.service", name)]
} else {
vec![format!("{}.service", name)]
};
for t in &targets {
let path = systemd_dir.join(t);
if path.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", t])
.status();
std::fs::remove_file(&path)?;
eprintln!(" removed {}", t);
}
}
}
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
}
"restart" => {
for (name, _, timer) in &units {
let target = if timer.is_some() {
format!("{}.timer", name)
} else {
format!("{}.service", name)
};
let _ = std::process::Command::new("systemctl")
.args(["--user", "restart", &target])
.status();
eprintln!(" restarted {}", target);
}
}
"status" => {
for (name, _, timer) in &units {
let target = if timer.is_some() {
format!("{}.timer", name)
} else {
format!("{}.service", name)
};
let output = std::process::Command::new("systemctl")
.args(["--user", "is-active", &target])
.output();
let state = match output {
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(_) => "unknown".to_string(),
};
eprintln!(" {} {}", state, target);
}
}
_ => anyhow::bail!(
"Unknown action: {}. Use install, uninstall, restart, or status.",
action
),
}
Ok(())
}
fn cmd_logs(errors: bool, lines: usize) -> Result<()> {
let log_path = Config::minutes_dir().join("logs").join("minutes.log");
if !log_path.exists() {
eprintln!("No log file found at {}", log_path.display());
return Ok(());
}
let content = std::fs::read_to_string(&log_path)?;
let all_lines: Vec<&str> = content.lines().collect();
let filtered: Vec<&&str> = if errors {
all_lines
.iter()
.filter(|line| line.contains("\"level\":\"error\"") || line.contains("ERROR"))
.collect()
} else {
all_lines.iter().collect()
};
let start = if filtered.len() > lines {
filtered.len() - lines
} else {
0
};
for line in &filtered[start..] {
println!("{}", line);
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use minutes_core::autoresearch::{
DecodeHintEvalCaseResult, DecodeHintEvalHintDebug, DecodeHintEvalOptions,
DecodeHintEvalReport, DecodeHintEvalTotals, DecodeHintEvalTranscriptMetrics,
};
use serde_json::json;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
#[cfg(feature = "parakeet")]
#[derive(Serialize)]
struct DummyTranscript {
transcript: String,
segments: Vec<String>,
}
fn test_guard() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn with_temp_home<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = test_guard();
let dir = std::env::temp_dir().join(format!(
"minutes-cli-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let original_home = std::env::var_os("HOME");
let original_userprofile = std::env::var_os("USERPROFILE");
std::env::set_var("HOME", &dir);
std::env::set_var("USERPROFILE", &dir);
let result = f(&dir);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
if let Some(userprofile) = original_userprofile {
std::env::set_var("USERPROFILE", userprofile);
} else {
std::env::remove_var("USERPROFILE");
}
std::fs::remove_dir_all(&dir).ok();
result
}
fn sample_decode_hint_eval_report_with_allowed_failures() -> DecodeHintEvalReport {
DecodeHintEvalReport {
generated_at: "2026-04-23T12:00:00Z".into(),
corpus_path: PathBuf::from("/tmp/corpus.json"),
options: DecodeHintEvalOptions::default(),
totals: DecodeHintEvalTotals {
cases_total: 2,
cases_passed: 2,
cases_failed: 0,
cases_allowed_failures: 1,
improved_cases: 1,
regressed_cases: 0,
average_delta_wer: -0.01,
},
cases: vec![
DecodeHintEvalCaseResult {
id: "self-intro-whisper".into(),
engine: "whisper".into(),
hint_debug: DecodeHintEvalHintDebug::default(),
baseline: DecodeHintEvalTranscriptMetrics {
wer: 0.12,
focus_hits: vec!["mat".into()],
forbidden_hits: vec![],
text: String::new(),
},
candidate: DecodeHintEvalTranscriptMetrics {
wer: 0.09,
focus_hits: vec!["mat".into(), "leadernet".into()],
forbidden_hits: vec![],
text: String::new(),
},
delta_wer: -0.03,
max_wer_regression: Some(0.03),
required_terms: vec!["mat".into(), "leadernet".into()],
forbidden_terms: vec![],
passed: true,
status: "pass".into(),
failure_reasons: vec![],
allowed_failure_reasons: vec![],
},
DecodeHintEvalCaseResult {
id: "external-proper-noun-research".into(),
engine: "parakeet".into(),
hint_debug: DecodeHintEvalHintDebug::default(),
baseline: DecodeHintEvalTranscriptMetrics {
wer: 0.10,
focus_hits: vec!["pdf toolkit".into()],
forbidden_hits: vec![],
text: String::new(),
},
candidate: DecodeHintEvalTranscriptMetrics {
wer: 0.10,
focus_hits: vec!["pdf toolkit".into()],
forbidden_hits: vec![],
text: String::new(),
},
delta_wer: 0.0,
max_wer_regression: Some(0.02),
required_terms: vec!["casey rowan".into()],
forbidden_terms: vec![],
passed: true,
status: "allowed-failure".into(),
failure_reasons: vec![],
allowed_failure_reasons: vec![
"missing required hinted term 'casey rowan'".into()
],
},
],
failure_messages: vec![],
}
}
fn sample_decode_hint_eval_report_with_failures() -> DecodeHintEvalReport {
DecodeHintEvalReport {
generated_at: "2026-04-23T12:00:00Z".into(),
corpus_path: PathBuf::from("/tmp/corpus.json"),
options: DecodeHintEvalOptions::default(),
totals: DecodeHintEvalTotals {
cases_total: 1,
cases_passed: 0,
cases_failed: 1,
cases_allowed_failures: 0,
improved_cases: 0,
regressed_cases: 1,
average_delta_wer: 0.03,
},
cases: vec![DecodeHintEvalCaseResult {
id: "case-1".into(),
engine: "parakeet".into(),
hint_debug: DecodeHintEvalHintDebug::default(),
baseline: DecodeHintEvalTranscriptMetrics {
wer: 0.12,
focus_hits: vec![],
forbidden_hits: vec![],
text: String::new(),
},
candidate: DecodeHintEvalTranscriptMetrics {
wer: 0.15,
focus_hits: vec![],
forbidden_hits: vec!["matt mullenweg".into()],
text: String::new(),
},
delta_wer: 0.03,
max_wer_regression: Some(0.02),
required_terms: vec!["alex chen".into()],
forbidden_terms: vec!["matt mullenweg".into()],
passed: false,
status: "fail".into(),
failure_reasons: vec!["contains forbidden hinted term 'matt mullenweg'".into()],
allowed_failure_reasons: vec![],
}],
failure_messages: vec!["case-1 contains forbidden hinted term 'matt mullenweg'".into()],
}
}
#[test]
fn parse_qmd_collection_names_extracts_collection_headers() {
let output = r#"Collections (2):
minutes (qmd://minutes/)
Pattern: **/*.md
Files: 12
Updated: 1h ago
life (qmd://life/)
Pattern: **/*.md
Files: 100
Updated: 2d ago
"#;
let collections = parse_qmd_collection_names(output);
assert_eq!(collections, vec!["minutes".to_string(), "life".to_string()]);
}
#[test]
fn json_envelope_includes_schema_metadata() {
let envelope = json_envelope("minutes health", json!({ "engine": "parakeet" }));
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["command"], "minutes health");
assert_eq!(value["meta"]["schemaVersion"], 1);
assert_eq!(value["data"]["engine"], "parakeet");
assert!(value["meta"]["generatedAt"].is_string());
}
#[test]
#[cfg(feature = "parakeet")]
fn parakeet_helper_envelope_flattens_transcript_fields() {
let envelope = parakeet_helper_envelope(
"minutes parakeet-helper",
DummyTranscript {
transcript: "[0:00] hello".into(),
segments: vec!["hello".into()],
},
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["command"], "minutes parakeet-helper");
assert_eq!(value["transcript"], "[0:00] hello");
assert_eq!(value["segments"][0], "hello");
assert_eq!(value["meta"]["schemaVersion"], 1);
}
#[test]
fn parakeet_helper_clap_accepts_fp16_flag_present_or_absent() {
let common = [
"minutes",
"parakeet-helper",
"--binary",
"/usr/local/bin/parakeet",
"--model-path",
"/tmp/model.bin",
"--audio-path",
"/tmp/audio.wav",
"--vocab-path",
"/tmp/vocab.txt",
"--model-id",
"tdt-600m",
];
let parsed_without =
Cli::try_parse_from(common).expect("parakeet-helper without --fp16 must parse");
match parsed_without.command {
Commands::ParakeetHelper { fp16, .. } => assert!(!fp16),
_ => panic!("expected ParakeetHelper variant"),
}
let mut with_fp16: Vec<&str> = common.to_vec();
with_fp16.push("--fp16");
let parsed_with =
Cli::try_parse_from(with_fp16).expect("parakeet-helper --fp16 must parse");
match parsed_with.command {
Commands::ParakeetHelper { fp16, .. } => assert!(fp16),
_ => panic!("expected ParakeetHelper variant"),
}
}
#[test]
fn import_accepts_audio_path_for_recovery_alias() {
let parsed = Cli::try_parse_from([
"minutes",
"import",
"/Users/test/.minutes/native-captures/2026-05-19-120148-call.voice.wav",
])
.expect("import must accept audio paths so it can route to process");
match parsed.command {
Commands::Import { from, dir, dry_run } => {
assert_eq!(
from,
"/Users/test/.minutes/native-captures/2026-05-19-120148-call.voice.wav"
);
assert!(dir.is_none());
assert!(!dry_run);
}
_ => panic!("expected Import variant"),
}
}
#[test]
fn looks_like_audio_path_matches_supported_process_formats() {
assert!(looks_like_audio_path("call.voice.wav"));
assert!(looks_like_audio_path("meeting.MOV"));
assert!(looks_like_audio_path("/tmp/memo.m4a"));
assert!(!looks_like_audio_path("granola"));
assert!(!looks_like_audio_path("notes.md"));
}
#[test]
fn render_decode_hints_plaintext_summary_surfaces_allowed_failures() {
let output = render_decode_hints_plaintext_summary(
&sample_decode_hint_eval_report_with_allowed_failures(),
Path::new("/tmp/decode-hints/2026-04-23T12-00-00Z"),
false,
);
assert!(output.contains("Decode hint eval: PASS WITH ALLOWED FAILURES"));
assert!(output.contains("Allowed failures: 1"));
assert!(output.contains("Allowed-failure cases:"));
assert!(output.contains("external-proper-noun-research"));
assert!(output.contains("missing required hinted term 'casey rowan'"));
}
#[test]
fn render_decode_hints_plaintext_summary_surfaces_blocking_failures() {
let output = render_decode_hints_plaintext_summary(
&sample_decode_hint_eval_report_with_failures(),
Path::new("/tmp/decode-hints/2026-04-23T12-00-00Z"),
true,
);
assert!(output.contains("Decode hint eval: FAIL"));
assert!(output.contains("Allowed failures: 0"));
assert!(output.contains("Failure messages:"));
assert!(output.contains("case-1 contains forbidden hinted term 'matt mullenweg'"));
assert!(!output.contains("Allowed-failure cases:"));
}
#[test]
fn parse_qmd_collection_path_reads_show_output() {
let output = r#"Collection: minutes
Path: /Users/you/meetings
Pattern: **/*.md
Include: yes (default)
"#;
assert_eq!(
parse_qmd_collection_path(output),
Some(PathBuf::from("/Users/you/meetings"))
);
}
#[test]
fn cleanup_live_capture_state_clears_pid_metadata_and_notes() {
with_temp_home(|_| {
minutes_core::pid::create().unwrap();
minutes_core::pid::write_recording_metadata(CaptureMode::Meeting).unwrap();
minutes_core::notes::save_context("pricing review").unwrap();
minutes_core::notes::add_note("remember to ask about budget").unwrap();
cleanup_live_capture_state();
assert!(!minutes_core::pid::pid_path().exists());
assert!(!minutes_core::pid::recording_meta_path().exists());
assert!(!minutes_core::notes::recording_start_path().exists());
assert!(minutes_core::notes::read_context().is_none());
assert!(minutes_core::notes::read_notes().is_none());
});
}
#[test]
fn resolve_recording_device_overrides_uses_single_cli_source() {
let mut config = Config::default();
resolve_recording_device_overrides(&mut config, &[String::from("Yeti Nano")], None, false)
.expect("single source should map to recording.device");
assert_eq!(config.recording.device.as_deref(), Some("Yeti Nano"));
assert!(config.recording.sources.is_none());
}
#[test]
fn resolve_recording_device_overrides_maps_dual_cli_sources() {
let mut config = Config::default();
resolve_recording_device_overrides(
&mut config,
&[String::from("Mic"), String::from("BlackHole 2ch")],
None,
false,
)
.expect("dual CLI sources should map to recording.sources");
let sources = config
.recording
.sources
.expect("dual sources should remain configured");
assert_eq!(sources.voice.as_deref(), Some("Mic"));
assert_eq!(sources.call.as_deref(), Some("BlackHole 2ch"));
assert!(config.recording.device.is_none());
}
#[test]
fn resolve_recording_device_overrides_preserves_dual_config_sources() {
let mut config = Config::default();
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice: Some("Mic".into()),
call: Some("BlackHole 2ch".into()),
});
resolve_recording_device_overrides(&mut config, &[], None, false)
.expect("dual config sources should remain intact");
let sources = config
.recording
.sources
.expect("dual config should remain configured");
assert_eq!(sources.voice.as_deref(), Some("Mic"));
assert_eq!(sources.call.as_deref(), Some("BlackHole 2ch"));
}
#[test]
fn resolve_recording_device_overrides_allows_explicit_device_to_win() {
let mut config = Config::default();
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice: Some("Mic".into()),
call: Some("BlackHole 2ch".into()),
});
resolve_recording_device_overrides(&mut config, &[], Some("USB Mic".into()), false)
.expect("explicit --device should override config sources");
assert_eq!(config.recording.device.as_deref(), Some("USB Mic"));
assert!(config.recording.sources.is_none());
}
#[test]
fn resolve_recording_device_overrides_normalizes_decorated_explicit_device() {
let mut config = Config::default();
resolve_recording_device_overrides(
&mut config,
&[],
Some("Ground Control (16000Hz, 1 ch)".into()),
false,
)
.expect("decorated --device value should normalize");
assert_eq!(config.recording.device.as_deref(), Some("Ground Control"));
}
#[test]
fn resolve_recording_device_overrides_uses_single_voice_config_source() {
let mut config = Config::default();
config.recording.sources = Some(minutes_core::config::SourcesConfig {
voice: Some("Built-in Microphone".into()),
call: None,
});
resolve_recording_device_overrides(&mut config, &[], None, false)
.expect("single voice source should map to recording.device");
assert_eq!(
config.recording.device.as_deref(),
Some("Built-in Microphone")
);
assert!(config.recording.sources.is_none());
}
#[test]
fn cmd_delete_archives_meeting_to_archive_dir() {
with_temp_home(|dir| {
let meetings = dir.join("meetings");
std::fs::create_dir_all(&meetings).unwrap();
let md = meetings.join("2026-04-01-test.md");
std::fs::write(&md, "---\ntitle: Test\n---\nContent").unwrap();
let wav = meetings.join("2026-04-01-test.wav");
std::fs::write(&wav, b"fake audio").unwrap();
let config = Config {
output_dir: meetings.clone(),
..Config::default()
};
cmd_delete("2026-04-01-test", false, false, &config).unwrap();
assert!(!md.exists(), "md should be moved");
assert!(
meetings.join("archive/2026-04-01-test.md").exists(),
"md should be in archive"
);
assert!(wav.exists(), "wav should remain without --with-audio");
});
}
#[test]
fn cmd_delete_archives_all_audio_artifacts_with_with_audio() {
with_temp_home(|dir| {
let meetings = dir.join("meetings");
std::fs::create_dir_all(&meetings).unwrap();
let md = meetings.join("2026-04-01-artifacts.md");
std::fs::write(&md, "---\ntitle: Artifacts\n---\nContent").unwrap();
let wav = meetings.join("2026-04-01-artifacts.wav");
std::fs::write(&wav, b"fake audio").unwrap();
let voice = meetings.join("2026-04-01-artifacts.voice.wav");
std::fs::write(&voice, b"fake voice stem").unwrap();
let system = meetings.join("2026-04-01-artifacts.system.wav");
std::fs::write(&system, b"fake system stem").unwrap();
let embeddings = meetings.join(".2026-04-01-artifacts.embeddings");
std::fs::write(&embeddings, b"{\"Speaker 1\":[0.1,0.2]}").unwrap();
let config = Config {
output_dir: meetings.clone(),
..Config::default()
};
cmd_delete("2026-04-01-artifacts", true, false, &config).unwrap();
assert!(!md.exists(), "md should be moved");
assert!(
meetings.join("archive/2026-04-01-artifacts.md").exists(),
"md should be in archive"
);
assert!(!wav.exists(), "merged wav should be moved");
assert!(!voice.exists(), "voice stem should be moved");
assert!(!system.exists(), "system stem should be moved");
assert!(!embeddings.exists(), "embeddings sidecar should be moved");
assert!(
meetings.join("archive/2026-04-01-artifacts.wav").exists(),
"merged wav should be archived"
);
assert!(
meetings
.join("archive/2026-04-01-artifacts.voice.wav")
.exists(),
"voice stem should be archived"
);
assert!(
meetings
.join("archive/2026-04-01-artifacts.system.wav")
.exists(),
"system stem should be archived"
);
assert!(
meetings
.join("archive/.2026-04-01-artifacts.embeddings")
.exists(),
"embeddings sidecar should be archived"
);
});
}
#[test]
fn cmd_delete_force_permanently_removes() {
with_temp_home(|dir| {
let meetings = dir.join("meetings");
std::fs::create_dir_all(&meetings).unwrap();
let md = meetings.join("2026-04-01-force.md");
std::fs::write(&md, "---\ntitle: Force\n---\nContent").unwrap();
let wav = meetings.join("2026-04-01-force.wav");
std::fs::write(&wav, b"fake audio").unwrap();
let voice = meetings.join("2026-04-01-force.voice.wav");
std::fs::write(&voice, b"fake voice stem").unwrap();
let system = meetings.join("2026-04-01-force.system.wav");
std::fs::write(&system, b"fake system stem").unwrap();
let embeddings = meetings.join(".2026-04-01-force.embeddings");
std::fs::write(&embeddings, b"{\"Speaker 1\":[0.1,0.2]}").unwrap();
let config = Config {
output_dir: meetings.clone(),
..Config::default()
};
cmd_delete("2026-04-01-force", true, true, &config).unwrap();
assert!(!md.exists(), "md should be gone");
assert!(
!wav.exists(),
"wav should be gone with --with-audio --force"
);
assert!(!voice.exists(), "voice stem should be gone");
assert!(!system.exists(), "system stem should be gone");
assert!(!embeddings.exists(), "embeddings sidecar should be gone");
assert!(
!meetings.join("archive/2026-04-01-force.md").exists(),
"nothing in archive for force delete"
);
});
}
#[test]
fn parse_retention_days_accepts_day_suffixes() {
assert_eq!(parse_retention_days("30").unwrap(), 30);
assert_eq!(parse_retention_days("14d").unwrap(), 14);
assert_eq!(parse_retention_days("7 days").unwrap(), 7);
}
#[test]
fn cmd_cleanup_apply_removes_only_expired_audio_candidates() {
with_temp_home(|dir| {
let meetings = dir.join("meetings");
std::fs::create_dir_all(&meetings).unwrap();
let old_md = meetings.join("old.md");
std::fs::write(
&old_md,
"---\ntitle: Old\ntype: meeting\ndate: 2026-04-01T09:00:00-07:00\nduration: 5m\n---\n\nOld",
)
.unwrap();
let old_wav = meetings.join("old.wav");
std::fs::write(&old_wav, b"old audio").unwrap();
let pinned_md = meetings.join("pinned.md");
std::fs::write(
&pinned_md,
"---\ntitle: Pinned\ntype: meeting\ndate: 2026-04-01T09:00:00-07:00\nduration: 5m\naudio_retention: pinned\n---\n\nPinned",
)
.unwrap();
let pinned_wav = meetings.join("pinned.wav");
std::fs::write(&pinned_wav, b"pinned audio").unwrap();
let config = Config {
output_dir: meetings.clone(),
..Config::default()
};
cmd_cleanup(true, Some("0d"), true, &config).unwrap();
assert!(old_md.exists(), "cleanup must not delete markdown");
assert!(
!old_wav.exists(),
"expired unpinned audio should be removed"
);
assert!(pinned_md.exists(), "pinned markdown remains");
assert!(pinned_wav.exists(), "pinned audio should be kept");
});
}
#[test]
fn setup_demo_installs_five_mcp_fixture_meetings_idempotently() {
with_temp_home(|_| {
let demo_dir = Config::minutes_dir().join("demo");
let first = demo_data::install_mcp_demo_fixtures(&demo_dir).unwrap();
assert_eq!(first.total_fixtures, 5);
assert_eq!(first.updated_fixtures, 5);
let files = std::fs::read_dir(&demo_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("md"))
.count();
assert_eq!(files, 5);
let one_fixture =
std::fs::read_to_string(demo_dir.join("2026-02-28-pricing-strategy.md")).unwrap();
assert!(one_fixture.contains("minutes_demo: true"));
let second = demo_data::install_mcp_demo_fixtures(&demo_dir).unwrap();
assert_eq!(second.total_fixtures, 5);
assert_eq!(second.updated_fixtures, 0);
});
}
#[test]
fn graceful_interrupt_requests_shutdown_before_force_exit() {
let stop = AtomicBool::new(false);
let shutdowns = AtomicUsize::new(0);
let first = handle_graceful_interrupt_with_shutdown(&stop, "Stopping...", || {
shutdowns.fetch_add(1, Ordering::Relaxed);
});
assert_eq!(first, InterruptAction::Continue);
assert!(stop.load(Ordering::Relaxed));
assert_eq!(shutdowns.load(Ordering::Relaxed), 1);
let second = handle_graceful_interrupt_with_shutdown(&stop, "Stopping...", || {
shutdowns.fetch_add(1, Ordering::Relaxed);
});
assert_eq!(second, InterruptAction::ForceExit(1));
assert_eq!(shutdowns.load(Ordering::Relaxed), 2);
}
}
fn cmd_delete(meeting: &str, with_audio: bool, force: bool, config: &Config) -> Result<()> {
let md_path = if Path::new(meeting).exists() {
PathBuf::from(meeting)
} else {
minutes_core::search::resolve_slug(meeting, config)
.ok_or_else(|| anyhow::anyhow!("no meeting found matching: {}", meeting))?
};
let title = md_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let audio_artifacts = minutes_core::capture::meeting_audio_artifact_paths(&md_path);
let has_audio = audio_artifacts.iter().any(|path| path.exists());
if force {
std::fs::remove_file(&md_path)?;
eprintln!("Deleted: {}", md_path.display());
if with_audio && has_audio {
for path in audio_artifacts.iter().filter(|path| path.exists()) {
std::fs::remove_file(path)?;
eprintln!("Deleted audio artifact: {}", path.display());
}
}
} else {
let archive_dir = config.output_dir.join("archive");
std::fs::create_dir_all(&archive_dir)?;
let dest_md = archive_dir.join(md_path.file_name().unwrap());
std::fs::rename(&md_path, &dest_md)?;
eprintln!("Archived: {} → {}", title, dest_md.display());
if with_audio && has_audio {
for path in audio_artifacts.iter().filter(|path| path.exists()) {
let dest_audio = archive_dir.join(path.file_name().unwrap());
std::fs::rename(path, &dest_audio)?;
eprintln!("Archived audio artifact: {}", dest_audio.display());
}
}
}
if has_audio && !with_audio {
eprintln!(
"Note: audio artifacts still exist alongside {}. Use --with-audio to remove them.",
md_path.display()
);
}
Ok(())
}
fn cmd_schema() -> Result<()> {
let schema = schemars::schema_for!(minutes_core::markdown::Frontmatter);
let json = serde_json::to_string_pretty(&schema)?;
println!("{}", json);
Ok(())
}
fn cmd_get(slug_or_path: &str, json: bool, compact_json: bool, config: &Config) -> Result<()> {
let path = if let Some(p) = minutes_core::search::resolve_slug(slug_or_path, config) {
p
} else {
let candidate = std::path::PathBuf::from(slug_or_path);
if !candidate.exists() || candidate.extension().and_then(|s| s.to_str()) != Some("md") {
anyhow::bail!("no meeting found matching slug or path: {}", slug_or_path);
}
if let Err(msg) = minutes_core::notes::validate_meeting_path(&candidate, &config.output_dir)
{
anyhow::bail!("{}", msg);
}
candidate
};
let content = std::fs::read_to_string(&path)?;
if !json {
println!("{}", content);
return Ok(());
}
let (frontmatter_str, body) = minutes_core::markdown::split_frontmatter(&content);
let mut frontmatter: minutes_core::markdown::Frontmatter = if frontmatter_str.is_empty() {
anyhow::bail!("meeting has no frontmatter: {}", path.display());
} else {
serde_yaml::from_str(frontmatter_str.trim())?
};
let overlay_db = minutes_core::overlays::default_db_path();
let confirmations =
minutes_core::overlays::load_speaker_confirmations_for_meeting_at(&overlay_db, &path)
.unwrap_or_default();
let overlay_applied = !confirmations.is_empty();
minutes_core::overlays::apply_speaker_confirmations(
&mut frontmatter.speaker_map,
&confirmations,
);
let payload = serde_json::json!({
"path": path.to_string_lossy(),
"frontmatter": frontmatter,
"body": body,
"overlay_applied": overlay_applied,
});
let payload = if compact_json {
payload
} else {
let mut payload = payload;
payload["raw_markdown"] = serde_json::Value::String(content);
payload
};
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
fn cmd_events(
limit: usize,
event_type: Option<String>,
since: Option<String>,
follow: bool,
since_seq: Option<u64>,
_config: &Config,
) -> Result<()> {
if since.is_some() && since_seq.is_some() {
anyhow::bail!("use either --since or --since-seq, not both");
}
if follow {
return cmd_events_follow(limit, event_type, since, since_seq);
}
let since_dt = parse_events_since(since.as_deref())?;
let mut events = if let Some(seq) = since_seq {
minutes_core::events::read_events_since_seq(
seq,
if event_type.is_some() {
None
} else {
Some(limit)
},
)
} else {
minutes_core::events::read_events(
since_dt,
if event_type.is_some() {
None
} else {
Some(limit)
},
)
};
filter_events_by_type(&mut events, event_type.as_deref());
apply_events_limit(&mut events, limit, since_seq.is_some());
let json = serde_json::to_string_pretty(&events)?;
println!("{}", json);
Ok(())
}
fn cmd_events_follow(
limit: usize,
event_type: Option<String>,
since: Option<String>,
since_seq: Option<u64>,
) -> Result<()> {
let since_dt = parse_events_since(since.as_deref())?;
let mut cursor = since_seq.unwrap_or(0);
let mut initial_events = if let Some(seq) = since_seq {
minutes_core::events::read_events_since_seq(seq, None)
} else {
minutes_core::events::read_events(
since_dt,
if event_type.is_some() {
None
} else {
Some(limit)
},
)
};
filter_events_by_type(&mut initial_events, event_type.as_deref());
apply_events_limit(&mut initial_events, limit, since_seq.is_some());
for event in &initial_events {
cursor = cursor.max(event.seq);
print_event_jsonl(event)?;
}
if since_seq.is_none() && initial_events.is_empty() {
cursor = minutes_core::events::latest_event_seq();
}
loop {
std::thread::sleep(Duration::from_millis(500));
for event in minutes_core::events::read_events_since_seq(cursor, None) {
cursor = cursor.max(event.seq);
if event_matches_type(&event, event_type.as_deref()) {
print_event_jsonl(&event)?;
}
}
}
}
fn filter_events_by_type(
events: &mut Vec<minutes_core::events::EventEnvelope>,
event_type: Option<&str>,
) {
if let Some(event_type) = event_type {
events.retain(|event| event_matches_type(event, Some(event_type)));
}
}
fn apply_events_limit(
events: &mut Vec<minutes_core::events::EventEnvelope>,
limit: usize,
since_seq_mode: bool,
) {
if events.len() <= limit {
return;
}
if since_seq_mode {
events.truncate(limit);
} else {
let skip = events.len().saturating_sub(limit);
events.drain(0..skip);
}
}
fn event_matches_type(
event: &minutes_core::events::EventEnvelope,
event_type: Option<&str>,
) -> bool {
let Some(event_type) = event_type else {
return true;
};
serde_json::to_value(event)
.ok()
.and_then(|value| value.get("event_type").cloned())
.and_then(|value| value.as_str().map(str::to_owned))
.map(|actual| actual == event_type)
.unwrap_or(false)
}
#[allow(clippy::too_many_arguments)]
fn cmd_agent_annotate(
agent_id: String,
tools: Vec<String>,
subkind: String,
meeting_id: Option<String>,
meeting_path: Option<String>,
span_start_ms: Option<u64>,
span_end_ms: Option<u64>,
body: String,
citations: Vec<String>,
confidence: String,
provenance: Option<String>,
) -> Result<()> {
use minutes_core::events::{
append_agent_annotation, AgentAnnotationAgent, AgentAnnotationRequest, AgentAnnotationSpan,
AgentAnnotationTarget,
};
let span = match (span_start_ms, span_end_ms) {
(Some(start_ms), Some(end_ms)) => Some(AgentAnnotationSpan { start_ms, end_ms }),
(None, None) => None,
_ => {
let error = serde_json::json!({
"ok": false,
"error": "invalid_payload",
"message": "--span-start-ms and --span-end-ms must be provided together",
"agent_id": agent_id,
"event_type": minutes_core::events::AGENT_ANNOTATION_EVENT_TYPE,
"allowlist_path": minutes_core::events::agents_allowlist_path().display().to_string()
});
eprintln!("{}", serde_json::to_string_pretty(&error)?);
std::process::exit(2);
}
};
let provenance = provenance
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|error| anyhow::anyhow!("invalid --provenance JSON: {error}"))?
.unwrap_or_else(|| serde_json::json!({}));
let request = AgentAnnotationRequest {
agent: AgentAnnotationAgent {
id: agent_id,
tools,
},
subkind,
target: AgentAnnotationTarget {
meeting_id,
meeting_path,
span,
},
body,
citations,
confidence,
provenance,
};
match append_agent_annotation(request) {
Ok(envelope) => {
println!("{}", serde_json::to_string_pretty(&envelope)?);
Ok(())
}
Err(error) => {
eprintln!("{}", serde_json::to_string_pretty(&error.to_body())?);
std::process::exit(2);
}
}
}
fn parse_events_since(raw: Option<&str>) -> Result<Option<chrono::DateTime<Local>>> {
let Some(raw) = raw else {
return Ok(None);
};
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(Some(parsed.with_timezone(&Local)));
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
return Ok(date
.and_hms_opt(0, 0, 0)
.and_then(|ndt| chrono::Local.from_local_datetime(&ndt).single()));
}
Err(anyhow::anyhow!(
"invalid --since value '{}' (expected YYYY-MM-DD or RFC3339)",
raw
))
}
fn print_event_jsonl(event: &minutes_core::events::EventEnvelope) -> Result<()> {
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "{}", serde_json::to_string(event)?)?;
stdout.flush()?;
Ok(())
}
fn cmd_insights(
kind: Option<String>,
confidence: Option<String>,
participant: Option<String>,
since: Option<String>,
limit: usize,
actionable: bool,
) -> Result<()> {
use minutes_core::events::{InsightConfidence, InsightFilter, InsightKind};
let since_dt = if let Some(s) = since.as_deref() {
match chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
Ok(d) => d
.and_hms_opt(0, 0, 0)
.and_then(|ndt| chrono::Local.from_local_datetime(&ndt).single()),
Err(e) => {
eprintln!("warning: invalid date '{}' (expected YYYY-MM-DD): {}", s, e);
None
}
}
} else {
None
};
let kind_filter = match kind.as_deref() {
Some("decision") => Some(InsightKind::Decision),
Some("commitment") => Some(InsightKind::Commitment),
Some("question") => Some(InsightKind::Question),
Some(other) => {
eprintln!("warning: unknown insight kind '{}', showing all", other);
None
}
None => None,
};
let min_confidence = if actionable {
Some(InsightConfidence::Strong)
} else {
confidence.as_deref().map(|c| match c {
"tentative" => InsightConfidence::Tentative,
"inferred" => InsightConfidence::Inferred,
"strong" => InsightConfidence::Strong,
"explicit" => InsightConfidence::Explicit,
other => {
eprintln!("warning: unknown confidence '{}', showing all", other);
InsightConfidence::Tentative
}
})
};
let filter = InsightFilter {
kind: kind_filter,
min_confidence,
participant,
since: since_dt,
limit: Some(limit),
};
let insights = minutes_core::events::read_insights(&filter);
let output: Vec<serde_json::Value> = insights
.into_iter()
.map(|(ts, insight, meeting_title)| {
serde_json::json!({
"timestamp": ts.to_rfc3339(),
"meeting_title": meeting_title,
"kind": insight.kind,
"content": insight.content,
"confidence": insight.confidence,
"participants": insight.participants,
"owner": insight.owner,
"deadline": insight.deadline,
"topic": insight.topic,
"source_meeting": insight.source_meeting,
})
})
.collect();
let json = serde_json::to_string_pretty(&output)?;
println!("{}", json);
Ok(())
}
fn cmd_context(action: ContextAction) -> Result<()> {
match action {
ContextAction::ActivitySummary {
session,
path,
start,
end,
json,
} => cmd_context_activity_summary(
session.as_deref(),
path.as_deref(),
start.as_deref(),
end.as_deref(),
json,
),
ContextAction::Search { query, limit, json } => cmd_context_search(&query, limit, json),
ContextAction::GetMoment {
session,
path,
at,
before_minutes,
after_minutes,
json,
} => cmd_context_get_moment(
session.as_deref(),
path.as_deref(),
at.as_deref(),
before_minutes,
after_minutes,
json,
),
}
}
fn parse_rfc3339_local(raw: &str) -> Result<chrono::DateTime<Local>> {
let parsed = chrono::DateTime::parse_from_rfc3339(raw)?;
Ok(parsed.with_timezone(&Local))
}
fn resolve_context_session(
session: Option<&str>,
path: Option<&Path>,
) -> Result<Option<minutes_core::context_store::ContextSession>> {
if let Some(session_id) = session {
return Ok(minutes_core::context_store::get_session(session_id)?);
}
if let Some(path) = path {
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string();
if let Some(session) = minutes_core::context_store::get_session_for_artifact(&canonical)? {
return Ok(Some(session));
}
let original = path.display().to_string();
return Ok(minutes_core::context_store::get_session_for_artifact(
&original,
)?);
}
Ok(None)
}
fn summarize_counts(values: impl Iterator<Item = Option<String>>) -> Vec<ContextCount> {
let mut counts = std::collections::HashMap::<String, usize>::new();
for value in values.flatten() {
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
*counts.entry(trimmed.to_string()).or_insert(0) += 1;
}
let mut pairs = counts
.into_iter()
.map(|(name, count)| ContextCount { name, count })
.collect::<Vec<_>>();
pairs.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
pairs.truncate(10);
pairs
}
fn cmd_context_activity_summary(
session: Option<&str>,
path: Option<&Path>,
start: Option<&str>,
end: Option<&str>,
json: bool,
) -> Result<()> {
let resolved_session = resolve_context_session(session, path)?;
let (events, links, window_start, window_end) = if let Some(session_row) = &resolved_session {
let events =
minutes_core::context_store::list_events_for_session(&session_row.id, None, None)?;
let links = minutes_core::context_store::list_links_for_session(&session_row.id)?;
let start = session_row.started_at;
let end = session_row.ended_at.unwrap_or_else(Local::now);
(events, links, start, end)
} else {
let start_dt = start.map(parse_rfc3339_local).transpose()?.ok_or_else(|| {
anyhow::anyhow!("provide --session, --path, or both --start and --end")
})?;
let end_dt = end.map(parse_rfc3339_local).transpose()?.ok_or_else(|| {
anyhow::anyhow!("provide --session, --path, or both --start and --end")
})?;
let events = minutes_core::context_store::list_events_in_window(start_dt, end_dt)?;
(events, vec![], start_dt, end_dt)
};
let output = ContextSummaryOutput {
session: resolved_session,
links,
top_apps: summarize_counts(events.iter().map(|e| e.app_name.clone())),
top_windows: summarize_counts(events.iter().map(|e| e.window_title.clone())),
events,
window: ContextWindow {
start: window_start.to_rfc3339(),
end: window_end.to_rfc3339(),
},
};
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
eprintln!(
"Desktop context summary: {} → {}",
output.window.start, output.window.end
);
if let Some(session_row) = &output.session {
eprintln!(
" session: {} [{} / {}]",
session_row.id,
serde_json::to_string(&session_row.session_type)?,
serde_json::to_string(&session_row.state)?
);
}
if !output.top_apps.is_empty() {
eprintln!(
" top apps: {}",
output
.top_apps
.iter()
.map(|entry| format!("{} ({})", entry.name, entry.count))
.collect::<Vec<_>>()
.join(", ")
);
}
if !output.top_windows.is_empty() {
eprintln!(
" top windows: {}",
output
.top_windows
.iter()
.map(|entry| format!("{} ({})", entry.name, entry.count))
.collect::<Vec<_>>()
.join(", ")
);
}
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn cmd_context_search(query: &str, limit: usize, json: bool) -> Result<()> {
let results = minutes_core::context_store::search_events(query, limit)?;
let output = ContextSearchOutput { results };
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
if output.results.is_empty() {
eprintln!("No desktop-context events found for \"{}\".", query);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
eprintln!("Desktop-context matches for \"{}\":", query);
for event in &output.results {
eprintln!(
" {} — {}{}{}",
event.observed_at.to_rfc3339(),
event
.app_name
.as_deref()
.or(event.bundle_id.as_deref())
.unwrap_or("unknown"),
event
.window_title
.as_deref()
.map(|title| format!(" :: {}", title))
.unwrap_or_default(),
event
.url
.as_deref()
.map(|url| format!(" <{}>", url))
.unwrap_or_default()
);
}
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn cmd_context_get_moment(
session: Option<&str>,
path: Option<&Path>,
at: Option<&str>,
before_minutes: i64,
after_minutes: i64,
json: bool,
) -> Result<()> {
let resolved_session = resolve_context_session(session, path)?;
let anchor = if let Some(session_row) = &resolved_session {
session_row.started_at
} else if let Some(raw) = at {
parse_rfc3339_local(raw)?
} else {
anyhow::bail!("provide --session, --path, or --at");
};
let window_start = anchor - chrono::Duration::minutes(before_minutes);
let window_end = anchor + chrono::Duration::minutes(after_minutes);
let events = if let Some(session_row) = &resolved_session {
minutes_core::context_store::list_events_for_session(
&session_row.id,
Some(window_start),
Some(window_end),
)?
} else {
minutes_core::context_store::list_events_in_window(window_start, window_end)?
};
let links = if let Some(session_row) = &resolved_session {
minutes_core::context_store::list_links_for_session(&session_row.id)?
} else {
vec![]
};
let output = ContextMomentOutput {
session: resolved_session,
links,
events,
window: ContextWindow {
start: window_start.to_rfc3339(),
end: window_end.to_rfc3339(),
},
};
if json {
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
eprintln!(
"Desktop-context moment window: {} → {}",
output.window.start, output.window.end
);
if let Some(session_row) = &output.session {
eprintln!(" session: {}", session_row.id);
}
for event in &output.events {
eprintln!(
" {} — {}{}",
event.observed_at.to_rfc3339(),
event.app_name.as_deref().unwrap_or("unknown"),
event
.window_title
.as_deref()
.map(|title| format!(" :: {}", title))
.unwrap_or_default()
);
}
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn cmd_import(from: &str, dir: Option<&Path>, dry_run: bool, config: &Config) -> Result<()> {
if dir.is_none() && looks_like_audio_path(from) {
let path = Path::new(from);
if dry_run {
eprintln!(
"Would process audio file as a meeting: minutes process \"{}\" --type meeting",
path.display()
);
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "dry-run",
"file": path.display().to_string(),
"content_type": "meeting",
"command": format!("minutes process \"{}\" --type meeting", path.display()),
}))?
);
return Ok(());
}
eprintln!(
"Processing audio file via import compatibility path. Preferred command: minutes process \"{}\" --type meeting",
path.display()
);
return cmd_process(path, "meeting", None, None, config);
}
match from {
"granola" => import_granola(dir, dry_run, config),
other => anyhow::bail!(
"Unknown import source: {}. Supported source: granola. To process an audio file, run: minutes process \"{}\" --type meeting",
other,
other
),
}
}
fn looks_like_audio_path(value: &str) -> bool {
Path::new(value)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"wav" | "m4a" | "mp3" | "ogg" | "webm" | "mp4" | "mov" | "aac"
)
})
.unwrap_or(false)
}
fn import_granola(dir: Option<&Path>, dry_run: bool, config: &Config) -> Result<()> {
let source_dir = dir.map(PathBuf::from).unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".granola-archivist")
.join("output")
});
if !source_dir.exists() {
anyhow::bail!(
"Granola export directory not found: {}\n\
Export your Granola meetings first, or specify a directory with --dir",
source_dir.display()
);
}
let output_dir = &config.output_dir;
std::fs::create_dir_all(output_dir)?;
let mut imported = 0;
let mut skipped = 0;
for entry in std::fs::read_dir(&source_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let content = std::fs::read_to_string(&path)?;
let title = content
.lines()
.find(|l| l.starts_with("# Meeting:"))
.map(|l| l.trim_start_matches("# Meeting:").trim().to_string())
.unwrap_or_else(|| "Untitled Granola Meeting".into());
let date = content
.lines()
.find(|l| l.starts_with("Date:"))
.and_then(|l| {
let raw = l.trim_start_matches("Date:").trim();
let cleaned = raw.replace(" @ ", "T").replace(" @", "T");
if cleaned.len() >= 10 {
Some(cleaned)
} else {
None
}
})
.unwrap_or_else(|| "2026-01-01T00:00:00".into());
let attendees_line = content
.lines()
.find(|l| l.starts_with("Attendees:"))
.map(|l| l.trim_start_matches("Attendees:").trim().to_string())
.unwrap_or_default();
let attendees = minutes_core::markdown::parse_attendees_raw(&attendees_line);
let notes_section = extract_section(&content, "## Your Notes");
let transcript_section = extract_section(&content, "## Transcript");
let date_prefix = &date[..10.min(date.len())];
let slug: String = title
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == ' ' {
c
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-");
let filename = format!("{}-{}.md", date_prefix, slug);
let output_path = output_dir.join(&filename);
if output_path.exists() {
skipped += 1;
if dry_run {
eprintln!(" SKIP (exists): {}", filename);
}
continue;
}
let mut output = String::new();
output.push_str("---\n");
output.push_str(&format!("title: {}\n", title));
output.push_str("type: meeting\n");
output.push_str(&format!("date: {}\n", date));
output.push_str("source: granola-import\n");
if !attendees.is_empty() {
output.push_str("attendees:\n");
for attendee in &attendees {
output.push_str(&format!(" - {}\n", serde_json::to_string(attendee)?));
}
}
if !attendees_line.is_empty() && attendees_line != "None" {
output.push_str(&format!(
"attendees_raw: {}\n",
serde_json::to_string(&attendees_line)?
));
}
output.push_str("---\n\n");
if let Some(notes) = ¬es_section {
output.push_str("## Notes\n\n");
output.push_str(notes);
output.push_str("\n\n");
}
if let Some(transcript) = &transcript_section {
output.push_str("## Transcript\n\n");
output.push_str(transcript);
output.push('\n');
}
if dry_run {
eprintln!(" WOULD IMPORT: {} -> {}", path.display(), filename);
} else {
std::fs::write(&output_path, &output)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&output_path, std::fs::Permissions::from_mode(0o600))?;
}
eprintln!(" Imported: {}", filename);
}
imported += 1;
}
if !dry_run && imported > 0 {
if let Err(e) = minutes_core::graph::rebuild_index(config) {
tracing::warn!(error = %e, "graph index rebuild failed (non-fatal)");
}
}
let action = if dry_run { "Would import" } else { "Imported" };
let json = serde_json::json!({
"imported": imported,
"skipped": skipped,
"source": "granola",
"output_dir": output_dir.display().to_string(),
"dry_run": dry_run,
});
println!("{}", serde_json::to_string_pretty(&json)?);
eprintln!(
"\n{} {} meetings ({} skipped, already exist)",
action, imported, skipped
);
Ok(())
}
fn extract_section(content: &str, heading: &str) -> Option<String> {
let mut in_section = false;
let mut section = String::new();
for line in content.lines() {
if line.starts_with(heading) {
in_section = true;
continue;
}
if in_section && line.starts_with("## ") {
break; }
if in_section {
section.push_str(line);
section.push('\n');
}
}
let trimmed = section.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn cmd_vault_setup(
path: Option<PathBuf>,
strategy_override: Option<String>,
subdir: Option<String>,
mut config: Config,
) -> Result<()> {
use minutes_core::vault;
if let Some(ref sub) = subdir {
let trimmed = sub.trim_matches('/');
if trimmed.is_empty() {
anyhow::bail!("--subdir cannot be empty");
}
if Path::new(sub).is_absolute() || sub.contains("..") {
anyhow::bail!("--subdir must be a relative path without '..' components");
}
config.vault.meetings_subdir = trimmed.to_string();
}
let vault_path = if let Some(p) = path {
let expanded = if p.starts_with("~") {
dirs::home_dir()
.unwrap_or_default()
.join(p.strip_prefix("~").unwrap_or(&p))
} else {
p
};
if !expanded.exists() {
anyhow::bail!("path does not exist: {}", expanded.display());
}
expanded
} else {
eprintln!("Scanning for markdown vaults...\n");
let vaults = vault::detect_vaults();
if vaults.is_empty() {
eprintln!("No Obsidian/Logseq vaults detected.");
eprintln!("Run with --path to specify your vault location:");
eprintln!(" minutes vault setup --path ~/Documents/life");
return Ok(());
}
eprintln!("Found {} vault(s):\n", vaults.len());
for (i, v) in vaults.iter().enumerate() {
let cloud_note = match &v.cloud {
Some(provider) => format!(" ({})", provider),
None => String::new(),
};
let tcc_note = if v.tcc_protected {
" [TCC-protected]"
} else {
""
};
eprintln!(
" {}. {} — {}{}{}",
i + 1,
v.path.display(),
v.kind,
cloud_note,
tcc_note
);
}
if vaults.len() == 1 {
eprintln!("\nUsing the only vault found.");
vaults[0].path.clone()
} else {
eprintln!("\nRe-run with --path to select a vault:");
eprintln!(" minutes vault setup --path {}", vaults[0].path.display());
return Ok(());
}
};
let tcc = vault::is_tcc_protected(&vault_path);
let cloud = vault::is_cloud_synced(&vault_path);
let recommended = strategy_override
.as_ref()
.map(|s| match s.as_str() {
"symlink" => vault::VaultStrategy::Symlink,
"copy" => vault::VaultStrategy::Copy,
"direct" => vault::VaultStrategy::Direct,
_ => vault::recommend_strategy(&vault_path),
})
.unwrap_or_else(|| vault::recommend_strategy(&vault_path));
eprintln!("\nVault: {}", vault_path.display());
if let Some(ref provider) = cloud {
eprintln!("Cloud sync: {} detected", provider);
}
if tcc {
eprintln!("TCC: ~/Documents/ is macOS-protected (terminal needs Full Disk Access)");
}
eprintln!("Strategy: {}", recommended);
match recommended {
vault::VaultStrategy::Symlink => {
let meetings_link = vault_path.join(&config.vault.meetings_subdir);
eprintln!(
"\nCreating symlink: {} → {}",
meetings_link.display(),
config.output_dir.display()
);
match vault::create_symlink(&meetings_link, &config.output_dir) {
Ok(()) => {
eprintln!("Symlink created successfully.");
}
Err(minutes_core::error::VaultError::PermissionDenied(path)) => {
eprintln!("\nPermission denied: {}", path);
eprintln!("\nmacOS blocks terminal access to this directory.");
eprintln!("Options:");
eprintln!(" 1. Use Minutes.app (Settings > Vault) — no FDA needed");
eprintln!(" 2. Create the symlink manually:");
eprintln!(
" ln -s {} {}",
config.output_dir.display(),
meetings_link.display()
);
eprintln!(" 3. Grant Full Disk Access to your terminal:");
eprintln!(" System Settings > Privacy & Security > Full Disk Access");
return Ok(());
}
Err(minutes_core::error::VaultError::ExistingDirectory(path)) => {
eprintln!("\nDirectory already exists: {}", path);
eprintln!("Move or merge it first, then re-run this command.");
eprintln!(
" mv {} {}/vault-backup-meetings",
path,
vault_path.display()
);
return Ok(());
}
Err(e) => return Err(e.into()),
}
}
vault::VaultStrategy::Copy => {
let dest = vault::vault_meetings_dir(&config);
if cloud.is_some() {
eprintln!("\nCloud-synced vault detected — using copy strategy.");
eprintln!("Meetings will be copied to: {}", dest.display());
eprintln!("This works with iCloud, Obsidian Sync, Dropbox, etc.");
} else if tcc {
eprintln!("\nTCC-protected path — using copy strategy.");
eprintln!("Note: copy requires write access to the vault directory.");
eprintln!("If this fails at runtime, use Minutes.app or grant FDA.");
}
}
vault::VaultStrategy::Direct => {
eprintln!("\nDirect mode: setting output_dir to vault meetings path.");
eprintln!("All meetings will be written directly to the vault.");
config.output_dir = vault_path.join(&config.vault.meetings_subdir);
}
}
config.vault.enabled = true;
config.vault.path = vault_path;
config.vault.strategy = recommended.to_string();
config
.save()
.map_err(|e| anyhow::anyhow!("failed to save config: {}", e))?;
eprintln!(
"\nVault configuration saved to: {}",
Config::config_path().display()
);
eprintln!("Run `minutes vault status` to check health.");
Ok(())
}
fn cmd_vault_status(config: &Config) -> Result<()> {
use minutes_core::vault;
let status = vault::check_health(config);
match status {
vault::VaultStatus::NotConfigured => {
eprintln!("Vault: not configured");
eprintln!("Run `minutes vault setup` to connect a vault.");
}
vault::VaultStatus::Healthy { strategy, path } => {
eprintln!("Vault: healthy");
eprintln!(" Strategy: {}", strategy);
eprintln!(" Path: {}", path.display());
eprintln!(" Subdir: {}", config.vault.meetings_subdir);
}
vault::VaultStatus::BrokenSymlink { link_path, target } => {
eprintln!("Vault: BROKEN SYMLINK");
eprintln!(" Link: {}", link_path.display());
eprintln!(" Target: {} (does not exist)", target.display());
eprintln!("Run `minutes vault setup` to fix.");
}
vault::VaultStatus::PermissionDenied { path } => {
eprintln!("Vault: PERMISSION DENIED");
eprintln!(" Path: {}", path.display());
eprintln!("Grant Full Disk Access or use Minutes.app.");
}
vault::VaultStatus::MissingVaultDir { path } => {
eprintln!("Vault: MISSING DIRECTORY");
eprintln!(" Expected: {}", path.display());
eprintln!("Run `minutes vault setup` to reconfigure.");
}
}
Ok(())
}
fn cmd_vault_unlink(mut config: Config) -> Result<()> {
if !config.vault.enabled {
eprintln!("Vault is not configured.");
return Ok(());
}
let old_path = config.vault.path.display().to_string();
config.vault.enabled = false;
config.vault.path = PathBuf::new();
config.vault.strategy = "auto".into();
config
.save()
.map_err(|e| anyhow::anyhow!("failed to save config: {}", e))?;
eprintln!("Vault unlinked (was: {})", old_path);
eprintln!("Note: any symlinks or copied files remain on disk.");
Ok(())
}
fn cmd_vault_sync(config: &Config) -> Result<()> {
use minutes_core::vault;
if !config.vault.enabled {
eprintln!("Vault is not configured. Run `minutes vault setup` first.");
return Ok(());
}
eprintln!("Syncing meetings to vault...");
match vault::sync_all(config) {
Ok(synced) => {
if synced.is_empty() {
eprintln!("No files to sync (strategy may not require copying).");
} else {
eprintln!("Synced {} file(s) to vault.", synced.len());
for path in &synced {
eprintln!(" {}", path.display());
}
}
}
Err(e) => {
eprintln!("Sync failed: {}", e);
}
}
Ok(())
}
fn cmd_health(json: bool) -> Result<()> {
let config = Config::load();
let items = minutes_core::health::check_all(&config);
if json {
let attention_count = items
.iter()
.filter(|item| item.state == "attention")
.count();
let report = serde_json::json!({
"engine": config.transcription.engine,
"all_ready": attention_count == 0,
"attention_count": attention_count,
"items": items,
});
let envelope = json_envelope("minutes health", report);
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else {
let all_ready = items.iter().all(|i| i.state == "ready");
for item in &items {
let icon = match item.state.as_str() {
"ready" => "\u{2713}", "attention" => "!",
_ => "?",
};
let opt = if item.optional { " (optional)" } else { "" };
eprintln!(" {} {}{}", icon, item.label, opt);
eprintln!(" {}", item.detail);
}
if all_ready {
eprintln!("\nAll systems ready.");
} else {
let attention_count = items.iter().filter(|i| i.state == "attention").count();
eprintln!(
"\n{} item{} need attention.",
attention_count,
if attention_count == 1 { "" } else { "s" }
);
}
}
Ok(())
}
fn cmd_demo_full(config: &Config) -> Result<()> {
let paths = demo_data::seed_demo_meetings(&config.output_dir)?;
if paths.is_empty() {
eprintln!("All demo meetings already exist. Run `minutes demo --clean --full` to re-seed.");
return Ok(());
}
{
let quiet = tracing_subscriber::fmt()
.with_max_level(tracing::Level::ERROR)
.with_target(false)
.finish();
tracing::subscriber::with_default(quiet, || {
minutes_core::graph::rebuild_index(config).ok();
});
}
demo_data::present_demo(paths.len(), 6, &config.output_dir);
Ok(())
}
const DEMO_WAV: &[u8] = include_bytes!("../assets/demo.wav");
fn cmd_demo(config: &Config) -> Result<()> {
config.ensure_dirs()?;
let demo_dir = config.output_dir.join(".demo-temp");
std::fs::create_dir_all(&demo_dir)?;
let demo_path = demo_dir.join("demo.wav");
std::fs::write(&demo_path, DEMO_WAV)?;
eprintln!("Running demo pipeline...");
let result = minutes_core::pipeline::process_with_progress(
&demo_path,
ContentType::Memo,
Some("Minutes Demo"),
config,
|stage| {
let label = match stage {
minutes_core::pipeline::PipelineStage::Transcribing => "Transcribing demo audio",
minutes_core::pipeline::PipelineStage::Diarizing => "Analyzing speakers",
minutes_core::pipeline::PipelineStage::Summarizing => "Generating summary",
minutes_core::pipeline::PipelineStage::Saving => "Saving demo",
};
eprintln!(" {}", label);
},
);
std::fs::remove_file(&demo_path).ok();
std::fs::remove_dir_all(&demo_dir).ok();
match result {
Ok(result) => {
eprintln!("\nDemo complete! Saved: {}", result.path.display());
let result_json = serde_json::json!({
"status": "done",
"file": result.path.display().to_string(),
"title": result.title,
"words": result.word_count,
});
println!("{}", serde_json::to_string_pretty(&result_json)?);
Ok(())
}
Err(e) => {
eprintln!("\nDemo failed: {}", e);
eprintln!("Run `minutes health` to check the speech model and audio pipeline.");
eprintln!(
"If the speech model is missing, run `minutes setup`; otherwise please report the demo failure."
);
Err(e.into())
}
}
}
#[cfg(feature = "whisper")]
fn cmd_dictate(stdout: bool, note_only: bool, config: &Config) -> Result<()> {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
eprintln!("[minutes] Starting dictation (Ctrl-C to stop)...");
if config.dictation.accumulate {
eprintln!(
"[minutes] Speak naturally. Text accumulates across pauses and is written when dictation ends."
);
} else {
eprintln!("[minutes] Speak naturally. Text goes to clipboard after each pause.");
}
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_flag);
ctrlc::set_handler(move || {
if let InterruptAction::ForceExit(code) = handle_graceful_interrupt(
&stop_clone,
"Stopping dictation... (Ctrl+C again to force quit)",
) {
std::process::exit(code);
}
})?;
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGTERM, libc::SIG_IGN);
}
let mut config = config.clone();
if stdout {
config.dictation.destination = "stdout".into();
config.dictation.daily_note_log = !note_only;
} else if note_only {
config.dictation.destination = "daily_note".into();
}
minutes_core::dictation::run(
stop_flag,
&config,
|event| {
use minutes_core::dictation::DictationEvent;
match event {
DictationEvent::Listening => eprintln!("[minutes] Listening..."),
DictationEvent::Accumulating => eprintln!("[minutes] Speaking detected..."),
DictationEvent::Processing => eprintln!("[minutes] Transcribing..."),
DictationEvent::PartialText(text) => {
eprint!("\r\x1b[K[minutes] {}", text);
}
DictationEvent::SilenceCountdown { .. } => {} DictationEvent::Success => {
eprintln!(); if config.dictation.accumulate {
eprintln!("[minutes] Captured text");
} else {
eprintln!("[minutes] Done — text copied to clipboard");
}
}
DictationEvent::Error => eprintln!("[minutes] Transcription failed — audio saved"),
DictationEvent::Cancelled => eprintln!("[minutes] Dictation cancelled"),
DictationEvent::Yielded => {
eprintln!("[minutes] Recording started — yielding dictation")
}
}
},
|result| {
if stdout {
println!("{}", result.text);
}
if let Some(ref path) = result.file_path {
eprintln!("[minutes] Saved: {}", path.display());
}
let (outcome, method, message) = match result.destination.as_str() {
"stdout" => ("printed", "stdout", "Printed dictation to stdout."),
"daily_note" => ("saved", "daily_note", "Saved dictation to the daily note."),
_ => (
"copied",
"clipboard_only",
"Copied dictation to the clipboard.",
),
};
let record = minutes_core::dictation_memory::DictationMemoryRecord::new(
minutes_core::dictation_memory::DictationMemoryInput {
raw_text: result.raw_text.clone(),
cleaned_text: result.text.clone(),
duration_secs: result.duration_secs,
engine_id: match config.dictation.backend.as_str() {
"whisper" | "" => format!("whisper:{}", config.dictation.model),
backend => backend.to_string(),
},
engine_descriptor_version: Some(config.dictation.model.clone()),
vocabulary_mode: None,
vocabulary_used: Vec::new(),
destination: result.destination.clone(),
insertion: minutes_core::dictation_memory::DictationInsertionMemory {
outcome: outcome.into(),
method: method.into(),
verified: true,
clipboard_restored: false,
message: message.into(),
},
target_context: None,
file_path: result.file_path.clone(),
daily_note_appended: result.daily_note_appended,
},
);
if let Err(error) = minutes_core::dictation_memory::append_record(record) {
eprintln!("[minutes] Could not update dictation history: {error}");
}
},
)?;
Ok(())
}
#[cfg(not(feature = "whisper"))]
fn cmd_dictate(_stdout: bool, _note_only: bool, _config: &Config) -> Result<()> {
Err(anyhow::anyhow!(
"`minutes dictate` requires the `whisper` feature. Reinstall without `--no-default-features` to use local dictation."
))
}
fn cmd_enroll(file: Option<&Path>, duration: u64, config: &Config) -> Result<()> {
use minutes_core::voice;
let my_name = match config.identity.name.as_ref() {
Some(name) if !name.is_empty() => name.clone(),
_ => {
eprintln!(
"Your name isn't set yet. This is needed so Minutes knows which speaker is you."
);
eprint!("What's your name? ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let name = input.trim().to_string();
if name.is_empty() {
return Err(anyhow::anyhow!("Name is required for voice enrollment."));
}
let config_path = dirs::config_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config"))
.join("minutes/config.toml");
if config_path.exists() {
let mut content = std::fs::read_to_string(&config_path)?;
if content.contains("[identity]") {
content =
content.replace("[identity]", &format!("[identity]\nname = \"{}\"", name));
} else {
content.push_str(&format!("\n[identity]\nname = \"{}\"\n", name));
}
std::fs::write(&config_path, content)?;
eprintln!("Saved to {}", config_path.display());
}
name
}
};
if !minutes_core::diarize::models_installed(config) {
eprintln!("Speaker diarization models aren't installed yet.");
eprintln!("Run this first: minutes setup --diarization");
eprintln!("Then try: minutes enroll");
return Err(anyhow::anyhow!(
"Diarization models required for voice enrollment."
));
}
eprintln!();
eprintln!(
" \x1b[1;36m◉ Voice Enrollment\x1b[0m \x1b[2mfor\x1b[0m \x1b[1m{}\x1b[0m",
my_name
);
eprintln!();
let audio_path = if let Some(path) = file {
if !path.exists() {
return Err(anyhow::anyhow!("File not found: {}", path.display()));
}
eprintln!(" Using audio file: {}", path.display());
path.to_path_buf()
} else {
eprintln!(" This creates a voice profile so Minutes can identify you");
eprintln!(
" in future meetings. Just talk normally for {} seconds.",
duration
);
eprintln!();
eprintln!(" Tips:");
eprintln!(" - Use the same mic you use for meetings");
eprintln!(" - Talk at your normal volume and pace");
eprintln!(" - Say anything — read something aloud, describe your day");
eprintln!();
eprint!(" Ready? Press Enter to start recording...");
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
eprintln!();
eprintln!(
" \x1b[1;32m● REC\x1b[0m \x1b[1mSpeak now!\x1b[0m ({}s)",
duration
);
eprintln!();
let tmp_dir = std::env::temp_dir().join("minutes-enroll");
std::fs::create_dir_all(&tmp_dir)?;
let tmp_path = tmp_dir.join("enroll-sample.wav");
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag_clone = stop_flag.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(duration * 1000));
flag_clone.store(true, std::sync::atomic::Ordering::Relaxed);
});
minutes_core::capture::record_to_wav(&tmp_path, stop_flag, config)?;
eprintln!(" \x1b[1;32m✓\x1b[0m Recording captured.");
tmp_path
};
eprintln!(" \x1b[2mAnalyzing your voice...\x1b[0m");
let result = minutes_core::diarize::diarize(&audio_path, config)
.ok_or_else(|| anyhow::anyhow!(
"Could not analyze the recording. Make sure you spoke clearly and your mic is working.\n\
Check with: minutes devices"
))?;
if result.segments.is_empty() {
return Err(anyhow::anyhow!(
"No speech detected in the recording.\n\n\
Try again:\n\
- Make sure your mic is not muted\n\
- Speak at normal volume\n\
- Reduce background noise\n\
- Check your mic: minutes devices"
));
}
if result.num_speakers > 1 {
tracing::warn!(
speakers = result.num_speakers,
"multiple speakers detected during enrollment — picking an arbitrary one"
);
eprintln!(
" ⚠ Detected {} voices — the enrolled profile may not be yours.",
result.num_speakers
);
eprintln!(" For best results, re-run in a quiet room with just you speaking.");
}
eprintln!(" \x1b[2mComputing voice profile...\x1b[0m");
let (_, embedding) = result
.speaker_embeddings
.iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Diarization produced no speaker embeddings."))?;
let embedding = embedding.clone();
let conn = voice::open_db().map_err(|e| anyhow::anyhow!("{}", e))?;
let slug: String = my_name
.to_lowercase()
.chars()
.map(|c: char| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
voice::save_profile_blended(
&conn,
&slug,
&my_name,
&embedding,
"self-enrollment",
voice::model_version(config),
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let profiles = voice::list_profiles(&conn).map_err(|e| anyhow::anyhow!("{}", e))?;
if let Some(p) = profiles.iter().find(|p| p.person_slug == slug) {
eprintln!();
eprintln!(" \x1b[1;32m✓ Voice profile saved!\x1b[0m");
eprintln!(" \x1b[2m───────────────────────\x1b[0m");
eprintln!(" \x1b[2mName:\x1b[0m \x1b[1m{}\x1b[0m", p.name);
eprintln!(" \x1b[2mSamples:\x1b[0m {}", p.sample_count);
eprintln!(" \x1b[2mModel:\x1b[0m {}", p.model_version);
eprintln!();
eprintln!(" \x1b[36mWhat happens next:\x1b[0m");
eprintln!(" \x1b[2m›\x1b[0m Your voice will be auto-identified in future meetings");
eprintln!(
" \x1b[2m›\x1b[0m Your lines show as \x1b[1m[{}]\x1b[0m instead of [SPEAKER_X]",
p.name
);
eprintln!(" \x1b[2m›\x1b[0m Run \x1b[33mminutes enroll\x1b[0m again to improve accuracy");
eprintln!(" \x1b[2m›\x1b[0m Run \x1b[33mminutes voices\x1b[0m to see your profile");
}
if file.is_none() {
std::fs::remove_file(&audio_path).ok();
}
Ok(())
}
fn cmd_voices(delete: bool, json: bool) -> Result<()> {
use minutes_core::voice;
let conn = voice::open_db().map_err(|e| anyhow::anyhow!("{}", e))?;
if delete {
let profiles = voice::list_profiles(&conn).map_err(|e| anyhow::anyhow!("{}", e))?;
if profiles.is_empty() {
eprintln!("No voice profiles enrolled.");
return Ok(());
}
for p in &profiles {
voice::delete_profile(&conn, &p.person_slug).map_err(|e| anyhow::anyhow!("{}", e))?;
eprintln!("Deleted voice profile: {}", p.name);
}
return Ok(());
}
let profiles = voice::list_profiles(&conn).map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
println!("{}", serde_json::to_string_pretty(&profiles)?);
return Ok(());
}
if profiles.is_empty() {
eprintln!("No voice profiles enrolled.\nRun: minutes enroll");
return Ok(());
}
eprintln!("Voice profiles:");
for p in &profiles {
eprintln!(
" {} — {} samples, {} ({})",
p.name, p.sample_count, p.source, p.model_version
);
eprintln!(
" enrolled: {}, updated: {}",
p.enrolled_at.get(..10).unwrap_or(&p.enrolled_at),
p.updated_at.get(..10).unwrap_or(&p.updated_at)
);
}
Ok(())
}
fn cmd_confirm(
meeting_path: &Path,
speaker: Option<&str>,
name: Option<&str>,
save_voice: bool,
config: &Config,
) -> Result<()> {
use minutes_core::diarize::{AttributionSource, Confidence};
use minutes_core::overlays;
use minutes_core::voice;
if !meeting_path.exists() {
return Err(anyhow::anyhow!(
"Meeting not found: {}",
meeting_path.display()
));
}
let content = std::fs::read_to_string(meeting_path)?;
let (yaml_str, _body) = minutes_core::markdown::split_frontmatter(&content);
if yaml_str.is_empty() {
return Err(anyhow::anyhow!("Meeting has no YAML frontmatter"));
}
let mut frontmatter: minutes_core::markdown::Frontmatter = serde_yaml::from_str(yaml_str)
.map_err(|e| anyhow::anyhow!("Failed to parse frontmatter: {}", e))?;
if frontmatter.speaker_map.is_empty() {
eprintln!("No speaker attributions found in this meeting.");
eprintln!("Process the meeting with diarization enabled first.");
return Ok(());
}
let meeting_embeddings = voice::load_meeting_embeddings(meeting_path);
let mut overlay_writes: Vec<(String, String, String)> = Vec::new();
if let (Some(speaker_label), Some(new_name)) = (speaker, name) {
let found = frontmatter
.speaker_map
.iter_mut()
.find(|a| a.speaker_label == speaker_label);
if let Some(attr) = found {
let old_confidence = attr.confidence;
let old_name = attr.name.clone();
attr.name = new_name.to_string();
attr.confidence = Confidence::High;
attr.source = AttributionSource::Manual;
overlay_writes.push((speaker_label.to_string(), new_name.to_string(), old_name));
eprintln!(
"Confirmed: {} = {} (was {:?} → High)",
speaker_label, new_name, old_confidence
);
if save_voice {
if let Some(ref embeddings) = meeting_embeddings {
if let Some(embedding) = embeddings.get(speaker_label) {
let conn = voice::open_db().map_err(|e| anyhow::anyhow!("{}", e))?;
let slug: String = new_name
.to_lowercase()
.chars()
.map(|c: char| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
voice::save_profile_blended(
&conn,
&slug,
new_name,
embedding,
"confirmed",
voice::model_version(config),
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
eprintln!(
"Voice profile saved for {} (from confirmed meeting)",
new_name
);
} else {
eprintln!(
"Warning: no embedding found for {} in meeting sidecar",
speaker_label
);
}
} else {
eprintln!("Warning: no meeting embeddings sidecar found (meeting was processed before Level 3)");
}
}
} else {
return Err(anyhow::anyhow!(
"Speaker '{}' not found in speaker_map. Available: {}",
speaker_label,
frontmatter
.speaker_map
.iter()
.map(|a| a.speaker_label.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
} else {
eprintln!("Speaker attributions for: {}", frontmatter.title);
eprintln!();
for attr in &mut frontmatter.speaker_map {
if attr.confidence == Confidence::High {
eprintln!(
" {} = {} (high, {:?}) ✓",
attr.speaker_label, attr.name, attr.source
);
continue;
}
eprint!(
" {} = {} ({:?}, {:?}) — confirm? [Y/n/name]: ",
attr.speaker_label, attr.name, attr.confidence, attr.source
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty()
|| input.eq_ignore_ascii_case("y")
|| input.eq_ignore_ascii_case("yes")
{
let old_name = attr.name.clone();
attr.confidence = Confidence::High;
attr.source = AttributionSource::Manual;
overlay_writes.push((attr.speaker_label.clone(), attr.name.clone(), old_name));
eprintln!(" → Confirmed: {} = {}", attr.speaker_label, attr.name);
} else if input.eq_ignore_ascii_case("n") || input.eq_ignore_ascii_case("no") {
eprintln!(" → Skipped");
} else {
let old_name = attr.name.clone();
attr.name = input.to_string();
attr.confidence = Confidence::High;
attr.source = AttributionSource::Manual;
overlay_writes.push((attr.speaker_label.clone(), attr.name.clone(), old_name));
eprintln!(" → Updated: {} = {}", attr.speaker_label, attr.name);
}
}
if save_voice {
if let Some(ref embeddings) = meeting_embeddings {
let conn = voice::open_db().map_err(|e| anyhow::anyhow!("{}", e))?;
for attr in &frontmatter.speaker_map {
if attr.confidence == Confidence::High
&& attr.source == AttributionSource::Manual
{
if let Some(embedding) = embeddings.get(&attr.speaker_label) {
let slug: String = attr
.name
.to_lowercase()
.chars()
.map(|c: char| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
voice::save_profile_blended(
&conn,
&slug,
&attr.name,
embedding,
"confirmed",
voice::model_version(config),
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
eprintln!(" Voice profile saved for {}", attr.name);
}
}
}
} else {
eprintln!("No meeting embeddings sidecar — voice profiles not saved");
}
}
}
for (speaker_label, confirmed_name, previous_name) in &overlay_writes {
overlays::write_speaker_confirmation(
meeting_path,
speaker_label,
confirmed_name,
Some(previous_name),
Some("minutes confirm"),
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
if !overlay_writes.is_empty() {
if let Err(error) = minutes_core::graph::rebuild_index(config) {
eprintln!(
"Warning: speaker overlay saved, but graph rebuild failed: {}",
error
);
}
}
let confirmed_count = frontmatter
.speaker_map
.iter()
.filter(|a| a.confidence == Confidence::High)
.count();
eprintln!(
"\nSpeaker overlay updated: {}/{} speakers confirmed. Meeting markdown was not rewritten.",
confirmed_count,
frontmatter.speaker_map.len()
);
Ok(())
}
#[cfg(feature = "whisper")]
fn cmd_live(config: &Config) -> Result<()> {
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
eprintln!("Starting live transcript session...");
if config.transcription.engine == "apple-speech" {
eprintln!(
"[minutes] Apple Speech experimental live path selected. If unavailable or weak, Minutes will fall back to Parakeet or Whisper for this session."
);
}
eprintln!("Press Ctrl-C or run `minutes stop` to end.\n");
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
ctrlc::set_handler(move || {
if let InterruptAction::ForceExit(code) = handle_graceful_interrupt(
&stop_clone,
"Stopping gracefully... (Ctrl+C again to force quit)",
) {
std::process::exit(code);
}
})
.ok();
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGTERM, libc::SIG_IGN);
}
let live_context_session_id =
minutes_core::desktop_context::maybe_start_live_transcript_session(
&config.desktop_context,
Local::now(),
);
let _desktop_context_collector = live_context_session_id.as_ref().and_then(|session_id| {
match minutes_core::desktop_context::DesktopContextCollector::start(
session_id.clone(),
minutes_core::desktop_context::DesktopContextSessionKind::LiveTranscript,
config.desktop_context.clone(),
) {
Ok(collector) => Some(collector),
Err(error) => {
tracing::warn!(error = %error, "desktop context collector unavailable for CLI live transcript");
None
}
}
});
match minutes_core::live_transcript::run(stop, config, live_context_session_id) {
Ok((lines, duration, path)) => {
eprintln!("\nLive transcript complete:");
eprintln!(" {} utterances in {:.0}s", lines, duration);
eprintln!(" Saved to: {}", path.display());
Ok(())
}
Err(e) => {
eprintln!("Live transcript error: {}", e);
Err(e.into())
}
}
}
#[cfg(not(feature = "whisper"))]
fn cmd_live(_config: &Config) -> Result<()> {
Err(anyhow::anyhow!(
"`minutes live` requires the `whisper` feature. Reinstall without `--no-default-features` to use live transcription."
))
}
#[cfg(feature = "whisper")]
fn cmd_transcript(since: Option<&str>, status: bool, format: &str) -> Result<()> {
if status {
let s = minutes_core::live_transcript::session_status();
if format == "json" {
println!("{}", serde_json::to_string_pretty(&s)?);
} else {
if s.active {
let source_label = match s.source {
Some(minutes_core::live_transcript::TranscriptSource::RecordingSidecar) => {
" (from recording)"
}
_ => "",
};
eprintln!(
"Live transcript: ACTIVE{} (PID: {})",
source_label,
s.pid.unwrap_or(0)
);
} else {
eprintln!("Live transcript: inactive");
}
eprintln!(" Lines: {}", s.line_count);
eprintln!(" Duration: {:.0}s", s.duration_secs);
if let Some(ref diagnostic) = s.diagnostic {
eprintln!(" Diagnostic: {}", diagnostic);
}
if let Some(ref p) = s.jsonl_path {
eprintln!(" File: {}", p);
}
}
return Ok(());
}
let lines = match since {
Some(s) if s.ends_with('m') || s.ends_with('s') => {
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.map_err(|_| anyhow::anyhow!("invalid duration: {}", s))?;
let ms = match unit {
"m" => num
.checked_mul(60_000)
.ok_or_else(|| anyhow::anyhow!("duration too large: {}", s))?,
"s" => num
.checked_mul(1000)
.ok_or_else(|| anyhow::anyhow!("duration too large: {}", s))?,
_ => anyhow::bail!("invalid duration unit: {}", unit),
};
minutes_core::live_transcript::read_since_duration(ms)?
}
Some(s) => {
let n: usize = s.parse().map_err(|_| {
anyhow::anyhow!(
"invalid --since value: '{}'. Use a line number (42) or duration (5m, 30s)",
s
)
})?;
minutes_core::live_transcript::read_since_line(n)?
}
None => {
minutes_core::live_transcript::read_since_line(0)?
}
};
if format == "json" {
for line in &lines {
println!("{}", serde_json::to_string(line)?);
}
} else {
for line in &lines {
let ts = line.ts.format("%H:%M:%S");
let speaker = line.speaker.as_deref().unwrap_or("?");
println!("[{}] [{}] {}", ts, speaker, line.text);
}
}
Ok(())
}
#[cfg(not(feature = "whisper"))]
fn cmd_transcript(_since: Option<&str>, _status: bool, _format: &str) -> Result<()> {
Err(anyhow::anyhow!(
"`minutes transcript` requires the `whisper` feature. Reinstall without `--no-default-features` to read live transcripts."
))
}