use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::voice::events::Event;
use crate::voice::{EventId, TranscriptEvent};
#[derive(Debug, Clone)]
pub struct SessionPaths {
pub root: PathBuf,
pub meta: PathBuf,
pub transcript: PathBuf,
pub events: PathBuf,
pub log: PathBuf,
}
impl SessionPaths {
#[must_use]
pub fn under(voice_root: &Path, id: &str) -> Self {
let root = voice_root.join(id);
Self {
meta: root.join("meta.yaml"),
transcript: root.join("transcript.jsonl"),
events: root.join("events.jsonl"),
log: root.join("reflections.log"),
root,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TtlDefaults {
#[serde(with = "ttl_secs")]
pub todo: Duration,
#[serde(with = "ttl_secs")]
pub research: Duration,
#[serde(with = "ttl_secs")]
pub question: Duration,
}
impl Default for TtlDefaults {
fn default() -> Self {
Self {
todo: Duration::from_secs(7 * 86_400),
research: Duration::from_secs(30 * 86_400),
question: Duration::from_secs(14 * 86_400),
}
}
}
mod ttl_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = u64::deserialize(d)?;
Ok(Duration::from_secs(secs))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SessionMeta {
#[serde(default)]
pub last_reflected_event_id: Option<EventId>,
#[serde(default)]
pub ttl_defaults: TtlDefaults,
}
#[derive(Debug, Clone)]
pub struct Session {
pub paths: SessionPaths,
pub meta: SessionMeta,
}
impl Session {
pub fn read_transcript_finals_after(&self) -> Result<Vec<TranscriptEvent>> {
read_transcript_finals_after(&self.paths.transcript, self.meta.last_reflected_event_id)
}
pub fn read_events(&self) -> Result<Vec<Event>> {
read_events(&self.paths.events)
}
pub fn append_events(&self, events: &[Event]) -> Result<()> {
append_events(&self.paths.events, events)
}
pub fn set_last_reflected(&mut self, id: EventId) -> Result<()> {
self.meta.last_reflected_event_id = Some(id);
write_meta(&self.paths.meta, &self.meta)
}
pub fn append_log(&self, line: &str) -> Result<()> {
append_log_line(&self.paths.log, line)
}
}
pub fn voice_root() -> Result<PathBuf> {
if let Ok(override_root) = std::env::var("OMNI_DEV_VOICE_ROOT") {
return Ok(PathBuf::from(override_root));
}
let home = dirs::home_dir().context(
"could not determine HOME directory for ~/.omni-dev/voice; \
set OMNI_DEV_VOICE_ROOT to override",
)?;
Ok(home.join(".omni-dev").join("voice"))
}
pub fn open_or_create(id: &str) -> Result<Session> {
let root = voice_root()?;
open_or_create_under(&root, id)
}
pub fn open_or_create_under(voice_root: &Path, id: &str) -> Result<Session> {
if id.is_empty() {
bail!("session id cannot be empty");
}
if id.contains('/') || id.contains('\\') || id == "." || id == ".." {
bail!("session id must not contain path separators: {id:?}");
}
let paths = SessionPaths::under(voice_root, id);
std::fs::create_dir_all(&paths.root)
.with_context(|| format!("creating session directory at {}", paths.root.display()))?;
for p in [&paths.transcript, &paths.events, &paths.log] {
if !p.exists() {
OpenOptions::new()
.create(true)
.append(true)
.open(p)
.with_context(|| format!("creating {}", p.display()))?;
}
}
let meta = if paths.meta.exists() {
read_meta(&paths.meta)?
} else {
let m = SessionMeta::default();
write_meta(&paths.meta, &m)?;
m
};
Ok(Session { paths, meta })
}
pub fn read_meta(path: &Path) -> Result<SessionMeta> {
let body = std::fs::read_to_string(path)
.with_context(|| format!("reading session meta at {}", path.display()))?;
serde_yaml::from_str(&body)
.with_context(|| format!("parsing session meta at {}", path.display()))
}
pub fn write_meta(path: &Path, meta: &SessionMeta) -> Result<()> {
let body = serde_yaml::to_string(meta).context("serialising session meta to YAML")?;
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, body.as_bytes())
.with_context(|| format!("writing temp meta at {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming temp meta to {}", path.display()))?;
Ok(())
}
pub fn read_transcript(path: &Path) -> Result<Vec<TranscriptEvent>> {
let file =
File::open(path).with_context(|| format!("opening transcript at {}", path.display()))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("reading {}:{}", path.display(), idx + 1))?;
if line.trim().is_empty() {
continue;
}
let event: TranscriptEvent = serde_json::from_str(&line)
.with_context(|| format!("parsing {}:{}", path.display(), idx + 1))?;
events.push(event);
}
Ok(events)
}
pub fn read_transcript_finals_after(
path: &Path,
after: Option<EventId>,
) -> Result<Vec<TranscriptEvent>> {
let all = read_transcript(path)?;
let finals: Vec<TranscriptEvent> = all
.into_iter()
.filter(|e| matches!(e, TranscriptEvent::Final { .. }))
.collect();
match after {
None => Ok(finals),
Some(target) => {
let pos = finals.iter().position(|e| match e {
TranscriptEvent::Final { event_id, .. } => *event_id == target,
_ => false,
});
match pos {
Some(idx) => Ok(finals.into_iter().skip(idx + 1).collect()),
None => bail!(
"last_reflected_event_id {target} not found in transcript at {}; \
meta.yaml may be inconsistent with transcript.jsonl",
path.display()
),
}
}
}
}
pub fn read_events(path: &Path) -> Result<Vec<Event>> {
if !path.exists() {
return Ok(Vec::new());
}
let file =
File::open(path).with_context(|| format!("opening events log at {}", path.display()))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("reading {}:{}", path.display(), idx + 1))?;
if line.trim().is_empty() {
continue;
}
let event: Event = serde_json::from_str(&line)
.with_context(|| format!("parsing {}:{}", path.display(), idx + 1))?;
events.push(event);
}
Ok(events)
}
pub fn append_events(path: &Path, events: &[Event]) -> Result<()> {
if events.is_empty() {
return Ok(());
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening events log for append at {}", path.display()))?;
let mut writer = BufWriter::new(file);
for e in events {
serde_json::to_writer(&mut writer, e)
.with_context(|| format!("serialising event to {}", path.display()))?;
writer
.write_all(b"\n")
.with_context(|| format!("appending newline to {}", path.display()))?;
}
writer
.flush()
.with_context(|| format!("flushing events log at {}", path.display()))?;
Ok(())
}
pub fn append_log_line(path: &Path, line: &str) -> Result<()> {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening reflections log at {}", path.display()))?;
let mut writer = BufWriter::new(file);
writer
.write_all(line.as_bytes())
.with_context(|| format!("writing log line to {}", path.display()))?;
if !line.ends_with('\n') {
writer
.write_all(b"\n")
.with_context(|| format!("appending newline to {}", path.display()))?;
}
writer
.flush()
.with_context(|| format!("flushing reflections log at {}", path.display()))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::events::{
EventKind, ItemClass, ItemCreate, Provenance, ReflectionId, TranscriptSpan,
};
use crate::voice::transcriber::EndpointKind;
use chrono::TimeZone;
use tempfile::TempDir;
fn fixed_ts() -> chrono::DateTime<chrono::Utc> {
chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}
fn provenance() -> Provenance {
Provenance {
transcript_span: Some(TranscriptSpan {
start_event_id: ulid::Ulid::from_parts(0, 1),
end_event_id: ulid::Ulid::from_parts(0, 2),
}),
model: Some("m".into()),
prompt_version: Some("p".into()),
}
}
fn make_event(event_id: u128) -> Event {
Event {
event_id: ulid::Ulid::from_parts(0, event_id),
ts: fixed_ts(),
reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
provenance: provenance(),
kind: EventKind::ItemCreate(ItemCreate {
item_id: ulid::Ulid::from_parts(0, 500),
class: ItemClass::Todo,
text: format!("event {event_id}"),
priority: None,
valid_until: None,
tags: None,
}),
}
}
fn make_final(event_id: u128, text: &str) -> TranscriptEvent {
TranscriptEvent::Final {
event_id: ulid::Ulid::from_parts(0, event_id),
text: text.to_string(),
start: Duration::from_millis(0),
end: Duration::from_millis(100),
confidence: 0.9,
words: None,
speaker: None,
revisable: false,
}
}
#[test]
fn open_or_create_bootstraps_an_empty_session() {
let tmp = TempDir::new().unwrap();
let session = open_or_create_under(tmp.path(), "s1").unwrap();
assert!(session.paths.meta.exists());
assert!(session.paths.transcript.exists());
assert!(session.paths.events.exists());
assert!(session.paths.log.exists());
assert_eq!(session.meta, SessionMeta::default());
}
#[test]
fn open_or_create_is_idempotent() {
let tmp = TempDir::new().unwrap();
let s1 = open_or_create_under(tmp.path(), "s1").unwrap();
let s2 = open_or_create_under(tmp.path(), "s1").unwrap();
assert_eq!(s1.meta, s2.meta);
}
#[test]
fn open_or_create_preserves_existing_meta() {
let tmp = TempDir::new().unwrap();
let mut s = open_or_create_under(tmp.path(), "s1").unwrap();
s.set_last_reflected(ulid::Ulid::from_parts(0, 42)).unwrap();
let reopened = open_or_create_under(tmp.path(), "s1").unwrap();
assert_eq!(
reopened.meta.last_reflected_event_id,
Some(ulid::Ulid::from_parts(0, 42))
);
}
#[test]
fn rejects_session_id_with_path_separator() {
let tmp = TempDir::new().unwrap();
assert!(open_or_create_under(tmp.path(), "a/b").is_err());
assert!(open_or_create_under(tmp.path(), "a\\b").is_err());
assert!(open_or_create_under(tmp.path(), "..").is_err());
assert!(open_or_create_under(tmp.path(), ".").is_err());
assert!(open_or_create_under(tmp.path(), "").is_err());
}
#[test]
fn ttl_defaults_match_799_defaults() {
let t = TtlDefaults::default();
assert_eq!(t.todo, Duration::from_secs(7 * 86_400));
assert_eq!(t.research, Duration::from_secs(30 * 86_400));
assert_eq!(t.question, Duration::from_secs(14 * 86_400));
}
#[test]
fn meta_yaml_round_trip_preserves_optional_marker() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("meta.yaml");
let meta = SessionMeta {
last_reflected_event_id: Some(ulid::Ulid::from_parts(0, 7)),
ttl_defaults: TtlDefaults::default(),
};
write_meta(&path, &meta).unwrap();
let back = read_meta(&path).unwrap();
assert_eq!(meta, back);
}
#[test]
fn append_then_read_events_round_trips() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("events.jsonl");
append_events(&path, &[make_event(1), make_event(2)]).unwrap();
let back = read_events(&path).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back[0], make_event(1));
assert_eq!(back[1], make_event(2));
}
#[test]
fn append_events_with_empty_slice_is_noop() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("events.jsonl");
append_events(&path, &[]).unwrap();
assert!(!path.exists());
}
#[test]
fn read_events_on_missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let result = read_events(&tmp.path().join("nothing.jsonl")).unwrap();
assert!(result.is_empty());
}
#[test]
fn read_transcript_finals_after_filters_partials_and_endpoints() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("transcript.jsonl");
std::fs::write(
&path,
format!(
"{}\n{}\n{}\n",
serde_json::to_string(&TranscriptEvent::Partial {
text: "ignored".into(),
start: Duration::ZERO,
end: Duration::from_millis(50),
words: None,
speaker: None,
})
.unwrap(),
serde_json::to_string(&make_final(1, "first")).unwrap(),
serde_json::to_string(&TranscriptEvent::Endpoint {
at: Duration::from_secs(1),
kind: EndpointKind::StreamEnd,
})
.unwrap(),
),
)
.unwrap();
let finals = read_transcript_finals_after(&path, None).unwrap();
assert_eq!(finals.len(), 1);
}
#[test]
fn read_transcript_finals_after_skips_through_marker() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("transcript.jsonl");
let lines = [
serde_json::to_string(&make_final(1, "a")).unwrap(),
serde_json::to_string(&make_final(2, "b")).unwrap(),
serde_json::to_string(&make_final(3, "c")).unwrap(),
];
std::fs::write(&path, lines.join("\n")).unwrap();
let after_id = ulid::Ulid::from_parts(0, 2);
let finals = read_transcript_finals_after(&path, Some(after_id)).unwrap();
assert_eq!(finals.len(), 1);
match &finals[0] {
TranscriptEvent::Final { text, .. } => assert_eq!(text, "c"),
other => panic!("expected Final, got {other:?}"),
}
}
#[test]
fn read_transcript_finals_after_errors_when_marker_missing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("transcript.jsonl");
std::fs::write(
&path,
serde_json::to_string(&make_final(1, "a")).unwrap() + "\n",
)
.unwrap();
let err =
read_transcript_finals_after(&path, Some(ulid::Ulid::from_parts(0, 99))).unwrap_err();
assert!(
err.to_string().contains("not found in transcript"),
"got: {err}"
);
}
#[test]
fn append_log_line_creates_file_and_adds_newline() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("reflections.log");
append_log_line(&path, "first entry").unwrap();
append_log_line(&path, "second entry\n").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "first entry\nsecond entry\n");
}
#[test]
fn read_transcript_skips_blank_lines() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("transcript.jsonl");
std::fs::write(
&path,
format!(
"\n{}\n\n \n{}\n",
serde_json::to_string(&make_final(1, "a")).unwrap(),
serde_json::to_string(&make_final(2, "b")).unwrap(),
),
)
.unwrap();
let events = read_transcript(&path).unwrap();
assert_eq!(events.len(), 2);
}
#[test]
fn read_transcript_reports_parse_failure_with_line_number() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("transcript.jsonl");
let good = serde_json::to_string(&make_final(1, "ok")).unwrap();
std::fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
let err = read_transcript(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("parsing") && msg.contains(":2"),
"error should point at line 2: {msg}"
);
}
#[test]
fn read_events_skips_blank_lines() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("events.jsonl");
append_events(&path, &[make_event(1)]).unwrap();
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(f, "\n ").unwrap();
drop(f);
let events = read_events(&path).unwrap();
assert_eq!(events.len(), 1);
}
#[test]
fn read_events_reports_parse_failure_with_line_number() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("events.jsonl");
std::fs::write(&path, "not valid json at all\n").unwrap();
let err = read_events(&path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("parsing") && msg.contains(":1"),
"error should point at line 1: {msg}"
);
}
#[test]
fn voice_root_respects_override_env_var() {
let original = std::env::var("OMNI_DEV_VOICE_ROOT").ok();
std::env::set_var("OMNI_DEV_VOICE_ROOT", "/tmp/overridden");
let root = voice_root().unwrap();
assert_eq!(root, PathBuf::from("/tmp/overridden"));
match original {
Some(v) => std::env::set_var("OMNI_DEV_VOICE_ROOT", v),
None => std::env::remove_var("OMNI_DEV_VOICE_ROOT"),
}
}
}