Skip to content

Plan: the session API behind the window

Updated View as Markdown

Goal: every verb the new window needs exists in Rust first, with a CLI form and a JSON form, so the web page (next plan) is a view over functions that are already tested. Architecture: src/api.rs holds one dispatcher, call(method, params, root), that the MCP verb and the window bridge both route through (ADR 0017). New capabilities (search, session metadata, delete, export formats) are session:: functions with unit tests, then one match arm each in api.rs, then a CLI verb. Tech Stack: Rust 1.85 floor, serde/serde_json, no new crates.

Global Constraints

  • This plan’s branch is cut from the MCP plan’s branch and lands after the session-tools and MCP plans (src/mcp.rs, mcp::handle, ADR 0016 and session::{summaries, transcript_appended, live_session_in, undo_last_naming} are on that branch, not yet on main). A task that finds one missing stops and reports it; it does not reimplement it.
  • Sessions are shared with the transcriber, the window and ambient mcp in another process. One lock protects a session directory: session::claim_transcription(dir) (src/session.rs:456, the hard-link lock the transcriber takes for the whole of transcribe_inner). The protocol: the session functions that write (diarize_session, name_speaker, undo_last_naming, and this plan’s update_meta, delete) stay lock-free themselves, because the transcriber calls diarize_session while already holding the lock; every entry point that calls one of them (each api::call method, each CLI arm, the window’s diarize worker) claims the lock around the call and fails with the lock’s message when a live transcriber holds it. Task 4 brings the existing diarize, name and undo CLI arms under this rule. A check followed by an unlocked write is not enough.
  • raw.jsonl is never rewritten. Metadata edits rewrite session.json only; speaker edits append to edits.jsonl.
  • Every api::call method has a JSON example in docs/developing/api.md (created by Task 1) and a test in src/api.rs that feeds that exact JSON.
  • Tests use a directory unique to the test: std::env::temp_dir().join(format!("ambient-{}-{}", <test fn name>, std::process::id())).
  • scripts/check does not look at docs; after any docs edit run node .github/scripts/check-links.mjs and quote … 0 broken link(s).
  • No .github/workflows/, .claude/, or loop/ changes; no editing existing tests.

Task 1: One dispatcher for the window and MCP

Files:

  • Create: src/api.rs; register pub mod api; in src/lib.rs before writing the test
  • Modify: src/mcp.rs (the tools/call arm routes to api::call; the tool descriptors move to api::methods())
  • Create: docs/developing/api.md (frontmatter title: "The session API", sidebar.order: 29)

Interfaces:

  • Produces: pub struct Method { pub name: &'static str, pub description: &'static str, pub input_schema: serde_json::Value }; pub fn methods() -> Vec<Method>; pub struct Paths { pub config_file: PathBuf, pub roster_file: PathBuf } with Paths::root(&self) -> PathBuf, which reads sessions_dir from Config::load_from(&self.config_file) on every call (so a config.set of sessions_dir moves the next sessions/search/delete with it; the window bridge and ambient mcp hold a Paths for their whole life); pub fn call(method: &str, params: &serde_json::Value, paths: &Paths) -> Result<serde_json::Value, ApiError> (the signature is fixed here; later tasks add methods, never arguments) with pub enum ApiError { InvalidParams(String), Failed(String) } (InvalidParams is what MCP maps to -32602, Failed to isError: true). Methods at the end of this task: sessions, transcript (session, since, verbatim), status, exactly the shapes the MCP plan defines; restated for the window’s sake: transcript replies {session, state: "live"|"transcribing"|"pending"|"done", next: <append-order line count>, lines: [Line…]} and a live session without raw.jsonl yet is state: "live", lines: [], not an error.

  • Step 1: write the failing tests in src/api.rs: methods() has three entries named sessions, transcript, status; call("status", {}, &paths) with a temp config file whose sessions_dir is a temp root with one session dir holding session.json and transcript.md returns {"live":null,"awaiting_transcript":0,"sessions":1}; call("transcript", {"session":"x","since":"1"}, &paths) is Err(InvalidParams); call("nope", {}, &paths) is Err(InvalidParams) mentioning nope.

  • Step 2: run cargo test api::, expect FAIL “cannot find function”.

  • Step 3: move the tool bodies out of src/mcp.rs into api.rs, leaving mcp.rs to parse JSON-RPC and map ApiError; mcp::serve builds Paths from config::path(), roster::path() and the sessions root. cargo test mcp:: must still pass unchanged.

  • Step 4: write docs/developing/api.md: one section per method with the request and reply JSON; say that ambient mcp and the window both call it. Run scripts/check, expect CHECK OK; run node .github/scripts/check-links.mjs, expect 0 broken link(s) (ADR 0017 links ADR 0016, which this branch carries).

  • Step 5: commit: git add src/api.rs src/lib.rs src/mcp.rs docs/developing/api.md && git commit -m "api: one dispatcher behind mcp and the window".

Task 2: Search across sessions

Files:

  • Modify: src/session.rs (new pub struct Hit, pub fn search(root: &Path, query: &str, limit: usize) -> Result<Vec<Hit>> after transcript_appended)
  • Modify: src/api.rs (method search with query string required, limit integer default 50), docs/developing/api.md
  • Modify: src/main.rs (new search arm: ambient search <query> [--json]), USAGE; docs/using/commands.md (new ## search)

Interfaces:

  • Produces: #[derive(Serialize)] pub struct Hit { pub session: String, pub index: usize, pub track: Track, pub start_ms: u64, pub speaker: Option<String>, pub text: String } where index is the line’s position in transcript_appended order, which is the order the transcript API method returns and the window renders without re-sorting (the ui-shell plan’s Task 3 states the same); session::transcript’s start_ms sort is not used anywhere the index is; matching is case-insensitive substring over the folded (non-verbatim) text; sessions are searched newest first and the result is truncated at limit.

  • Step 1: write the failing test: temp root with two sessions whose raw.jsonl lines contain “Budget review” (newest) and “budget” (older) and one unrelated line; search(root, "BUDGET", 50) returns two hits, newest session first, each with the right index, checked by indexing transcript_appended(dir, false) with it and getting the same text (with a room line appended after a call line that starts earlier, so append order and time order differ); search(root, "zzz", 50) is empty; limit: 1 returns one; an empty query is an error “empty query”.

  • Step 2: run cargo test session::search, expect FAIL “cannot find function”.

  • Step 3: implement; add the api method (a query that is not a string → InvalidParams) and its test; the CLI prints session mm:ss speaker text per hit or the JSON array.

  • Step 4: run scripts/check, expect CHECK OK; cargo run -- search the and quote the first line; node .github/scripts/check-links.mjs, expect 0 broken link(s).

  • Step 5: commit: git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "search: substring over every transcript".

Task 3: Session metadata: name, tags, notes, pinned

Files:

  • Modify: src/session.rs:91-121 (SessionMeta gains #[serde(default)] pub tags: Vec<String>, #[serde(default)] pub notes: String, #[serde(default)] pub pinned: bool)
  • Modify: src/session.rs (new pub fn update_meta(dir: &Path, patch: &MetaPatch) -> Result<SessionMeta>; summaries gains tags, pinned fields and sorts pinned sessions first)
  • Modify: src/api.rs (method session.update with session required and optional name, notes, pinned, add_tag, remove_tag), docs/developing/api.md
  • Modify: src/main.rs (new meta arm: ambient meta <dir> name|notes|pinned|tag|untag <value>), USAGE; docs/using/commands.md

Interfaces:

  • Produces: #[derive(Deserialize, Default)] pub struct MetaPatch { pub name: Option<String>, pub notes: Option<String>, pub pinned: Option<bool>, pub add_tag: Option<String>, pub remove_tag: Option<String> } (tags change one at a time under the lock, so two clients adding different tags both keep theirs; add_tag of a tag already present and remove_tag of one absent are no-ops); update_meta reads session.json, applies the Some fields, writes it back atomically (write to session.json.tmp, rename) and returns the new meta; the api method and the CLI arm wrap it in claim_transcription(dir) (so two callers serialise and a running transcriber refuses the edit with “being transcribed”); a session without session.json (a live capture) is an error “session is still recording”. SessionSummary gains pub tags: Vec<String>, pub pinned: bool.

  • Step 1: write the failing tests: update_meta on a temp session sets name and leaves duration_s untouched; a second call with add_tag: Some("1:1") keeps the name, a third with add_tag: Some("budget") keeps both, remove_tag: Some("1:1") leaves ["budget"]; pinned: true makes that session first in summaries even when older; a dir without session.json errors “still recording”; the api method with a transcribing.lock naming this test’s own pid errors “being transcribed” and session.json is unchanged; session.json loaded from before this change (no tags key) parses with defaults.

  • Step 2: run cargo test session::meta, expect FAIL “cannot find”.

  • Step 3: implement, add the api method and CLI verb (tag/untag take one tag, pinned takes true/false via the same flag parser config uses).

  • Step 4: run scripts/check, expect CHECK OK; node .github/scripts/check-links.mjs, expect 0 broken link(s).

  • Step 5: commit: git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "session: name, tags, notes, pinned".

Task 4: Delete a session

Files:

  • Modify: src/session.rs (new pub fn delete(root: &Path, id: &str) -> Result<()> next to sweep_audio_at, src/session.rs:992)
  • Modify: src/api.rs (method session.delete with session required), docs/developing/api.md
  • Modify: src/main.rs (new delete arm: ambient delete <dir> --yes; the existing diarize, name and undo arms claim claim_transcription(dir) around their session call), USAGE; docs/using/commands.md; docs/using/what-is-kept.md (one sentence: delete removes the directory, retention only removes audio)

Interfaces:

  • Produces: delete(root, id, lock: &TranscribeLock) validates the id (a single non-symlink directory under root, the MCP plan’s rule) and takes proof of the lock rather than claiming it, so it follows the protocol above: the api method and the CLI arm claim claim_transcription(dir) once (a live transcriber, diarizer or namer makes that fail “is being transcribed by another process”, and none can start while it is held) and pass the guard in. It then checks for a live capture: live_session_in(root) names this dir, or either native track (audio/room.native.wav, audio/call.native.wav) was modified in the last 5 s (“session is still recording”; a quiet microphone stops the room file growing while the call file still does). A directory with no session.json and no fresh audio on either track is a failed capture and is deleted like any other. Then std::fs::remove_dir_all while the caller still holds the lock (the lock file goes with the directory; TranscribeLock::drop tolerates a missing file). The CLI refuses without --yes.

  • Step 1: write the failing tests: delete removes a finished temp session; removes a directory with no session.json and both native tracks last modified an hour ago (a failed capture); refuses one with a fresh audio/room.native.wav; refuses one whose room file is an hour old but whose audio/call.native.wav is fresh (a live call with a quiet room); the api method against a session whose transcribing.lock names this test’s pid errors “being transcribed” and leaves the directory intact, and against a finished session succeeds (one claim, no self-conflict); refuses "../x"; refuses a symlinked entry; the CLI arm without --yes prints “add –yes” and exits non-zero (test the pure argument check as a function).

  • Step 2: run cargo test session::delete, expect FAIL “cannot find function”.

  • Step 3: implement, add the api method (the window shows its own confirm dialog; the API does not ask) and the CLI verb.

  • Step 4: run scripts/check, expect CHECK OK; node .github/scripts/check-links.mjs, expect 0 broken link(s).

  • Step 5: commit: git add src/session.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md docs/using/what-is-kept.md && git commit -m "delete: remove a session, never a live one; writers take the lock".

Task 5: Export formats

Files:

  • Create: src/export.rs; register pub mod export; in src/lib.rs before writing the test
  • Modify: src/api.rs (method export with session required, format one of markdown|text|json|srt|vtt|assistant), docs/developing/api.md
  • Modify: src/main.rs:119-140 (export gains --format <f>; default stays markdown to transcript.md), docs/using/commands.md (the export section)

Interfaces:

  • Consumes: session::transcript(dir, false) -> Vec<Line>, session::markdown(dir) (src/session.rs:1503), SessionMeta.

  • Produces: pub enum Format { Markdown, Text, Json, Srt, Vtt, Assistant } with FromStr; pub fn render(dir: &Path, format: Format) -> Result<String>: Markdown is session::markdown unchanged; Text is speaker: text per line, blank line between tracks changes; Json is the Line array; Srt/Vtt number the lines and use start_ms/end_ms as HH:MM:SS,mmm / HH:MM:SS.mmm with the speaker as a prefix; Assistant is the markdown with a first line # <name or id> (<started_at>, <duration>) and a last line Read live with the ambient MCP server: session id <id>.

  • Step 1: write the failing tests: a temp session with two lines (room 0–1500 “hello” by “Ana”, call 1500–4000 “hi”) renders Srt as 1\n00:00:00,000 --> 00:00:01,500\nAna: hello\n\n2\n00:00:01,500 --> 00:00:04,000\nhi\n; Vtt starts with WEBVTT\n\n and uses .; Json round-trips through serde_json to two objects; "xml".parse::<Format>() is an error listing the six names.

  • Step 2: run cargo test export::, expect FAIL “unresolved import”.

  • Step 3: implement, add the api method and the --format flag (--out keeps working; with a non-markdown format the default output path swaps the extension).

  • Step 4: run scripts/check, expect CHECK OK; node .github/scripts/check-links.mjs, expect 0 broken link(s).

  • Step 5: commit: git add src/export.rs src/lib.rs src/api.rs src/main.rs docs/developing/api.md docs/using/commands.md && git commit -m "export: text, json, srt, vtt and a copy for an assistant".

Task 6: Config, roster and naming through the API

Files:

  • Modify: src/api.rs (methods config.get, config.set (key, value strings, via Config::set and config::refuse_while_live), roster.list, roster.add, roster.remove, speakers.name (session, label, name), speakers.undo (session), devices (input devices as config prints them)), docs/developing/api.md

Interfaces:

  • Consumes: config::{Config, path, refuse_while_live}, roster::{load_from, save_to, add, remove}, session::{name_speaker, undo_last_naming, unnamed_labels, live_session_in}, and the device listing src/main.rs:403-431 prints (extract it into pub fn input_devices() -> Vec<String> in the module that owns it if it is inline).

  • Produces: every method reads and writes only through Paths: Config::load_from(&paths.config_file) / save_to (src/config.rs:74,103) and roster::load_from(&paths.roster_file) / save_to (src/roster.rs:30,55), never Config::load/save or roster::load/save, which touch the user’s real files; config.get returns the same JSON settings.rs pushes today (src/settings.rs:295-314, minus unnamed, which becomes speakers.unnamed with a session argument; latest_session stays in config.get, the id of the newest finished session or null, so the Settings page can ask about that one without a selection).

  • Step 1: write the failing tests with temp config and roster files: config.set {"key":"diarize","value":"off"} then config.get shows diarize: false; config.set of sessions_dir while a temp live session exists is Err(Failed) mentioning “recording”; config.set {"key":"sessions_dir","value":<other temp root>} (no live session) makes the next sessions list that root’s sessions; roster.add {"name":"Ana"} then roster.list is ["Ana"]; speakers.name on a session with SPEAKER_00 lines returns {"renamed": 2} and speakers.undo returns {"reverted": 2}; an unknown key in config.set is Err(InvalidParams); after the whole test the real config::path() and roster::path() files have the same bytes (or absence) as before it.

  • Step 2: run cargo test api::, expect FAIL on the new methods.

  • Step 3: implement; document every method in docs/developing/api.md.

  • Step 4: run scripts/check, expect CHECK OK; node .github/scripts/check-links.mjs, expect 0 broken link(s).

  • Step 5: commit: git add src/api.rs src/mcp.rs src/main.rs docs/developing/api.md && git commit -m "api: config, roster and naming" (add any module Step 3 touched).

Open Questions

Navigation

Type to search…

↑↓ navigate↵ selectEsc close