Skip to content

Keep every Teams call the owner is in from its live captions - #16

Merged
SychPL merged 57 commits into
mainfrom
feat/captions-archive
Nov 10, 2026
Merged

SychPL merged 57 commits into
mainfrom
feat/captions-archive

Conversation

@SychPL

@SychPL SychPL commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Change

The owner wants every Teams call they are in written down privately on this computer, so they can come back to what was said later through an agent (word against word; a meeting whose data and tasks were not noted). This builds that from live captions:

  • Teams module (modules/teams/src/live.rs, captions.rs, manifest.rs):
    • New teams_list_calls tool: lists the user's running calls from the call store, as a chat thread, the 1:1 chat or call:<id>.
    • teams_join_captions joins any of these calls, with a required call. Before joining it reads the roster: the user must be connected on another device, and allowTranscription: false gives policy.
    • Up to 3 sessions per account.
    • Turns captions on when nobody has them on: it adds the combined bot via addParticipantAndModality / addParticipant with botData, or sends start on the bot's commandUrl. It never does so again in a call where captions were already on.
    • Recognises e2ee, lobby and policy refusals from the docs 10.8 codes, and has describe-target for call:<id>.
    • teams_read_captions adds call, captions_seen, skipped, threads, cue own and at in milliseconds.
  • Core archiver (crates/open-mess/src/recording/captions.rs, archive.rs): turned on by a new switch in the window, off by default.
    • It joins every listed call and stores its captions in the recordings store as a captions recording (captions.jsonl, gaps.jsonl, coverage).
    • Rejoin, retry, block and restart rules follow spec 4.4.
    • The threads read check of spec 4.2 is in tools/extra.rs.
  • Tools: list_recordings and get_recording_transcript read captions recordings under the conversation's read right (every thread). stop_recording takes recording and returns stopped.
  • Window, tray and toast: the switch and the captions language; the list with source and coverage; "Pokaż" with gaps; a "Zapisuję napisy: N rozmów" indicator with "Zatrzymaj wszystkie"; a toast with "Zatrzymaj".
  • AGENTS.md: the second-device exception now also covers archive sessions.

Not verified live yet (spec 0b series 2 and 3, step 5):

  • the data-only join of a 1:1 call;
  • the requests that turn captions on; some fields of the bot body are the client's names from static analysis;
  • what others see.

The owner decided on 2026-11-09 to ship before those series. Step 5 is the live acceptance test.

Spec

.specs/2026-11-04-captions-archive.md, rounds R1-R10 with Codex and GLM; the owner approved it after R10. Step 0a (static REA, docs 10.8) and series 1 of step 0b (live reads only, "Live results" 20) are recorded. The owner's decision of 2026-11-09 to build now is in section 2.

Tests

  • Module: unit tests in captions.rs, and host tests in crates/open-mess-host/tests/teams/main.rs against the fake server with the shapes from Live results 18-20, covering:
    • the call list;
    • joining by call;
    • the pre-join roster;
    • several sessions per account;
    • turning captions on, and never sending setLanguage or stop;
    • refusal codes.
  • Core: 34 tests in crates/open-mess/tests/captions_archive.rs, with test-echo standing in for the Teams module and a hand-set clock, plus unit tests in recording/ and window tests in tests/ui.rs. They cover:
    • the switch and its lock;
    • joining, rejoining and retries;
    • Stop with its block surviving a restart;
    • gaps and coverage;
    • torn lines;
    • limits and rollover;
    • permissions over every thread, including the escalated 1:1 case.

cargo xtask ci on Windows at f6119ba (the module merged into the core branch): green. The tree is unchanged since then, apart from AGENTS.md (6681fc3) and the merge of main, whose tree is byte-identical.

Commit reviews

Every commit reviewed by Codex and GLM and reconciled in .reviews/<sha>.processed.

Coexistence (platform modules only)

open_mess joins as a separate device of the owner (own endpoint id), data channel only, muted, never reads or writes the official client's data, never calls read receipts. The official client shows the owner on another device while a session runs (AGENTS.md exception). Turning captions on adds the Teams captions bot, as the client does. Not yet checked side by side live with the new join paths (step 5).

Checklist

  • No secrets, tokens or real message contents in code, fixtures, logs or this PR
  • No unwrap / expect / panic! / unchecked indexing in non-test code
  • New tool available over both MCP and the CLI
  • New dependencies named here, with their licence and why (none)

SychPL added 30 commits October 28, 2026 21:03
The owner decided on 2026-10-28 (decision D1 of the stage 2 meetings spec)
that open_mess should transcribe calls that have no Teams transcript. This
spec settles how: the daemon notices that Teams holds the microphone (the
Windows consent store), records the Teams process's loopback audio and the
microphone in shared mode, and after the call turns them into text with
Whisper large-v3-turbo on candle, inside the daemon, on the CPU. Audio lives
in %LOCALAPPDATA% only until it is transcribed; the text stays until the
owner deletes it. Agents read transcripts through three core tools under a
reserved `local` read permission; only the window can enable recording,
change settings or delete.

Reviewed by Codex and GLM for 10 rounds; GLM approved, and the last Codex
P2 (no visible indicator with a hidden tray icon, muted notifications and
no microphone track) is a rebuttal the owner accepted.
The review of f42c13f found that a release dropping a model revision from the allowed list would delete the only model on disk at start and stop recording until the owner downloads again. The list now only grows, and the daemon deletes a model folder only after a newer revision downloaded or on the owner's click.
Step 1 of the local transcription spec (.specs/2026-10-28-local-transcription.md,
4.4): each recording is a folder under %LOCALAPPDATA%\open_mess\recordings with
meta.json, the tracks' WAV files until transcription, and transcript.json. The
data folder may roam to a server, so nothing of a recording goes there.

Crash safety comes from the order of writes and a recovery pass at start: a
recording the daemon left gets valid WAV headers and waits marked interrupted,
a transcription that wrote its transcript is done, leftover audio of a done
recording goes. Audio not transcribed within 7 days is deleted, and the size
and disk limits refuse a new recording. Ids are checked before they touch a
path, and the WAV reader takes only the header open_mess writes, with every
size checked against the file. Nothing calls this yet; detection, capture and
transcription come in the next steps.
Review of 82a012c (Codex and GLM P2): a track shorter than its header (the daemon stopped as the track was created) made the recovery fail and left the recording in state recording forever, outside retention and inside the audio limit. Such a track holds no audio and is now deleted; a recording with no track left is failed, and the writer puts the header on disk at once. The end of a left recording is also read from the tracks' last write before the header fix, so the 7-day retention no longer restarts at the daemon's restart.
Review of 167d79f (Codex and GLM P2): recovery deleted a track whenever fixing its header failed, so a recording another program held open at start could lose hours of audio. Only a file shorter than the WAV header (no samples) is deleted now; any other error leaves the recording as it is for the next start, with a regression test.
Review of 704af6a (Codex P2): a recording whose track another program held at start stayed in state recording until the next daemon start, outside retention. recover now takes the recording and transcription running now and leaves them alone, so the daily pass with expire can run it too and pick the recording up once the file is free.
Step 2 of the local transcription spec (.specs/2026-10-28-local-transcription.md,
4.3 and 4.7), the owner's choice of engine: Whisper large-v3-turbo in pure Rust,
in the daemon, on the CPU.

model.rs pins revision 41f01f3f of openai/whisper-large-v3-turbo (MIT) and
reads each file whole against its SHA-256 and size; the weights go to candle
from that buffer, since candle maps files only as unsafe. stt.rs cuts each
track into 30 s windows, skips silent ones (Whisper invents text over
silence), detects the language per track unless one is set, and decodes with
Whisper's timestamp rules and temperature fallback, including the
compression-ratio check against looping text. The model's text is untrusted:
cues and transcripts are capped, and a cancel flag is checked before the
encoder and after each decoder step. isolated() runs a transcription on its
own thread with half the cores, so a panic in candle ends as an error, not
in the daemon.

Tests run every step on a tiny zero-weight model and decode real tokens of
the model's tokenizer, so CI downloads nothing. cargo deny: candle's gemm
uses the unmaintained paste, and candle holds zip at 8, whose advisories are
about extract, which neither candle nor open_mess calls; both are ignored
with reasons. Nothing calls this yet; the queue and the daemon come next.
Review of 826ea0c (Codex P2): the decoder stopped at half of Whisper's 448 decoder positions, so the end of fast speech in a 30 s window was lost without a mark. It now decodes until the positions run out, and a window cut at the limit marks its last piece, as the spec's caps require. The pieces of a window come from one function now, with a test.
Step 3 of the local transcription spec (.specs/2026-10-28-local-transcription.md,
4.5): list_recordings and get_recording_transcript, the same over MCP and the
CLI. They answer under the read permission of the reserved account `local`,
which no real account (`<module>:<account>`) can be; the permission is read
before the files and again before the answer, so taking it back mid-call lets
nothing out. The transcript comes in pages by size, its text under `untrusted`
with the notice; ids are checked before any path, and a recording not done
says so.

The window shows a "Nagrania lokalne" row with one read box, even with no
account; its write permission is stored as never whatever is sent, and it has
no conversations. The daemon reads recordings from %LOCALAPPDATA% where
updates install (Windows); elsewhere the tools say they are not supported.
stop_recording and the status object come with the capture, which they control.
Step 4 of the local transcription spec (.specs/2026-10-28-local-transcription.md,
4.6 and 4.7). The new Nagrywanie rozmów section shows the model and offers
"Pobierz model" and "Usuń model"; only the window starts a download, nothing
else of the recording reaches the network.

A revision downloads file by file into its folder under %LOCALAPPDATA%, each
streamed through the update downloader's checks: HTTPS, Hugging Face and its
CDN hosts only (a `*.hf.co` entry, since the weights redirect to regional
hosts), every redirect checked, the exact size and SHA-256 compiled in, three
hours for the 1.6 GB weights. A failed download leaves no folder; a whole one
replaces the other listed revisions. Files are now pinned by exact size, also
when they are read for a transcription. The update downloader's per-file loop
became the shared download_file it uses too.

The recording settings and the list of recordings join the section with the
recording itself.
Review of 2771cef (Codex P2): a daemon stopped during the model download left an incomplete revision folder and its .part file, up to 1.6 GB, for good. At start, a listed revision's folder without all its files or with a .part file is deleted; unlisted folders are left alone. GLM P3: tests for the *.hf.co host rule and for the window's 'newer model' flag.
The live check of step 0 showed that Windows no longer writes the
ConsentStore times on the owner's computer and that process loopback of
Teams gives silence during a call, while the loopback of the headset
carries it. The spec now detects a call by the audio sessions of
ms-teams.exe, records the other side from the device Teams plays the
call on for the whole call, and records the microphone only while it is
on in Teams.

The CPU measurement of step 0e is in too: the spec's path needs about
72 minutes for an hour of one track. On the owner's decision the
transcription gets its own copy of the Whisper network, with a decoder
that keeps its keys and values and a cancel check between encoder
layers, and packs only speech into the model's windows. Rounds R11-R15
of the spec review are in its changelog; the owner chose to implement
without further rounds.

docs/protocols/teams.md gets section 10 with the redacted step 0
results: audio sessions, loopback, model download hosts and the CPU
numbers.
list_recordings returned at most the 500 newest recordings, and since
only filters newer ones, so an older recording could not be reached.
It now takes before, the last recording of the previous page, as
read_messages does.

The language of a transcript was written only to meta.json, after
transcript.json, so a crash between the two left a done recording with
no language. transcript.json now carries it too, and the repair at start
copies it into meta.json.
…yers

candle's Whisper runs the decoder's whole token sequence again at every
step and has no way to stop inside the encoder. On the owner's computer
that made a 30 s window take about 33 s, and a call that started during
a transcription waited for a whole encoder pass, about 9.5 s, while the
spec allows 5.

recording/whisper.rs is a copy of candle-transformers 0.11's Whisper
network (MIT OR Apache-2.0) with two changes: the decoder keeps the keys
and values of the tokens it has seen, and the encoder checks the cancel
flag between its layers. Weight names and shapes are unchanged, so the
same model file loads. Measured on the pinned model: a 30 s window in
22.8 s instead of 32.8 s with the same text, and a cancel during the
encoder returns after 0.11 s. A test checks that the kept keys give the
same output as running the whole sequence.
A track was cut into 30 s windows and a window was skipped only when it
was silent as a whole. In a call each track is silent while the other
side speaks, but the turns are shorter than a window, so in a ten-minute
two-track dialogue all 40 windows went through the encoder although
less than half of the audio was speech.

The speech is now found in 30 ms frames, kept with 300 ms around it and
pauses under a second joined, and packed into windows of at most 30 s
with 200 ms of silence between pieces; the same dialogue needs 16
windows. Times in a window are mapped back to the track through the
piece they fall in, and a time from the model that lies in a pause or
past the pieces moves to the nearest piece, so a cue stays on speech.
The model's times are untrusted, so the mapping saturates instead of
overflowing on a huge one.
Recordings in the waiting state had nothing to transcribe them. A queue
thread in the daemon now takes them one at a time, the oldest first,
and only while nothing records: a recording that starts cancels the
running transcription, which goes back to waiting and starts over after
the call. Reading the model checks the cancel flag every 64 MiB, so a
call waits a fraction of a second even then. The model is loaded for
each recording and dropped after it, so the daemon's memory comes back
between recordings. A transcription starts only with the model on disk
and 6.6 GB of memory free (the measured peak and 2 GB), because a
failed allocation ends the process.

A panic or an error fails only its recording; a model that fails its
checksum leaves the recording waiting and holds the queue until the
model is downloaded again. Deleting a recording stops its transcription
first, so no file of the folder is open when it goes. The same thread
runs the retention pass every hour. list_recordings shows the progress
of the running transcription.

The measured result of the whole new path goes into
docs/protocols/teams.md: a ten-minute two-track call in 418 s, about 42
minutes per hour.
Finding the speech reads the whole track before the first window, and
packing reads through the silence between pieces; neither looked at the
cancel flag. With a six-hour track that kept the transcription busy for
seconds after a call started, where the spec allows a fraction of one.
Both now check it every 30 s of audio. Codex's review of 49e06a3.
Step 0 showed that the ConsentStore times are not written on the owner's
computer, while the audio sessions of ms-teams.exe follow a call
closely: active during it, inactive while muted, removed about 180 s
after the hang-up. recording/detect.rs reads those sessions on every
active device (wasapi, safe functions; process names from sysinfo) and
turns the reads into a call: it starts on an active session, lasts while
a session is listed, and ends 15 s after none is. A read that fails is
skipped; reads failing for a minute make detection unavailable and end
the call. The 30-minute cap on a quiet call waits for step 0b, as the
spec says.

The logic takes the reads and the time, so it is tested without
Windows; an ignored test reads the live sessions of the computer it
runs on.
The ignored test that reads this computer's audio sessions printed its
counts, which the workspace lints refuse; 88a1690 went in without a
green cargo xtask ci because of it. It now asserts what it read instead.
recording/capture.rs records the device Teams plays the call on, by
loopback, and the microphone Teams uses, each into its own 16 kHz mono
track (spec 4.2). A track is written by the clock rather than by the
packets: what did not arrive is silence and what came past the clock
is cut, so both tracks keep the recording's length and their times
agree, also when a device is quiet for minutes. The microphone track
gets silence while Teams has the microphone muted, and what the
microphone heard then is dropped in memory.

The capture follows the devices the detection reports when Teams moves
to another one, opens a device again after an error, and leaves a track
silent after three failed tries 20 s apart. Devices are reached through
a trait, so the logic is tested with fakes; the WASAPI side opens shared
streams only. An ignored test reads this computer's loopback briefly.
… a failed read

Reviews of 3c5f2d8 and 88a1690:

- Any InvalidData from a transcription was taken for a damaged model, so
  a recording with a bad WAV header left itself waiting and held the
  whole queue (GLM P1). Only the model's own size or checksum check now
  reports a damaged model (SttError::Damaged); a bad track fails its
  recording, as the spec says, and the queue goes on.
- Nothing cleared the damaged state after the model was downloaded
  again (Codex P2). The gate now holds only while the weights are the
  ones that failed, so a new download lets transcriptions start.
- A transcription that came to nothing (stopped, a meta.json that cannot
  be written) was started again at once, in a tight loop (GLM P2). The
  queue now waits for a change, as it does when empty, and shows the
  reason meanwhile.
- When the process list could not be read, the Teams sessions came back
  as none, which ended a call that went on (Codex P2). A lookup that
  reads none of the processes asked for is now a failed read, which the
  detection skips and reports only after a minute. The capture uses the
  same lookup.
recording/recorder.rs takes the detection's read every second and runs
a recording's life (spec 4.1, 4.2). A recording starts while a call is
on, a Teams session is active, the automatic mode is on, the call was
not stopped, the tray is up, the model is there and the disk and the
limits allow it; otherwise the owner is told once per call why it is
not recorded. It ends with the call, a stop, six hours, less than 2 GB
free, the detection lost or the capture failing, each with its reason,
and the recording then waits for the transcription queue.

A stopped call is not recorded again until it ends, also across a
restart of the daemon (skip.json, kept while Teams lists sessions at
start). Turning the automatic mode off ends a recording without that.

The owner sees it in toasts under their own AppUserModelID,
open_mess.recording, whose buttons Zatrzymaj and Zatrzymaj i usuń act on
the recording they name only, and in the tray: "open_mess - nagrywanie"
and a Zatrzymaj nagrywanie item while a call is recorded. Agents get
stop_recording, under the read permission of local, and status gets the
recording object, with the reason the queue waits (Codex on 3c5f2d8).
Outside, the recorder reaches the settings, the disk, the capture and
the notices through hooks, so its logic is tested without Windows.
The Nagrywanie rozmów section gets what spec 4.6 gives it beside the
model: the automatic mode (off by default, confirmed when turned on),
the microphone and the language, saved through changes_allowed; the
recording going on with its own Zatrzymaj; why calls are not recorded
or transcriptions wait; and the recordings with Pokaż, Usuń, Ponów and
Usuń wszystkie.

Pokaż is the owner's read of a transcript, whatever agents may read, so
a refusal for agents never locks the owner out; the text goes into the
page through textContent only. Deleting stops the recording going on or
its transcription first, and a folder another program holds stays with
an error while the rest go. Ponów puts a failed recording whose audio
is left back in the queue. Usuń model is refused while a call is
recorded or transcribed.
Reviews of b9b8687 and 922ccde:

- A call stopped by hand started recording again when the detection
  failed for a minute and came back (Codex P1): only a working read
  now tells that a call is over.
- A capture that failed or panicked ended the recording, and the next
  read started a new one in the same call, every few seconds with its
  toast (GLM P1). Six hours, a failed capture and a failed write now
  end a recording as a stop does: the rest of that call is not
  recorded (Codex P2, GLM P2).
- A skip.json that could not be written was forgotten; it is written
  again on the next read (Codex P2).
- A track lost after three failed tries stayed silent when Teams moved
  to a working device (Codex P2, GLM P2): another device starts afresh.
- Samples that came after a gap were written right after the silence,
  about 200 ms early; they now end at the clock (Codex P2).
- A device that opens but fails every read never counted as lost; only
  a read that works now resets the count (Codex P2).
Spec 4.8 (and Codex's review of 922ccde): an update stopped the daemon
whatever was being recorded, so the rest of the call was lost.

- Zainstaluj and the toast refuse to start while a call is recorded
  ("aktualizacja po końcu nagrania"); installing on its own waits for
  the call's end like it waits for other work.
- A call recorded during phase A or B stops the installation as a
  cancel does, everything resumed, and the window reads "przerwano:
  nagrywanie rozmowy". Phase A therefore never gives up on its 30
  minutes during a call. Installing on its own may start the same
  release again after the call: its once-only mark is cleared then.
- Once the daemon hands over to the helper, no recording starts; the
  rest of the call is recorded after the restart.

The recorder and the update share an UpdateLink: whether a call is
recorded, and whether the daemon is handing over.
What is recorded and when, where it stays and for how long, who reads
the text, and that telling the participants and following the
organisation's rules is up to the owner (spec 2026-10-28-local-transcription,
section 7).
An ignored test that runs the real detection, capture and toasts in a
temporary folder while the owner is in a Teams call, so the daemon the
owner runs is not touched. It prints only the Teams sessions' states
and each track's level per second, and deletes the recording at the
end: what step 0b needs to settle (the other side heard while muted, a
long mute, joining muted, a device change) without keeping any audio.
…topped

Codex's reviews of db70d3c, fa142e9 and 5d6b1fa:

- A recording could start between the update's last look and its
  handover to the helper, and the daemon then ended in the middle of it
  (P1). UpdateLink is now one atomic state: a recording and a handover
  each claim it, and only one of them can.
- Why phase A or B stopped was read again after resume(), so a call
  that ended meanwhile lost the reason, and a cancel during a call was
  taken for the call's and let installing on its own start the release
  again (P2 x2). The phases now say why when they stop.
- A capture with no track left open ended the recording and skipped the
  rest of the call, which spec 4.2 says it must not (P1). It no longer
  skips; no new recording starts while Teams stays on the device that
  did not open, and one does once Teams moves.
- Deleting the recording going on reported success when its folder
  could not go (P2); the error now reaches the window.
- Usuń model checked for a recording and then deleted, so one could
  start in between (P2). The check and the deletion now run while no
  recording can start; a transcription that finds the model gone waits.
- The window also refreshes while a recording waits (P3).
Codex on 990f2f2: a recording lasts until Teams lets go of its audio,
about three minutes after the hang-up, not the hang-up itself; and an
agent allowed to read the transcripts passes them to its model
provider, so "never sent anywhere" was wrong.
A recording whose tracks could not be opened remembered only the speaker,
and took it from the detection read rather than from the capture. If Teams
moved to other speakers just before that read, the new, working device was
blocked for the rest of the call; and moving only to another microphone never
let the recording start again.

The capture now publishes the devices its dead flags belong to, so a failure
counts only for the devices it happened on, and the block holds the pair of
speaker and recorded microphone.

The live check removes the folders a killed run left behind and says that it
must not run while the daemon records, since both share the toasts' app id.
SychPL added 27 commits November 3, 2026 16:03
The live check removed every folder in %TEMP% whose name started with its
prefix, so a folder of the same family kept on purpose (a backup, say) would
have gone with it. Its temporary folder now sits inside one folder with an
exact name, and only that folder is removed before a run.
The owner decided on 2026-11-03 that a meeting's context is its transcript
and its chat. A recording does not know its meeting, so the recording tools'
descriptions tell the agent how to join them by time: teams_list_meetings for
the meeting that overlaps the recording, then read_messages of its
conversation, or list_conversations and search where the meetings tool is not
offered. No new field and no second way to the chat: it still comes from
read_messages, under untrusted and the Teams account's permissions.

The spec records the decision, the rounds R16-R19 with their fixes (the
measured 42 minutes per hour as a known limitation, the retention promise in
line with a transcription that runs at the deadline, the reason set) and the
promised comment against panic = "abort" in the workspace Cargo.toml.
Brings in stages 2 and 3 (meetings, transcripts, live captions), the module
tools, the two-language window and the release pipeline.

The recording side follows them: its window section, toasts and tray menu
now have English beside Polish (spec 2026-10-30-ui-languages), while the
reasons in status and list_recordings stay English for agents and are shown
in the window's language; an update stopped by a call is recorded as an
English outcome like the others. The recording tools' names are reserved
against module tools as the shared ones are. The Windows audio facts move to
section 11 of docs/protocols/teams.md, since section 10 now holds live
captions. The lockfile keeps candle 0.11.0, tokenizers 0.22.2 and zip 8.6.0,
the versions the transcription was measured with and deny.toml covers.
Relabelling the tray read whether a call was recorded and then set the menu,
so a recording that started in between was shown, then overwritten with the
state read before it: no stop item and no recording tooltip for the rest of
the call. The tray's state now sits behind one lock that both the recorder's
indicator and the relabelling hold while they change the tray.

The last window messages of the recordings without an English version
(unknown recording, transcript not ready or damaged, no model to download)
now follow the window's language too.
The tray's lock was held while the menu changed, and Tauri changes a menu on
the main thread: the recorder's indicator, already under the recorder's lock,
held the tray's lock and waited for the main thread, while the main thread,
relabelling after a change of Windows's language, waited for the tray's lock.
The window, the tray and the detection would have stopped for good.

The indicator now only stores whether a call is recorded and posts a redraw
to the main thread; relabelling posts one too. Each redraw reads the language
and the state when it runs, so nobody waits and the last redraw shows the
latest state.
The test helpers from both sides are kept: the sign-in page forwarding from
main, the recordings folder from this branch. The new admin tests build the
window state with the recording fields too.
The owner wants every call he is in - meetings, Meet now, group chat
calls and 1:1 calls someone joined - kept on his own machine, so he can
go back later and know what was said. The archiver in the core joins
each call through the live captions tools, turns captions on when no one
has, and appends every cue with its speaker to the local recordings
store, with gaps marking when it was not listening. Agents read it with
the recording tools under the conversation's read permission.

Reviewed by Codex and GLM over ten rounds; GLM approved the last five,
every finding is fixed, and the owner approved it after round ten.
Specs and docs are Markdown that neither fmt, clippy nor the tests read,
so running the full gate for them only costs time. The owner asked to
exclude them.
Static analysis of the current Teams build (26276) answers what the spec
left open: an item without groupContext in the call store is a 1:1 call,
adding a person keeps the same call and attaches a group thread, and the
client turns captions on by adding the bot with botData or sending it a
start command without a language. The refusal codes for end-to-end
encrypted meetings, the lobby and policy are written down, and roster
endpoints carry media stream types, which tell a data-only leg from a
muted official client. The spec follows these facts; what static
analysis cannot settle moves to the live check of step 0b.
RUSTSEC-2026-0429 is another `ZipArchive::extract` advisory, like the three
already ignored: candle reads zip only for .npz and .pth weights, never
extracts, and open_mess loads safetensors. The fix is in zip 9, which candle
0.11.0 does not allow; the transcription was measured on 0.11.0.
Codex and GLM found that a live read after a third person joins a 1:1
call checked only the new group chat, though the session's buffer still
holds what was said before, so every thread the session has had now
needs the read right. The 1:1 rule follows the client's own test (no
groupContext), and a thread the conversation reports after joining is
added, so a meeting without groupContext never opens more. The bot is
added through addParticipant when the call has no thread, as the client
does, the consent text names the combined recording bot, the live check
covers 1:1 calls, and the protocol notes say that the call store item
of 2026-10-29 has the 1:1 shape. The code context now describes main.
A read-only run over a 1:1 call, the same call after a person was
added, and a scheduled meeting settled what the static analysis of
step 0a left open: the 1:1 item shape, a call id that survives the
escalation, the userJoinTime format (and that it turns null after an
escalation), the bot the official client adds to a 1:1 call, the
commandUrl host, and that reads without a join are not throttled.

The spec now says what series 1 answered and what waits for series 2
and 3, treats allowTranscription null as no refusal, and tells the
captions bot apart by its MRI, since a meeting can hold another 28:
participant.
The owner gave consent for the remaining live series of step 0b once,
and wants steps 2-4 built without waiting for them: series 2 now runs
by itself in the background on the next 1:1 call, and what it does not
answer is left to the acceptance test.
…s-archive-core

# Conflicts:
#	docs/protocols/teams.md
The captions archive (spec 2026-11-04-captions-archive, 4.4-4.6) keeps
each Teams call it hears as a recording of source captions: its cues in
captions.jsonl, word for word, and the times nothing was heard in
gaps.jsonl, beside the archiver's state of the calls in
captions-calls.json with its previous version. A torn last line is cut
before the next append, and a damaged line is skipped and counted, never
a panic.

The recording tools read these recordings in every state, with the
speaker's name, the gaps and the damaged lines, and only under the read
permission of the call's conversation and of each of its threads on an
account that is still there; unreadable recordings are left out before
the list's limit. The audio recordings' start and daily passes leave
captions recordings to the archiver, and their text counts towards the
shared 1 GB limit.
Step 2 of the captions archive spec (.specs/2026-11-04-captions-archive.md,
4.2-4.3): the core's archiver will call the module's tools for every call
the owner is in, so the module has to name those calls, keep a session in
each, and turn captions on when nobody has.

- teams_list_calls lists the call store's calls: a meeting or group call
  by its chat thread, a 1:1 call by the 1:1 chat with the other person,
  anything else as call:<id>. The call is the correlationId; items with
  another id shape or a conversation URL off the service host are left
  out and counted in the log. joined_at stays null until step 0b settles
  what userJoinTime means.
- teams_join_captions takes a required call (and language,
  start_captions) and joins only that call. Before joining it reads the
  call's conversation: allowTranscription false is "policy", and without
  another device of the user with a media stream other than data it is
  "user_not_in_meeting", so nothing reaches the call. The same device
  rule applies to the roster during the session, so a data-only leg of
  another open_mess instance never keeps a session in a call.
- Up to 3 sessions an account, 4 in the module, each a device of its own
  (an endpoint id per slot, so two Trouter sockets never share an epid).
  A join for a running call returns its state instead of joining twice;
  a session of another call in the same conversation ends first; an
  ended session stays readable with its own token for 15 minutes.
- Captions are turned on the way the official client does it (docs
  10.8): 10 s after the data channel opens without the captions bot on,
  the combined bot is added through addParticipantAndModality or, for a
  1:1 call, addParticipant, with botData and a token for the recording
  resource (a new WAM scope), and gets start once it is in the call; a
  bot already there gets start on its commandUrl. At most 4 tries, 5
  minutes apart, and never again once captions were on in the session or
  in that call within the day (a list in module storage). The old
  no_captions endings go: the archive records the gap instead.
- Reads give call, captions_seen, skipped, threads (the call's chat
  threads, for the core's permission check when someone is added to a
  1:1 call), own on each cue and at to the millisecond.
- Join refusals and leg ends are told apart by their codes: e2ee, lobby
  (also a lobby acceptance) and policy.
- describe-target for call:<id> describes the call from its conversation
  (subject, start time, roster) for the approval.

Tests: the pure parts in captions.rs, and the module in the host against
the fake server with the shapes of "Live results" 18-20. The core's window
test counts the Teams manifest's scopes, now six.
…ssion

A join for a call that already has a session returned its state at once and
added the conversation given to the session's threads, without checking
that the conversation is one of that call's names. An approved join under
an unrelated chat's id could so hang that chat's name on another call's
session, and an agent allowed to read only that chat would read the call's
captions with it. The call store is now read first in every join, and the
session is reused only under the call's own chat, its 1:1 chat or
call:<id>; any other name is "that call is no longer running".

Codex review of 7f866d9 (P1).
The owner wants a lasting record of every Teams call he is in (spec
2026-11-04-captions-archive). With the window's new switch on, the core's
archiver lists each account's calls every 20 s through the module's
list_calls, joins each one under the module write guard of approved
actions, reads its captions every 5 s into a captions recording and
leaves it when the owner says "Zatrzymaj". The switch is the standing
consent: off by default, its turning off ends every call kept after a
join running now, and its turning on never brings back what was said
before it.

Every gap is written down: the late start, cues lost from the buffer,
captions turned off, and the time between a session's end and the next
join. Stopped calls, given-up calls and the captions seen stay in
captions-calls.json, so a restart never rejoins a stopped call, and a
lost state joins no call listed at the start. Permanent refusals (E2EE,
lobby, policy) leave a failed recording that says why; passing ones are
tried every 2 minutes, at most 15 times (refused 3). An update, a scan
of the modules and the daemon's stop read every session before the
modules end them. Logs carry states and counts only.

stop_recording now takes a recording and returns what it stopped,
stopping only the captions of calls the agent may read. An agent reading
or leaving a captions session must be allowed to read every thread the
session named, before the module is called and after, so an escalated
1:1 call's earlier chat stays closed to an agent allowed only the new
one. The window shows the switch, the language, the calls kept on top,
the source and coverage of each recording and its captions with gaps;
the tray says how many calls are kept and stops them all; a toast with
"Zatrzymaj" tells of each start. test-echo stands in for the Teams
module's captions tools in the tests.

Also fixes Codex's findings on 4a885da: the list checks every
recording's permission again once the page is made, a captions
recording with a damaged meta.json stays hidden from agents, and a state
file with an entry that is not a call's counts as unreadable, as it may
hide a stop.
Codex's review of f3e3b79 found three ways the archive could lose what
it promised. A stop whose captions-calls.json write failed left the old
"active" state on the disk, so a restart could join the stopped call
again: the archiver now blocks every call whose recording ended
"stopped" in the last day at every start, not only when the state is
lost, and deleting such a recording is refused until the block is on
the disk. Cues that could not be appended to captions.jsonl went
missing without a trace: they are a lost gap now. And an agent's first
leave of an escalated call reached the module before the core knew the
call's earlier chat: the archive's own reads now tell the core every
thread a session names, so that call is refused before the module runs.

From GLM's review of the same commit: a call given up is written down
before its failed recording, and the core's memory of sessions' threads
drops the one told of longest ago when full, never a session read now.

Adds a test of a scan of the modules during a join: the version that
goes reads the last cue first and ends the session the join made.
A session whose reads failed in a row kept its recording open for as
long as the reads kept failing, even after its call was gone, which the
captions archive spec rules out (4.4: a recording never stays in
"recording" without a session). After a minute of failed reads the
session counts as gone: the call is joined again with a rejoin gap, or
its recording ends when the call is no longer listed.

Codex on f2f52cb: deleting a recording waited for the calls' state to be
written even when that recording keeps no call stopped, so one state
that could not be written held back the deletion of every finished
recording. Only a stopped call's recording, the trace of its block,
waits for that now.
Codex and GLM on d8f68ae: after a minute of failed reads the archiver
joins the call again, and the module answers that join with the session
that was there all along. Its first read then starts from the oldest
cue the buffer keeps, and the cues already written went into the
recording a second time. The archiver now keeps the given-up session's
token and cursor, and when the join brings back that very session it
leaves out what it had read of it.
Codex and GLM on 0633178: the session given up after failed reads was
forgotten when the next one was given up before its first read, and
only the first page of a returning session was checked against what had
been read of it, so a buffer over one page, or a second minute of failed
reads, still wrote cues twice. The archiver keeps the last session it
read until a page of it reaches its cursor, over as many pages as that
takes, and a page it emptied that way no longer counts as the end of the
buffer.

test-echo's captions pages now hold 100 cues, as a module's pages do.
The archive joins every call the owner is in once the owner turns it on
in the window, so the official client shows the owner in the call on
another device then too (spec 2026-11-04-captions-archive, section 5).
rmcp before 3.9.0 reads non-streaming HTTP response bodies of its
Streamable HTTP clients with no size limit, and cargo deny now fails the
dependency check on it. 3.9.0 bounds them.
The test makes the calls state read-only to fail its write. On Windows
that stops the rename of the new state over it; on Linux a rename
replaces a read-only file, the write succeeds and the test failed in the
Ubuntu gate. open_mess runs on Windows.
@SychPL
SychPL merged commit d40d215 into main Nov 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant