Skip to content

Commit 2bc6983

Browse files
alexkromanclaude
andauthored
Add --save-dir finalization: auto-name, note, and sidecar (#200)
Implements the post-streaming finalization for `assembly stream --save-dir`: auto-naming recordings from transcript content, writing LLM-generated notes, and creating metadata sidecars. ## Summary This PR completes the `--save-dir` feature by adding three finalization steps that run after streaming ends: 1. **Auto-naming** (`--auto-name`): Derives a short title from the transcript via the LLM and renames the provisional timestamp-only files to include that slug (e.g., `2026-06-16-143005-quarterly-review.txt`). 2. **Note writing** (`--llm` + `--save-dir`): Writes the final LLM answer as a `.md` file alongside the transcript. 3. **Sidecar metadata** (always): Creates a `.aai.json` file with title, date, duration, speaker list, turn count, and file references — enabling rich list/browse UIs without parsing transcripts. ## Key Changes - **New module `aai_cli/streaming/savedir.py`**: Core finalization logic - `SaveDirPlan`: Immutable dataclass capturing the resolved `--save-dir` intent - `derive_title()`: Calls the LLM to generate a short headline from the transcript - `write_outputs()`: Orchestrates the rename, note write, and sidecar creation - Error handling wraps OSError as clean `CLIError` with `save_dir_path` type - **New module `aai_cli/streaming/batch.py`**: Extracted batch streaming logic - `stream_batch_sources()`: Drives sequential streaming of stdin sources - Moved from `session.py` to keep session focused on single-run state - Handles per-source failures and Ctrl-C/pipe cleanup - **Updated `aai_cli/streaming/session.py`**: - Added `save_plan`, `_meta_lines`, `_meta_speakers`, `_capture_start`, `_last_answer` fields to track metadata for finalization - `_note_meta()`: Records finalized turn text and speaker labels for the sidecar - `_finalize_save_dir()`: Calls `derive_title()` (when `--auto-name` and transcript non-empty) and `write_outputs()` with collected metadata - Graceful error handling: failed title derivation warns but still saves the recording - **Updated `aai_cli/streaming/naming.py`**: - `SavePaths` refactored from two fields (`transcript`, `audio`) to computed properties (`transcript`, `audio`, `note`, `sidecar`) derived from `directory` and `stem` - Added `SIDECAR_SUFFIX` constant (`.aai.json`) - **Updated `aai_cli/commands/stream/_exec.py`**: - Added `auto_name` and `no_save_audio` flags to `StreamOptions` - `_resolve_save_targets()` now returns a `SaveDirPlan` as the third element - Validation: `--auto-name` and `--no-save-audio` require `--save-dir`; `--auto-name` and `--name` are mutually exclusive - Batch import moved to `aai_cli/streaming/batch.py` - **Test infrastructure**: - New `tests/_stream_helpers.py`: Shared fakes (`FakeMic`, `RecordingMic`, `FakeTurn`, `emit_turns`, `FixedDatetime`, `DEFAULTS`) used by both `test_stream_exec.py` and new test files - New `tests/test_streaming_savedir.py`: 218 lines of unit tests for `write_outputs`, `derive_title`, and error handling (pure file I/O, LLM mocked) - New `tests/test_stream_save_dir.py`: End-to-end tests of `--save-dir` through `run_stream` (real session + savedir, LLM mocked) - Updated `tests/test_stream_exec.py`: https://claude.ai/code/session_01KNx966tACLPYX4B5jkcfqp Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff4ea78 commit 2bc6983

19 files changed

Lines changed: 1600 additions & 370 deletions

REFERENCE.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,26 @@ object per dataset (not NDJSON; a single dataset is therefore one object):
116116
the row's `llm` key (the WER score still uses the raw transcript), and
117117
`--llm-reduce` runs one prompt over every item's result and adds a top-level
118118
`reduce` (`{"model","prompts","output"}`) to the object.
119+
120+
## Recording streams to disk
121+
122+
`assembly stream --save-dir DIR` auto-names a capture under `DIR/YYYY-MM-DD/`
123+
with a timestamped stem (`YYYY-MM-DD-HHMMSS[-slug]`) shared across every file it
124+
writes:
125+
126+
- `<stem>.txt` — the transcript, one finalized turn per line (flushed live).
127+
- `<stem>.wav` — the recorded audio, 16-bit mono PCM. Suppress it with
128+
`--no-save-audio` to keep only the text. Under `--system-audio` the two channels
129+
can't share a file, so each gets its own `<stem>-you.wav` / `<stem>-system.wav`.
130+
- `<stem>.md` — written when `--llm "…"` is also passed: the final answer of the
131+
live prompt chain, captured as a note next to the transcript.
132+
- `<stem>.aai.json` — a metadata sidecar so a list/browse UI needs no transcript
133+
parsing: `{"title", "date", "duration_seconds", "speakers", "turns",
134+
"transcript", "audio", "note"}`. `audio` is the list of WAV file names (empty
135+
under `--no-save-audio`, two entries under `--system-audio`); `note` is `null`
136+
when no `--llm` note was written.
137+
138+
`--name "Title"` slugs an explicit title into the stem; `--auto-name` instead
139+
derives that title from the transcript via the LLM Gateway once the stream ends,
140+
renaming the files to match (the timestamp stem is kept if the title is empty).
141+
The two are mutually exclusive.

aai_cli/commands/agent/_exec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
from aai_cli.app.context import AppState
2525
from aai_cli.core import choices, client, errors, signals
2626
from aai_cli.core.errors import UsageError
27-
from aai_cli.streaming.session import resolve_output_modes
2827
from aai_cli.streaming.sources import FileSource
28+
from aai_cli.streaming.validate import resolve_output_modes
2929
from aai_cli.ui import output
3030

3131

aai_cli/commands/agent_cascade/_exec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from aai_cli.core import choices, client, config_builder, errors, llm, signals
2626
from aai_cli.core.errors import UsageError
2727
from aai_cli.streaming import turn_presets
28-
from aai_cli.streaming.session import resolve_output_modes
2928
from aai_cli.streaming.sources import FileSource
29+
from aai_cli.streaming.validate import resolve_output_modes
3030
from aai_cli.tts import session as tts_session
3131
from aai_cli.ui import output
3232

aai_cli/commands/dictate/_exec.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from aai_cli.core.config_builder import split_csv
2020
from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys
2121
from aai_cli.core.microphone import MicrophoneSource
22-
from aai_cli.streaming.session import resolve_output_modes
22+
from aai_cli.streaming.validate import resolve_output_modes
2323
from aai_cli.ui import output
2424

2525
# Capture is resampled to one rate the Sync API accepts; 16 kHz mono PCM16 keeps

aai_cli/commands/stream/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"Auto-name the transcript + WAV under a dir",
4040
'assembly stream --save-dir ~/recordings --name "Standup"',
4141
),
42+
(
43+
"Name from content + save a summary note",
44+
'assembly stream --save-dir ~/recordings --auto-name --llm "summarize as a note"',
45+
),
4246
(
4347
"Boost domain terms with keyterm prompts",
4448
'assembly stream --keyterms-prompt "AssemblyAI" --keyterms-prompt "Claude"',
@@ -121,6 +125,18 @@ def stream(
121125
help="Title to slug into the --save-dir filename (e.g. a meeting title)",
122126
rich_help_panel=help_panels.OPT_SAVING,
123127
),
128+
auto_name: bool = typer.Option(
129+
False,
130+
"--auto-name",
131+
help="With --save-dir, derive the filename from the transcript via the LLM",
132+
rich_help_panel=help_panels.OPT_SAVING,
133+
),
134+
no_save_audio: bool = typer.Option(
135+
False,
136+
"--no-save-audio",
137+
help="With --save-dir, skip the WAV and save only the transcript",
138+
rich_help_panel=help_panels.OPT_SAVING,
139+
),
124140
# model & input
125141
speech_model: SpeechModel = typer.Option(
126142
DEFAULT_SPEECH_MODEL,
@@ -398,5 +414,7 @@ def stream(
398414
save_transcript=save_transcript,
399415
save_dir=save_dir,
400416
name=name,
417+
auto_name=auto_name,
418+
no_save_audio=no_save_audio,
401419
)
402420
run_with_options(ctx, stream_exec.run_stream, opts, json=json_out)

aai_cli/commands/stream/_exec.py

Lines changed: 82 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,14 @@
2323
from aai_cli.core import choices, client, config_builder, signals, stdio, youtube
2424
from aai_cli.core.errors import UsageError, mutually_exclusive
2525
from aai_cli.core.microphone import MicrophoneSource
26-
from aai_cli.streaming import naming, record, transcript, turn_presets
26+
from aai_cli.streaming import naming, record, savedir, transcript, turn_presets
27+
from aai_cli.streaming.batch import stream_batch_sources
2728
from aai_cli.streaming.macos import MacSystemAudioSource
2829
from aai_cli.streaming.render import StreamRenderer
29-
from aai_cli.streaming.session import (
30-
SourceOptions,
31-
StreamSession,
32-
resolve_output_modes,
33-
stream_batch_sources,
34-
validate_sources,
35-
)
30+
from aai_cli.streaming.session import StreamSession
3631
from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource
3732
from aai_cli.streaming.turn_presets import TurnDetectionPreset
33+
from aai_cli.streaming.validate import SourceOptions, resolve_output_modes, validate_sources
3834
from aai_cli.ui import output
3935
from aai_cli.ui.follow import FollowRenderer
4036

@@ -90,6 +86,8 @@ class StreamOptions:
9086
save_transcript: Path | None
9187
save_dir: Path | None
9288
name: str | None
89+
auto_name: bool
90+
no_save_audio: bool
9391

9492
def source_options(self) -> SourceOptions:
9593
"""The audio-input subset, in the shape the validation/dispatch helpers read."""
@@ -205,57 +203,97 @@ class SaveTargets:
205203
``audio`` tees a single source to one WAV; ``audio_by_label`` instead maps each
206204
parallel ``--system-audio`` channel ("you", "system") to its own WAV when the two
207205
streams can't share a file. At most one of the two is set; ``transcript`` is the
208-
single shared transcript either way.
206+
single shared transcript either way. ``plan`` is set only under ``--save-dir`` and
207+
carries the post-stream finalization (auto-name rename, ``--llm`` note, sidecar).
209208
"""
210209

211210
transcript: Path | None = None
212211
audio: Path | None = None
213212
audio_by_label: dict[str, Path] | None = None
213+
plan: savedir.SaveDirPlan | None = None
214+
215+
216+
def _save_dir_targets(opts: StreamOptions, sources: SourceOptions, save_dir: Path) -> SaveTargets:
217+
"""Resolve ``--save-dir`` into auto-named targets plus the finalization plan.
218+
219+
``--save-dir`` owns filename assembly, so it rejects the explicit
220+
``--save-audio``/``--save-transcript`` paths and the conflicting ``--name``/
221+
``--auto-name`` title pair. Two parallel ``--system-audio`` streams can't tee to one
222+
WAV, so each channel gets its own ``<stem>-{you,system}.wav`` (one shared transcript);
223+
``--no-save-audio`` drops the WAV(s) entirely.
224+
"""
225+
mutually_exclusive(
226+
("--save-dir", True),
227+
("--save-audio", opts.save_audio is not None),
228+
("--save-transcript", opts.save_transcript is not None),
229+
suggestion="--save-dir names the files for you; drop the explicit path.",
230+
)
231+
mutually_exclusive(
232+
("--name", opts.name is not None),
233+
("--auto-name", opts.auto_name),
234+
suggestion="Both set the title — pass --name for an explicit one or "
235+
"--auto-name to derive it from the transcript.",
236+
)
237+
# Local wall-clock time (what a meeting filename wants); the explicit utc-then-
238+
# astimezone keeps the now() call timezone-aware for the linter.
239+
now = datetime.now(UTC).astimezone()
240+
plan = savedir.SaveDirPlan(
241+
save_dir=save_dir,
242+
now=now,
243+
name=opts.name,
244+
auto_name=opts.auto_name,
245+
write_note=bool(opts.llm_prompt),
246+
)
247+
paths = plan.paths
248+
naming.ensure_dir(paths.directory)
249+
if opts.no_save_audio:
250+
# Transcript + sidecar (+ note) only; no WAV teed for any source.
251+
return SaveTargets(transcript=paths.transcript, plan=plan)
252+
if sources.system_audio:
253+
# Parallel mic + system: one WAV per channel beside the shared transcript.
254+
return SaveTargets(
255+
transcript=paths.transcript,
256+
audio_by_label={
257+
"you": naming.channel_audio(paths.audio, "you"),
258+
"system": naming.channel_audio(paths.audio, "system"),
259+
},
260+
plan=plan,
261+
)
262+
if sources.system_audio_only:
263+
# A lone system-audio stream; label its single WAV so it reads like the pair.
264+
return SaveTargets(
265+
transcript=paths.transcript,
266+
audio=naming.channel_audio(paths.audio, "system"),
267+
plan=plan,
268+
)
269+
return SaveTargets(transcript=paths.transcript, audio=paths.audio, plan=plan)
214270

215271

216272
def _resolve_save_targets(opts: StreamOptions, sources: SourceOptions) -> SaveTargets:
217273
"""Resolve the save flags into the destinations the session writes.
218274
219-
``--save-dir`` owns filename assembly — it auto-names the transcript and a matching
220-
WAV under ``DIR/YYYY-MM-DD/`` — so it can't be combined with the explicit
221-
``--save-audio``/``--save-transcript`` paths, and ``--name`` only feeds that assembly.
222-
Two parallel ``--system-audio`` streams can't tee to one WAV, so under ``--save-dir``
223-
each channel gets its own ``<stem>-{you,system}.wav`` (one shared transcript), and the
224-
explicit single-path ``--save-audio`` is rejected outright.
275+
``--save-dir`` owns filename assembly (see ``_save_dir_targets``); the explicit
276+
``--save-audio``/``--save-transcript`` paths are the fallback, with the save-dir-only
277+
``--name``/``--auto-name``/``--no-save-audio`` flags rejected outside it.
225278
"""
226279
if opts.save_dir is not None:
227-
mutually_exclusive(
228-
("--save-dir", True),
229-
("--save-audio", opts.save_audio is not None),
230-
("--save-transcript", opts.save_transcript is not None),
231-
suggestion="--save-dir names the files for you; drop the explicit path.",
232-
)
233-
# Local wall-clock time (what a meeting filename wants); the explicit utc-then-
234-
# astimezone keeps the now() call timezone-aware for the linter.
235-
now = datetime.now(UTC).astimezone()
236-
paths = naming.resolve(opts.save_dir, opts.name, now=now)
237-
naming.ensure_dir(paths.transcript.parent)
238-
if sources.system_audio:
239-
# Parallel mic + system: one WAV per channel beside the shared transcript.
240-
return SaveTargets(
241-
transcript=paths.transcript,
242-
audio_by_label={
243-
"you": naming.channel_audio(paths.audio, "you"),
244-
"system": naming.channel_audio(paths.audio, "system"),
245-
},
246-
)
247-
if sources.system_audio_only:
248-
# A lone system-audio stream; label its single WAV so it reads like the pair.
249-
return SaveTargets(
250-
transcript=paths.transcript, audio=naming.channel_audio(paths.audio, "system")
251-
)
252-
return SaveTargets(transcript=paths.transcript, audio=paths.audio)
280+
return _save_dir_targets(opts, sources, opts.save_dir)
253281
if opts.name is not None:
254282
raise UsageError(
255283
"--name applies only with --save-dir.",
256284
suggestion="Pass --save-dir DIR to auto-name the files, "
257285
"or --save-transcript PATH for an explicit path.",
258286
)
287+
if opts.auto_name:
288+
raise UsageError(
289+
"--auto-name applies only with --save-dir.",
290+
suggestion="Pass --save-dir DIR so there's an auto-named file to title.",
291+
)
292+
if opts.no_save_audio:
293+
raise UsageError(
294+
"--no-save-audio applies only with --save-dir.",
295+
suggestion="Omit --save-audio to skip the WAV, or pass --save-dir DIR.",
296+
)
259297
if opts.save_audio is not None:
260298
if sources.system_audio:
261299
raise UsageError(
@@ -343,6 +381,8 @@ def _collect_batch_sources(opts: StreamOptions, *, text_mode: bool) -> list[str]
343381
("--save-transcript", opts.save_transcript is not None),
344382
("--save-dir", opts.save_dir is not None),
345383
("--name", opts.name is not None),
384+
("--auto-name", opts.auto_name),
385+
("--no-save-audio", opts.no_save_audio),
346386
suggestion="--from-stdin streams many sources; saving applies to a single run.",
347387
)
348388
mutually_exclusive(
@@ -434,6 +474,7 @@ def run_stream(opts: StreamOptions, state: AppState, *, json_mode: bool) -> None
434474
save_audio=targets.audio,
435475
save_audio_by_label=targets.audio_by_label,
436476
save_transcript=targets.transcript,
477+
save_plan=targets.plan,
437478
llm_interval=opts.llm_interval,
438479
)
439480
with signals.terminate_as_interrupt():

aai_cli/streaming/batch.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Drive a ``assembly stream --from-stdin`` list of sources, one realtime session each.
2+
3+
The realtime API is one session at a time, so a list of files/URLs (read on stdin,
4+
one per line) streams sequentially. This lives beside ``StreamSession`` rather than
5+
inside it: a session owns *one* run, while this owns the sequence — fresh session per
6+
source, per-source failure accounting, and the batch-wide Ctrl-C/pipe handling.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from collections.abc import Callable, Iterable
12+
13+
import typer
14+
15+
from aai_cli.core.errors import CANCELLED_EXIT_CODE, CLIError, NotAuthenticated
16+
from aai_cli.streaming.render import StreamRenderer
17+
from aai_cli.streaming.session import StreamSession
18+
from aai_cli.ui import output
19+
20+
# A batch source string resolved to its real-time audio chunks and declared rate.
21+
_OpenedSource = tuple[Iterable[bytes], int]
22+
23+
24+
def _stream_source(
25+
source: str,
26+
*,
27+
index: int,
28+
total: int,
29+
make_session: Callable[[], StreamSession],
30+
open_source: Callable[[str], _OpenedSource],
31+
renderer: StreamRenderer,
32+
json_mode: bool,
33+
) -> bool:
34+
"""Stream one batch source in its own session; return True when it failed.
35+
36+
A ``CLIError`` (bad path, missing ffmpeg, decode failure) is recorded as a warning
37+
so the batch carries on — except ``NotAuthenticated``, which re-raises to abort the
38+
whole batch (one rejected key fails every source identically, and auto-login should
39+
trigger once).
40+
"""
41+
renderer.source(source, index=index, total=total)
42+
try:
43+
audio, rate = open_source(source)
44+
# handle_interrupt=False: let a Ctrl-C/pipe close bubble to the batch loop below so
45+
# one interrupt stops the whole sequence. Flipping it to True is behavior-equivalent
46+
# here (the session would convert the same interrupt to the same Exit(130)/Exit(0)),
47+
# so no test can distinguish it.
48+
make_session().run(audio, rate, handle_interrupt=False) # pragma: no mutate
49+
except NotAuthenticated:
50+
raise
51+
except CLIError as exc:
52+
# Flatten newlines so a crafted path/URL can't inject extra log lines (CR/LF).
53+
detail = f"{source}: {exc.message}".replace("\n", " ").replace("\r", " ")
54+
output.emit_warning(detail, json_mode=json_mode)
55+
return True
56+
else:
57+
return False
58+
59+
60+
def stream_batch_sources(
61+
sources: list[str],
62+
*,
63+
make_session: Callable[[], StreamSession],
64+
open_source: Callable[[str], _OpenedSource],
65+
renderer: StreamRenderer,
66+
json_mode: bool,
67+
) -> None:
68+
"""Stream each source in ``sources`` in turn — the ``assembly stream --from-stdin``
69+
batch mode.
70+
71+
The realtime API is one session at a time, so a list of files/URLs streams
72+
sequentially: each source gets a fresh ``StreamSession`` from ``make_session`` (its
73+
own transcript and ``--llm`` chain state) via ``_stream_source``.
74+
75+
A Ctrl-C stops the batch with the cancel code (exit 130); a closed downstream pipe
76+
stops it quietly (exit 0). When any source failed, raises a ``CLIError`` at the end
77+
so a script can trust the exit code.
78+
"""
79+
total = len(sources)
80+
failures = 0
81+
try:
82+
for index, source in enumerate(sources, start=1):
83+
failures += _stream_source(
84+
source,
85+
index=index,
86+
total=total,
87+
make_session=make_session,
88+
open_source=open_source,
89+
renderer=renderer,
90+
json_mode=json_mode,
91+
)
92+
except KeyboardInterrupt:
93+
# One Ctrl-C stops the whole batch, not just the current source. Exit 130
94+
# (cancel) so the interrupt isn't mistaken for a clean run of every source.
95+
renderer.stopped()
96+
raise typer.Exit(code=CANCELLED_EXIT_CODE) from None
97+
except BrokenPipeError:
98+
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
99+
raise typer.Exit(code=0) from None
100+
finally:
101+
renderer.close()
102+
if failures:
103+
raise CLIError(
104+
f"{failures} of {total} sources failed.",
105+
error_type="batch_failed",
106+
suggestion="Check each failed path or URL, then re-run.",
107+
)

0 commit comments

Comments
 (0)