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 andsession::{summaries, transcript_appended, live_session_in, undo_last_naming}are on that branch, not yet onmain). 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 mcpin 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 oftranscribe_inner). The protocol: the session functions that write (diarize_session,name_speaker,undo_last_naming, and this plan’supdate_meta,delete) stay lock-free themselves, because the transcriber callsdiarize_sessionwhile already holding the lock; every entry point that calls one of them (eachapi::callmethod, 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 existingdiarize,nameandundoCLI arms under this rule. A check followed by an unlocked write is not enough. raw.jsonlis never rewritten. Metadata edits rewritesession.jsononly; speaker edits append toedits.jsonl.- Every
api::callmethod has a JSON example indocs/developing/api.md(created by Task 1) and a test insrc/api.rsthat 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/checkdoes not look at docs; after any docs edit runnode .github/scripts/check-links.mjsand quote… 0 broken link(s).- No
.github/workflows/,.claude/, orloop/changes; no editing existing tests.
Task 1: One dispatcher for the window and MCP
Files:
- Create:
src/api.rs; registerpub mod api;insrc/lib.rsbefore writing the test - Modify:
src/mcp.rs(thetools/callarm routes toapi::call; the tool descriptors move toapi::methods()) - Create:
docs/developing/api.md(frontmattertitle: "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 }withPaths::root(&self) -> PathBuf, which readssessions_dirfromConfig::load_from(&self.config_file)on every call (so aconfig.setofsessions_dirmoves the nextsessions/search/deletewith it; the window bridge andambient mcphold aPathsfor 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) withpub enum ApiError { InvalidParams(String), Failed(String) }(InvalidParamsis what MCP maps to-32602,FailedtoisError: 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:transcriptreplies{session, state: "live"|"transcribing"|"pending"|"done", next: <append-order line count>, lines: [Line…]}and alivesession withoutraw.jsonlyet isstate: "live",lines: [], not an error. -
Step 1: write the failing tests in
src/api.rs:methods()has three entries namedsessions,transcript,status;call("status", {}, &paths)with a temp config file whosesessions_diris a temp root with one session dir holdingsession.jsonandtranscript.mdreturns{"live":null,"awaiting_transcript":0,"sessions":1};call("transcript", {"session":"x","since":"1"}, &paths)isErr(InvalidParams);call("nope", {}, &paths)isErr(InvalidParams)mentioningnope. -
Step 2: run
cargo test api::, expect FAIL “cannot find function”. -
Step 3: move the tool bodies out of
src/mcp.rsintoapi.rs, leavingmcp.rsto parse JSON-RPC and mapApiError;mcp::servebuildsPathsfromconfig::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 thatambient mcpand the window both call it. Runscripts/check, expectCHECK OK; runnode .github/scripts/check-links.mjs, expect0 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(newpub struct Hit,pub fn search(root: &Path, query: &str, limit: usize) -> Result<Vec<Hit>>aftertranscript_appended) - Modify:
src/api.rs(methodsearchwithquerystring required,limitinteger default 50),docs/developing/api.md - Modify:
src/main.rs(newsearcharm: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 }whereindexis the line’s position intranscript_appendedorder, which is the order thetranscriptAPI method returns and the window renders without re-sorting (the ui-shell plan’s Task 3 states the same);session::transcript’sstart_mssort 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 atlimit. -
Step 1: write the failing test: temp root with two sessions whose
raw.jsonllines contain “Budget review” (newest) and “budget” (older) and one unrelated line;search(root, "BUDGET", 50)returns two hits, newest session first, each with the rightindex, checked by indexingtranscript_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: 1returns 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
apimethod (aquerythat is not a string →InvalidParams) and its test; the CLI printssession mm:ss speaker textper hit or the JSON array. -
Step 4: run
scripts/check, expectCHECK OK;cargo run -- search theand quote the first line;node .github/scripts/check-links.mjs, expect0 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(SessionMetagains#[serde(default)] pub tags: Vec<String>,#[serde(default)] pub notes: String,#[serde(default)] pub pinned: bool) - Modify:
src/session.rs(newpub fn update_meta(dir: &Path, patch: &MetaPatch) -> Result<SessionMeta>;summariesgainstags,pinnedfields and sorts pinned sessions first) - Modify:
src/api.rs(methodsession.updatewithsessionrequired and optionalname,notes,pinned,add_tag,remove_tag),docs/developing/api.md - Modify:
src/main.rs(newmetaarm: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_tagof a tag already present andremove_tagof one absent are no-ops);update_metareadssession.json, applies theSomefields, writes it back atomically (write tosession.json.tmp, rename) and returns the new meta; theapimethod and the CLI arm wrap it inclaim_transcription(dir)(so two callers serialise and a running transcriber refuses the edit with “being transcribed”); a session withoutsession.json(a live capture) is an error “session is still recording”.SessionSummarygainspub tags: Vec<String>, pub pinned: bool. -
Step 1: write the failing tests:
update_metaon a temp session setsnameand leavesduration_suntouched; a second call withadd_tag: Some("1:1")keeps the name, a third withadd_tag: Some("budget")keeps both,remove_tag: Some("1:1")leaves["budget"];pinned: truemakes that session first insummarieseven when older; a dir withoutsession.jsonerrors “still recording”; theapimethod with atranscribing.locknaming this test’s own pid errors “being transcribed” andsession.jsonis unchanged;session.jsonloaded from before this change (notagskey) parses with defaults. -
Step 2: run
cargo test session::meta, expect FAIL “cannot find”. -
Step 3: implement, add the
apimethod and CLI verb (tag/untagtake one tag,pinnedtakestrue/falsevia the sameflagparserconfiguses). -
Step 4: run
scripts/check, expectCHECK OK;node .github/scripts/check-links.mjs, expect0 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(newpub fn delete(root: &Path, id: &str) -> Result<()>next tosweep_audio_at,src/session.rs:992) - Modify:
src/api.rs(methodsession.deletewithsessionrequired),docs/developing/api.md - Modify:
src/main.rs(newdeletearm:ambient delete <dir> --yes; the existingdiarize,nameandundoarms claimclaim_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 underroot, the MCP plan’s rule) and takes proof of the lock rather than claiming it, so it follows the protocol above: theapimethod and the CLI arm claimclaim_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 nosession.jsonand no fresh audio on either track is a failed capture and is deleted like any other. Thenstd::fs::remove_dir_allwhile the caller still holds the lock (the lock file goes with the directory;TranscribeLock::droptolerates a missing file). The CLI refuses without--yes. -
Step 1: write the failing tests:
deleteremoves a finished temp session; removes a directory with nosession.jsonand both native tracks last modified an hour ago (a failed capture); refuses one with a freshaudio/room.native.wav; refuses one whose room file is an hour old but whoseaudio/call.native.wavis fresh (a live call with a quiet room); theapimethod against a session whosetranscribing.locknames 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--yesprints “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
apimethod (the window shows its own confirm dialog; the API does not ask) and the CLI verb. -
Step 4: run
scripts/check, expectCHECK OK;node .github/scripts/check-links.mjs, expect0 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; registerpub mod export;insrc/lib.rsbefore writing the test - Modify:
src/api.rs(methodexportwithsessionrequired,formatone ofmarkdown|text|json|srt|vtt|assistant),docs/developing/api.md - Modify:
src/main.rs:119-140(exportgains--format <f>; default stays markdown totranscript.md),docs/using/commands.md(theexportsection)
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 }withFromStr;pub fn render(dir: &Path, format: Format) -> Result<String>:Markdownissession::markdownunchanged;Textisspeaker: textper line, blank line between tracks changes;Jsonis theLinearray;Srt/Vttnumber the lines and usestart_ms/end_msasHH:MM:SS,mmm/HH:MM:SS.mmmwith the speaker as a prefix;Assistantis the markdown with a first line# <name or id> (<started_at>, <duration>)and a last lineRead live with the ambient MCP server: session id <id>. -
Step 1: write the failing tests: a temp session with two lines (
room0–1500 “hello” by “Ana”,call1500–4000 “hi”) rendersSrtas1\n00:00:00,000 --> 00:00:01,500\nAna: hello\n\n2\n00:00:01,500 --> 00:00:04,000\nhi\n;Vttstarts withWEBVTT\n\nand uses.;Jsonround-trips throughserde_jsonto 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
apimethod and the--formatflag (--outkeeps working; with a non-markdown format the default output path swaps the extension). -
Step 4: run
scripts/check, expectCHECK OK;node .github/scripts/check-links.mjs, expect0 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(methodsconfig.get,config.set(key,valuestrings, viaConfig::setandconfig::refuse_while_live),roster.list,roster.add,roster.remove,speakers.name(session,label,name),speakers.undo(session),devices(input devices asconfigprints 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 listingsrc/main.rs:403-431prints (extract it intopub 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) androster::load_from(&paths.roster_file)/save_to(src/roster.rs:30,55), neverConfig::load/saveorroster::load/save, which touch the user’s real files;config.getreturns the same JSONsettings.rspushes today (src/settings.rs:295-314, minusunnamed, which becomesspeakers.unnamedwith asessionargument;latest_sessionstays inconfig.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"}thenconfig.getshowsdiarize: false;config.setofsessions_dirwhile a temp live session exists isErr(Failed)mentioning “recording”;config.set{"key":"sessions_dir","value":<other temp root>}(no live session) makes the nextsessionslist that root’s sessions;roster.add{"name":"Ana"}thenroster.listis["Ana"];speakers.nameon a session withSPEAKER_00lines returns{"renamed": 2}andspeakers.undoreturns{"reverted": 2}; an unknownkeyinconfig.setisErr(InvalidParams); after the whole test the realconfig::path()androster::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, expectCHECK OK;node .github/scripts/check-links.mjs, expect0 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).