minutes-core 0.18.8

Core library for minutes — audio capture, transcription, and meeting memory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use crate::config::Config;
use crate::markdown::ConsentBasis;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

// ──────────────────────────────────────────────────────────────
// Meeting notes — timestamped annotations from the user.
//
// During recording:
//   minutes note "Alex wants monthly billing"
//   → Reads recording start time from ~/.minutes/recording-start.txt
//   → Calculates elapsed time → [4:23]
//   → Appends to ~/.minutes/current-notes.md (atomic append)
//
// After recording:
//   minutes note --meeting <path> "Follow-up: confirmed via email"
//   → Appends to existing meeting file's ## Notes section
//
// In the pipeline:
//   Pipeline reads current-notes.md + current-context.txt
//   → Passes to LLM as high-priority context
//   → Includes in ## Notes section of output markdown
// ──────────────────────────────────────────────────────────────

/// Path to the current recording's notes file (`~/.minutes/current-notes.md`).
pub fn notes_path() -> PathBuf {
    Config::minutes_dir().join("current-notes.md")
}

/// Path to the pre-meeting context file (`~/.minutes/current-context.txt`).
pub fn context_path() -> PathBuf {
    Config::minutes_dir().join("current-context.txt")
}

/// Path to the current recording's consent sidecar (`~/.minutes/current-consent.json`).
pub fn consent_path() -> PathBuf {
    Config::minutes_dir().join("current-consent.json")
}

/// Path to the recording start timestamp file (`~/.minutes/recording-start.txt`).
pub fn recording_start_path() -> PathBuf {
    Config::minutes_dir().join("recording-start.txt")
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct ConsentSidecar {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    basis: Option<ConsentBasis>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    notice: Option<String>,
}

/// Save the recording start timestamp (epoch seconds).
pub fn save_recording_start() -> std::io::Result<()> {
    let path = recording_start_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    fs::write(&path, now.to_string())
}

/// Get elapsed time since recording started, formatted as [M:SS].
fn elapsed_timestamp() -> Option<String> {
    let path = recording_start_path();
    if !path.exists() {
        return None;
    }

    let start_str = fs::read_to_string(&path).ok()?;
    let start_epoch: u64 = start_str.trim().parse().ok()?;

    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .ok()?
        .as_secs();

    let elapsed = now.saturating_sub(start_epoch);
    let mins = elapsed / 60;
    let secs = elapsed % 60;
    Some(format!("{}:{:02}", mins, secs))
}

/// Add a note to the current recording.
/// Returns the timestamped note line that was appended.
pub fn add_note(text: &str) -> Result<String, String> {
    // Check recording is in progress
    let pid_path = crate::pid::pid_path();
    if !pid_path.exists() {
        return Err("No recording in progress. Start one with: minutes record".into());
    }

    let timestamp = elapsed_timestamp().unwrap_or_else(|| "?:??".into());
    let line = format!("[{}] {}", timestamp, text.trim());

    // Atomic append (O_APPEND mode)
    let path = notes_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }

    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|e| format!("could not open notes file: {}", e))?;

    writeln!(file, "{}", line).map_err(|e| format!("could not write note: {}", e))?;

    tracing::info!(note = %line, "note added");
    Ok(line)
}

/// Save pre-meeting context.
pub fn save_context(text: &str) -> std::io::Result<()> {
    let path = context_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, text.trim())
}

/// Save consent metadata for the current recording.
pub fn save_consent(basis: Option<ConsentBasis>, notice: Option<&str>) -> std::io::Result<()> {
    let path = consent_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let sidecar = ConsentSidecar {
        basis,
        notice: notice
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string),
    };
    let json = serde_json::to_string_pretty(&sidecar)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    fs::write(&path, json)
}

/// Clear consent metadata for the current recording, if any.
pub fn clear_consent() {
    let _ = fs::remove_file(consent_path());
}

/// Read current notes (if any). Returns None if no notes file exists.
pub fn read_notes() -> Option<String> {
    let path = notes_path();
    if path.exists() {
        fs::read_to_string(&path)
            .ok()
            .filter(|s| !s.trim().is_empty())
    } else {
        None
    }
}

/// Read pre-meeting context (if any).
pub fn read_context() -> Option<String> {
    let path = context_path();
    if path.exists() {
        fs::read_to_string(&path)
            .ok()
            .filter(|s| !s.trim().is_empty())
    } else {
        None
    }
}

/// Load consent metadata for the current recording.
pub fn load_consent() -> (Option<ConsentBasis>, Option<String>) {
    let path = consent_path();
    if !path.exists() {
        return (None, None);
    }
    fs::read_to_string(&path)
        .ok()
        .and_then(|raw| serde_json::from_str::<ConsentSidecar>(&raw).ok())
        .map(|sidecar| {
            (
                sidecar.basis,
                sidecar
                    .notice
                    .map(|value| value.trim().to_string())
                    .filter(|value| !value.is_empty()),
            )
        })
        .unwrap_or((None, None))
}

/// Clean up notes and context files after recording completes.
pub fn cleanup() {
    let _ = fs::remove_file(notes_path());
    let _ = fs::remove_file(context_path());
    clear_consent();
    let _ = fs::remove_file(recording_start_path());
}

/// Validate that a meeting annotation target is a markdown file inside the
/// configured meetings output directory.
pub fn validate_meeting_path(meeting_path: &Path, meetings_root: &Path) -> Result<(), String> {
    if meeting_path.extension().and_then(|ext| ext.to_str()) != Some("md") {
        return Err("meeting path must point to a .md file".into());
    }

    let canonical_meeting = meeting_path.canonicalize().map_err(|e| {
        format!(
            "could not resolve meeting path {}: {}",
            meeting_path.display(),
            e
        )
    })?;
    let canonical_root = meetings_root.canonicalize().map_err(|e| {
        format!(
            "could not resolve meetings directory {}: {}",
            meetings_root.display(),
            e
        )
    })?;

    if !canonical_meeting.starts_with(&canonical_root) {
        return Err(format!(
            "meeting path must be inside {}",
            canonical_root.display()
        ));
    }

    Ok(())
}

/// Add a note to an existing meeting file (post-meeting annotation).
pub fn annotate_meeting(meeting_path: &Path, text: &str) -> Result<(), String> {
    if !meeting_path.exists() {
        return Err(format!(
            "meeting file not found: {}",
            meeting_path.display()
        ));
    }

    let now = chrono::Local::now()
        .format("%b %d, post-meeting")
        .to_string();
    let note_line = format!("- [{}] {}", now, text.trim());

    let mut content = fs::read_to_string(meeting_path).map_err(|e| e.to_string())?;

    // Find ## Notes section header (anchored to line start to avoid matching inside transcript)
    if let Some(pos) = content.find("\n## Notes") {
        let pos = pos + 1; // skip the leading newline
                           // Find the end of the Notes section (next ## or end of file)
        let notes_start = pos + "## Notes".len();
        let next_section = content[notes_start..]
            .find("\n## ")
            .map(|i| notes_start + i);

        let insert_pos = next_section.unwrap_or(content.len());
        content.insert_str(insert_pos, &format!("\n{}\n", note_line));
    } else {
        // Find ## Transcript or ## Decisions and insert Notes before it
        let insert_before = ["## Transcript", "## Decisions", "## Action Items"];
        let mut inserted = false;

        for marker in &insert_before {
            if let Some(pos) = content.find(marker) {
                content.insert_str(pos, &format!("## Notes\n\n{}\n\n", note_line));
                inserted = true;
                break;
            }
        }

        if !inserted {
            // Append to end
            content.push_str(&format!("\n## Notes\n\n{}\n", note_line));
        }
    }

    fs::write(meeting_path, &content).map_err(|e| e.to_string())?;

    // Restore 0600 permissions
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = fs::set_permissions(meeting_path, fs::Permissions::from_mode(0o600));
    }

    tracing::info!(
        meeting = %meeting_path.display(),
        note = %text.trim(),
        "post-meeting note added"
    );

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn with_temp_home<T>(f: impl FnOnce(&Path) -> T) -> T {
        let _guard = crate::test_home_env_lock();
        let dir = TempDir::new().unwrap();
        let previous_home = std::env::var_os("HOME");
        std::env::set_var("HOME", dir.path());
        let result = f(dir.path());
        if let Some(home) = previous_home {
            std::env::set_var("HOME", home);
        } else {
            std::env::remove_var("HOME");
        }
        result
    }

    #[test]
    fn elapsed_timestamp_returns_none_without_recording() {
        // No recording-start.txt should exist in test environment
        // (unless a recording is actually happening on this machine)
        // This test is environment-dependent, so just verify the function doesn't panic
        let _ = elapsed_timestamp();
    }

    #[test]
    fn consent_sidecar_saves_loads_and_cleans_up() {
        with_temp_home(|_| {
            save_consent(
                Some(ConsentBasis::VerbalAllParties),
                Some("Read the configured disclosure."),
            )
            .unwrap();

            let (basis, notice) = load_consent();
            assert_eq!(basis, Some(ConsentBasis::VerbalAllParties));
            assert_eq!(notice.as_deref(), Some("Read the configured disclosure."));

            cleanup();
            assert_eq!(load_consent(), (None, None));
        });
    }

    #[test]
    fn annotate_meeting_creates_notes_section() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test-meeting.md");
        fs::write(
            &path,
            "---\ntitle: Test\n---\n\n## Summary\n\nGood meeting.\n\n## Transcript\n\n[0:00] Hello\n",
        )
        .unwrap();

        annotate_meeting(&path, "Follow-up needed").unwrap();

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains("## Notes"));
        assert!(content.contains("Follow-up needed"));
        // Notes should appear before Transcript
        let notes_pos = content.find("## Notes").unwrap();
        let transcript_pos = content.find("## Transcript").unwrap();
        assert!(notes_pos < transcript_pos);
    }

    #[test]
    fn annotate_meeting_appends_to_existing_notes() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test-meeting.md");
        fs::write(
            &path,
            "---\ntitle: Test\n---\n\n## Notes\n\n- [4:23] First note\n\n## Transcript\n\n[0:00] Hello\n",
        )
        .unwrap();

        annotate_meeting(&path, "Second note").unwrap();

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains("First note"));
        assert!(content.contains("Second note"));
    }

    #[test]
    fn annotate_meeting_rejects_nonexistent_file() {
        let result = annotate_meeting(Path::new("/nonexistent/meeting.md"), "note");
        assert!(result.is_err());
    }

    #[test]
    fn validate_meeting_path_allows_files_inside_output_dir() {
        let dir = TempDir::new().unwrap();
        let meetings_dir = dir.path().join("meetings");
        fs::create_dir_all(&meetings_dir).unwrap();

        let meeting = meetings_dir.join("demo.md");
        fs::write(&meeting, "# demo").unwrap();

        let result = validate_meeting_path(&meeting, &meetings_dir);
        assert!(result.is_ok());
    }

    #[test]
    fn validate_meeting_path_rejects_files_outside_output_dir() {
        let dir = TempDir::new().unwrap();
        let meetings_dir = dir.path().join("meetings");
        let outside_dir = dir.path().join("outside");
        fs::create_dir_all(&meetings_dir).unwrap();
        fs::create_dir_all(&outside_dir).unwrap();

        let meeting = outside_dir.join("demo.md");
        fs::write(&meeting, "# demo").unwrap();

        let result = validate_meeting_path(&meeting, &meetings_dir);
        assert!(result.is_err());
    }

    #[cfg(unix)]
    #[test]
    fn validate_meeting_path_rejects_symlink_escape() {
        use std::os::unix::fs::symlink;

        let dir = TempDir::new().unwrap();
        let meetings_dir = dir.path().join("meetings");
        let outside_dir = dir.path().join("outside");
        fs::create_dir_all(&meetings_dir).unwrap();
        fs::create_dir_all(&outside_dir).unwrap();

        let target = outside_dir.join("secret.md");
        fs::write(&target, "# secret").unwrap();

        let link = meetings_dir.join("linked.md");
        symlink(&target, &link).unwrap();

        let result = validate_meeting_path(&link, &meetings_dir);
        assert!(result.is_err());
    }
}