@gotcos/glasses-server 6.85.0 → 6.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,122 @@
1
+ ## 6.87.0 - A rough level per chunk for COS Control's glasses meeting strip
2
+
3
+ The server half of the level strip COS Control 0.5.339 draws in a glasses meeting's bottom bar (plan of 2026-10-23,
4
+ approved 09:25). The quiet gate already measured every live chunk (RMS, and Silero speech seconds) and only printed the
5
+ numbers on the `[perf]` line. Now each chunk keeps them.
6
+
7
+ - **Two numbers per chunk on the active-session record.** `chunkLevels[{i, level, speechSec}]` in
8
+ `active-sessions/<id>.json`: `level` is the chunk's RMS on a dBFS scale, 0 at -60 dBFS and under, 1 at -10 dBFS and
9
+ over, three decimals; `speechSec` is the Silero speech seconds (two decimals), or null when the gate fell back to
10
+ RMS. Every chunk the quiet gate sees gets one, quiet chunks included; a replayed index keeps its first answer, like
11
+ the chunk itself. Bounded to the newest 120 chunks (about 12 minutes of 6 s chunks) and restored, validated, after a
12
+ restart. It lives only as long as the record (deleted at save). `server/lib/chunk-levels.ts`.
13
+ - **Control reads it from the file it already reads.** The helper's `live-transcript` read opens this same file, so
14
+ there is no new route. `capabilities.liveMeeting.chunkLevels: true` on `/api/health` and `/api/models` tells Control
15
+ the record carries levels; an older server omits it and Control keeps its still glyph.
16
+ - **Not text, never logged.** Nothing new is logged, and no text is stored: the entries hold an index and two numbers.
17
+ `transcribe-stream-chunk-levels.test.ts` spies every console level and checks the record holds numbers only.
18
+ - **No spend.** No LLM, no new caller, no new disk write: the numbers ride on the durable write each chunk already makes.
19
+ - **Checks (6.87.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a scratch folder,
20
+ COS_SCRIPTS_DIR unset: 584 files (583 passed, 1 skipped), 8815 tests, 8801 passed, 14 skipped, 0 failed. Mutation gate `live-meeting-6870-mutation-gate.mjs` against a green
21
+ baseline (32 tests in 4 files): 25 of 25 killed, each by a named
22
+ test, every target landing exactly once and restored byte for byte. `tsc --noEmit` clean. Pack dry run 546 files (6.86.0:
23
+ 545; the one new file is `server/lib/chunk-levels.ts`).
24
+
25
+ ## 6.86.0 - Decision, promise and follow-up cues, live cues on Mac recordings, and the grey provisional line
26
+
27
+ The server half of COS Control's live meeting view (plan of 2026-10-22, decisions of 15:21). Three lanes: three more cue
28
+ kinds from the same single writer call, live cues on Mac recordings when COS Control asks for them, and the newest
29
+ provisional (grey) line of a live meeting for Control to show before the final text replaces it.
30
+
31
+ - **This adds spend for Mac meetings.** Until now a Mac recording never got live cues. From this release, a Mac
32
+ recording that COS Control starts with `liveCues: true` runs the live cues pipeline (the Jev gate and judge, and the
33
+ one Claude writer call per evaluation) for that meeting. It is capped by the same budgets as every meeting: the
34
+ master switch (COS Control's Live cues toggle, or `COS_LIVE_CUES_V2`), 25 cues and 50 writer calls a meeting, the
35
+ daily writer ceiling and gate COS Control sets, the weekly writer ceiling, the Jev daily token cap and breaker, and
36
+ the per-meeting breaker. There is no new LLM caller. `COS_LIVE_CUES_V2_MAC=0` switches Mac recordings off on their
37
+ own (answer `mac_cues_off`) and leaves glasses meetings alone. An older COS Control never sends `liveCues`, so it
38
+ starts no Mac cues and spends nothing new.
39
+ - **Pairs with the COS Control live meeting view** (the Control half ships separately). An older Control and every
40
+ companion are unaffected: glasses meetings keep reinforce and challenge, byte-identical writer rules, and the
41
+ same wire shapes.
42
+ - **Privacy: five more log lines stop printing what was said.** 6.85.0 took the text out of the per-chunk
43
+ `[perf] TOTAL` line. Five others still printed it: the three `[hallucination]` lines (a dropped brand URL, thank-you
44
+ or vocabulary echo, and a chunk the filter emptied, each with up to 60 characters of the chunk), the dictation
45
+ `[prompt-draft] tail_drop` line (up to 100 characters of the dropped sentence) and `[fuzzy-correct]` (the misheard
46
+ word and its correction, up to five pairs). Each now logs its reason and a character count; fuzzy-correct logs how
47
+ many words it fixed. `transcript-log-privacy-686.test.ts` scans every server and shared source for a console call
48
+ that prints a slice of transcript text, and drives fuzzy-correct; restoring each of the five old lines fails it
49
+ (5 of 5). Nothing new logs transcript text, and the provisional line is held in memory only, never written to disk
50
+ or the log.
51
+ - **Checks (6.86.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a
52
+ scratch folder, COS_SCRIPTS_DIR unset: 582 files (581 passed, 1 skipped), 8800 tests, 8786 passed, 14 skipped, 0
53
+ failed (re-run after the log-line fix). Mutation gate `live-meeting-6860-mutation-gate.mjs` against a green baseline (159 tests in 7 files): 63 of
54
+ 63 killed, each by a named test, every target landing exactly once and restored byte for byte. The 6.79.x verdicts
55
+ gate's `schema-kind` mutant, retargeted to `isCueKind`, is still killed. `tsc --noEmit` clean. `npm pack --dry-run`:
56
+ 545 files (6.85.0's 543 plus `server/lib/live-provisional.ts` and `server/lib/live-cues-v2-prompts-kinds.ts`), no
57
+ test, gate or fixture.
58
+
59
+ ### Cue kinds: decision, promise and followUp
60
+
61
+ - **`CueKind`** (`shared/live-cues-v2.ts`) is now `reinforce | challenge | decision | promise | followUp`, with
62
+ `CUE_KINDS`, `LEGACY_CUE_KINDS`, `isCueKind` and `legacyCueKind`. `isLiveCueV2` and the cue log reader accept the
63
+ five kinds and still refuse anything else.
64
+ - **Opt-in per meeting.** A meeting gets the new kinds only when its armer asks: a Mac recording (`mac_` capture id)
65
+ by default, or `POST /api/live-cues/start {sessionId, kinds: 'extended'}`. `kinds: 'legacy'` forces the two old
66
+ kinds; any other value is 400 `invalid_kinds` and arms nothing. A glasses meeting armed as before keeps reinforce and
67
+ challenge: a companion that has not taken the new contract validates `kind` against those two and would drop
68
+ anything else. The arm answer adds `kinds` (the list this meeting may carry). A repeat arm keeps the first kinds.
69
+ - **Writer rules v4** (`server/lib/live-cues-v2-prompts-kinds.ts`) run only for those meetings: v3 with rule 3 (five
70
+ kinds: a decision says what was decided, a promise says who owes what, a followUp names what is still open), rule 4
71
+ (a decision, promise or followUp may record what the room said) and the schema kind list swapped. Everything else
72
+ is v3: one cue, at most 200 characters, the same evidence rules, null for noise. Built from v3 by exact swaps, so a
73
+ regenerated v3 fails the suite instead of shipping under the v4 label. Not replay-measured; rate them in Control.
74
+ The generated `live-cues-v2-prompts.ts` is unchanged, so the v2 and v3 replay pins hold.
75
+ - **Old readers.** The v1 `coaching_nudge` twin and the stop answer's `nudges[].type` carry a legacy type (decision
76
+ and promise read as reinforce, followUp as challenge). The v2 cue, the status route and history keep the real kind.
77
+ COS Control before this release shows an unknown kind as its raw name (`MeetingAskCore.kindLabel` default).
78
+ - **Trace.** An evaluation of an extended meeting traces `kindSet: 'extended'` and `prompt: 'v4'`; the trace still
79
+ carries no transcript text.
80
+
81
+ ### Live cues on Mac recordings
82
+
83
+ - **Arm point.** `POST /api/mac-recording/start {captureId, startTime, sourceType, liveCues: true}`. Arming runs only
84
+ after the recording is admitted, and never fails the start: the answer adds `liveCues: {armed: true, kinds,
85
+ cuesGenerated, paused, pauseSeq}` or `{armed: false, reason}` (`master_flag_off`, `mac_cues_off`, `meeting_ended`,
86
+ `budget_exhausted`, `jev_cap_reached`, and the other capability reasons). With v2 off the engine is never built,
87
+ so a Mac recording never reaches the v1 Composer path. `liveCues` that is not a boolean is 400 `invalid_live_cues`
88
+ before anything is written. Without `liveCues` the answer is byte for byte 6.85.0's.
89
+ - **Resume.** A Mac recording armed from the start route is remembered in `live-cues-v2-state.json` (`resume`, with
90
+ its kinds). A restarted server re-arms it on its next chunk and traces one `resume` row. Cleared when the meeting
91
+ ends; expires with the counters (24 h).
92
+ - **Title.** Mac meetings are titled `Mac Recording <date> <time>` in the gate, the writer prompt and the cue log.
93
+ - **Pause and stop** are the existing routes: `POST /api/live-cues/pause` and the save, which ends the meeting's
94
+ cues through `recording_stop`.
95
+
96
+ ### The grey provisional line
97
+
98
+ - **Route.** `GET /api/transcribe-stream/provisional?sessionId=<id>` (token-protected, `private, no-store`) answers
99
+ `{v: 1, sessionId, live, provisional: {chunkIndex, previewGen, text, model, at} | null, serverInstanceId}`.
100
+ - **What it holds.** The newest preview the preview route produced for a session this server holds open, one per
101
+ meeting: a later chunk, or the same chunk with a later `previewGen`, replaces it; a preview for a chunk that already
102
+ settled is refused. The canonical answer for that chunk (text, silence or a drop) removes it, and so does any close.
103
+ Bounded: the last 600 characters, 8 meetings, 30 s without an update reads as none.
104
+ - **Why a route and not the active-session file.** That file is the 4-hour crash-recovery record and is written with
105
+ fsync before each chunk is acknowledged. Provisional text must not outlive the live session, and writing the file
106
+ per preview would add a durable write on the recording path and churn the stamp Control uses to skip reads.
107
+ - **Mac recordings.** COS Control does not post previews today, so a Mac recording has no provisional text until it
108
+ does. The minimal path needs no server change: Control posts a copy of the still-open chunk (canonical 16 kHz mono
109
+ WAV, at most 512 KB, about 16 s) to the existing `POST /api/transcribe-stream/preview` with the capture id, the open
110
+ chunk index and a rising `previewGen`. The answer carries the text, and the route above serves it too. Preview runs
111
+ Whisper Turbo (or Parakeet when selected); transcription never calls an LLM.
112
+
113
+ ### Capabilities
114
+
115
+ - `capabilities.liveCues.kinds`: the five kinds, beside the unchanged v1 `available` and `reason`.
116
+ - `capabilities.liveMeeting: {protocol: 1, macLiveCues: true, provisional: {route, maxChars: 600, ttlMs: 30000}}`.
117
+ - Both on `/api/health` and `/api/models` from one helper. An older server omits them, which is how COS Control knows
118
+ to hide the inline kinds, skip `liveCues` on the Mac start and skip the grey line.
119
+
1
120
  ## 6.85.0 - Your polish instructions, and COS Control can list the questions COS is answering right now
2
121
 
3
122
  Two lanes and a privacy fix in one release. Your own polish instructions for AI dictation polish, layered after
@@ -626,7 +745,7 @@ Pairs with COS Glasses companion 6.10.645. Includes 6.77.0. No COS Control chang
626
745
 
627
746
  Includes 6.76.2. Off until Miles turns it on. No COS Control or companion change is required; Control can add the toggle, the screen feed and the Dictionary suggestions later against the contracts below.
628
747
 
629
- - Miles, 2026-10-17: after a transcription (Parakeet Ultra or Whisper), run Haiku or Sonnet over the passages where the engine was unsure, with screen and textual awareness, so word hallucinations and misheard names ("Queen Ukoma", "Yukoma", "eucoma", "U coma" for Queen Ukaoma) are resolved.
748
+ - Miles, 2026-10-17: after a transcription (Parakeet Ultra or Whisper), run Haiku or Sonnet over the passages where the engine was unsure, with screen and textual awareness, so word hallucinations and misheard names ("Grace Ukoma", "Yukoma", "eucoma", "U coma" for Grace Ukaoma) are resolved.
630
749
  - Where it runs. Once per saved meeting, in the durable finalization job after the HQ batch text and the Nemotron relabel are written, and before the meeting is handed to COS operations or summarised, so both read the cleaned text. Orphan recovery runs it too. Never during live transcription: it is a separate module no transcription file imports (pinned by a test), and a meeting whose session is still live is skipped. It never fails a save.
631
750
  - Selection, no model (`transcript-cleanup-spans.ts`): low confidence (Parakeet below 0.50, Whisper below 0.40, from the batch words in the sidecar, aligned to the saved transcript); a word near a known name when the engine was unsure of it (below 0.9: one edit past fuzzy-correct's limit for a capitalised word, or the same consonant skeleton and onset, so eucoma reads like Ukaoma); two or three short pieces that join into a name ("U coma"), never function words; the leading-y rule at any confidence ("Yukoma"); an out-of-vocabulary capital mid-sentence beside a known name; a phrase repeated three times or more. Spans stay inside one speaker turn and one clause, hold at most 6 words (12 for a repeat), carry 12 words of context each side, and are cut to 40 per meeting with names first.
632
751
  - Deterministic fixes first, free (fuzzy-correct's own rule, exported as `fuzzyReplacementFor`): only capitalised words that are not already a known spelling; this meeting's own screen and file terms as a temporary per-meeting vocabulary at any confidence; the curated dictionary (vocabulary and corrections) and the owner only where the engine was unsure; never an inflection ("Setting" is never a "Settings" button). Measured read-only on 68 real meetings (38.5 hours): rewriting lowercase words, or using every enrolled name as a target, swapped everyday words and real names for other names (giant to grant, Kelsey to Kelley, Slack to Black), so neither is done; with these rules the same meetings got 27 fixes, all real (Manuj to Manoj, MarketPOS to MarktPOS).
@@ -660,7 +779,7 @@ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.322. Includes 6.76
660
779
  - When SubQ serves cues, the live transcript window (up to 2,200 characters per call) and the candidate notes go to SubQ, which retains data by default.
661
780
  - Capability additions (optional fields; `shared/live-cues-v2.ts` changed additively, new sha256 174728e7...): `memory` (`cue_memory`, `notes` or `none`) and `decisionProvider` (`jev` or `subq`). New reasons: `decision_not_configured` (no key for the chosen provider; `jev_not_configured` is kept for Jev chosen while only a SubQ key exists), `decision_cap_reached`, `decision_breaker_open` (SubQ) and `decision_invalid_provider`. The same decision codes stop a running meeting's cues quietly with that reason; other SubQ errors count toward the meeting breaker as Jev errors do.
662
781
  - Live COS Ask finds the owner's notes too. When `searchMemories` does not answer (it fails, its meaning arm fails as on the public runtime, or it finds nothing), the Ask's COS search also reads the memory bridge listing and matches it with the same keyword scorer, the same 0.35 floor and the same confirmed-domain filter as other COS hits, fused by rank with any memory hits (at most 3 memory sources). Listed notes are cited as COS memory sources (H ids, `memoryId`), counted in the `cos_search` phase time and result count, and the notices add "COS also searched the notes in its memory list by keyword." whenever one is cited. The listing is bounded at 3 s and the Ask's own cancel; a failed listing changes nothing. A memory search that answers is used exactly as before, the same sources in the same order and key order, and the listing is never read. With nothing found in either, the notices are the existing empty wording, word for word. On the public runtime this lets the 6.76.0 person lookup ("Who is X?") answer from the owner's note. One helper serves both cues and the Ask: `server/lib/cos-bridge-notes.ts` (bridge listing, keyword scoring, local dates, rank fusion).
663
- - Tests: `live-cues-v2.test.ts` (52, 13 new), `live-cues-v2-notes.test.ts` (11), `live-cues-v2-notes-bridge.test.ts` (2: the real searchers over a scratch bridge, a markdown note and a saved meeting), `live-cues-v2-settings.test.ts` (3), `live-cues-v2-settings-route.test.ts` (10: behind the real token gate, a module reload as the restart, and the real deps with faked network sending the gate to SubQ then Jev), `health.test.ts` (1 new: `/api/health` and `/api/models` flip with the setting), `live-cos-ask-memory-fallback.test.ts` (8), `live-cos-ask-queen-runtime.test.ts` (3: the shipped public memory runtime 0.1.0 installed in a scratch dir with one accepted note; `searchMemories` alone finds nothing, the Ask answers "Who is Delgado?" from the note). `server/scripts/live-cues-v2-notes-mutation-gate.mjs`: 85 mutants over the cue switch, the cue notes fallback, the shared helper, the decision provider selection (global setting ignored, only-key fallback, explicit override both ways) and the Ask fallback (13 Ask guards), each killed by a named test against a green baseline (160 tests in 12 files). Full suite 7240 passed, 13 skipped, 0 failed; typecheck clean.
782
+ - Tests: `live-cues-v2.test.ts` (52, 13 new), `live-cues-v2-notes.test.ts` (11), `live-cues-v2-notes-bridge.test.ts` (2: the real searchers over a scratch bridge, a markdown note and a saved meeting), `live-cues-v2-settings.test.ts` (3), `live-cues-v2-settings-route.test.ts` (10: behind the real token gate, a module reload as the restart, and the real deps with faked network sending the gate to SubQ then Jev), `health.test.ts` (1 new: `/api/health` and `/api/models` flip with the setting), `live-cos-ask-memory-fallback.test.ts` (8), `live-cos-ask-grace-runtime.test.ts` (3: the shipped public memory runtime 0.1.0 installed in a scratch dir with one accepted note; `searchMemories` alone finds nothing, the Ask answers "Who is Delgado?" from the note). `server/scripts/live-cues-v2-notes-mutation-gate.mjs`: 85 mutants over the cue switch, the cue notes fallback, the shared helper, the decision provider selection (global setting ignored, only-key fallback, explicit override both ways) and the Ask fallback (13 Ask guards), each killed by a named test against a green baseline (160 tests in 12 files). Full suite 7240 passed, 13 skipped, 0 failed; typecheck clean.
664
783
 
665
784
  ## 6.76.1 - Stop a glasses meeting from COS Control
666
785
 
@@ -674,10 +793,10 @@ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.321. Includes 6.76
674
793
  - Tests: `server/routes/meeting-stop-request.test.ts` (9) over a real router; seven mutants (window ignored, window off by one, a refused request claimable, a not-live session asked, a Mac capture asked, no display event, a request id from another session) each killed.
675
794
  ## 6.76.0 - Live COS Ask knows who owns this COS, looks in COS for person questions, and times every phase
676
795
 
677
- - Field report (Queen, 2026-10-17): on her own COS, "Who is Queen Ukaoma?" (heard as "Queen Ukoma") was answered "These sources say nothing about who Queen Ukaoma is" from one transcript passage.
796
+ - Field report (Grace, 2026-10-17): on her own COS, "Who is Grace Ukaoma?" (heard as "Grace Ukoma") was answered "These sources say nothing about who Grace Ukaoma is" from one transcript passage.
678
797
  - Owner card. A typed question gets a `profile` source built from the profile the server already keeps: `owner_name`, `owner_speaker_label`, `home_city`, and the owner's own `system_prompt_context` notes with any sentence holding a street address, zip, phone number or email removed. It is cited with an H id like other COS history, returned in `sources` when cited, and named "COS profile" on the lens From line. No profile name: no card, and the prompt is byte-for-byte as before. Presets never get it.
679
- - ASR spellings of the owner's name read as the owner: one edit for names under seven letters (two from seven), one edit after a recognizer-added leading y (Ukoma and Yukoma both read as Ukaoma). Full name, a surname after a lead-in word ("who is Ukoma"), or an exact first name as the whole subject. A different first name with the same surname ("Isaiah Ukaoma") is not the owner. The answer's notices say how the question was read.
680
- - Person and self-history questions always look in COS first. "Who is X", "tell me about X" and the owner's own history ("what was going on with me when I was pregnant with Isaiah?") run one COS lookup (memories and earlier meetings, every domain, not only the confirmed meeting domain) before the first model call, whatever the planner would choose. It takes one of the two lookups and saves the planner hop. It never goes to the web: web search stays gated exactly as before, so a public "who is" question still reaches the gated web through the planner after an empty COS lookup.
798
+ - ASR spellings of the owner's name read as the owner: one edit for names under seven letters (two from seven), one edit after a recognizer-added leading y (Ukoma and Yukoma both read as Ukaoma). Full name, a surname after a lead-in word ("who is Ukoma"), or an exact first name as the whole subject. A different first name with the same surname ("Rafael Ukaoma") is not the owner. The answer's notices say how the question was read.
799
+ - Person and self-history questions always look in COS first. "Who is X", "tell me about X" and the owner's own history ("what was going on with me when I was pregnant with Rafael?") run one COS lookup (memories and earlier meetings, every domain, not only the confirmed meeting domain) before the first model call, whatever the planner would choose. It takes one of the two lookups and saves the planner hop. It never goes to the web: web search stays gated exactly as before, so a public "who is" question still reaches the gated web through the planner after an empty COS lookup.
681
800
  - An empty COS lookup is never proof of absence. The prompt lists each COS lookup with its result count, the notices say what was searched, and an answer that reads as "nothing about X" after an empty lookup gets one sentence saying what COS searched and that this does not mean COS has nothing on it.
682
801
  - Latency, measured on Miles's Mac before changing anything: "through 210666 ms" and "288457 ms" in the report are `throughMs`, the meeting position when the question was asked (3:30 and 4:48 into the recording), not how long the answer took. The 98 completed Asks in the journal took 3.2 s to 24.3 s (median 6.7 s), and the durable runtime stops any Ask at 30 s. Per model call (the live server's token audit since 2026-10-10): Sonnet median 4.6 s, max 9.4 s (n=121); Opus median 6.1 s, max 12.3 s (n=11); Haiku median 3.8 s, max 7.0 s (n=10); web search median 9.1 s, max 10.5 s (n=10). No search timing existed before this release.
683
802
  - Per-phase timing. Each exchange carries `timings` (`totalMs` and ordered `phases`: plan, answer, review, cos_search, web_search, public_topic, fact_check, with `forced`, `failed` and result counts), kept on failed and timed-out Asks too, and each Ask logs one line `[meeting-ask] timing <requestId> <action> <outcome> total=...ms <phase>=<ms>[/<results>] ...` with no question, answer or query text.
@@ -1795,7 +1914,7 @@ The phone can list the skills this Mac will actually run.
1795
1914
 
1796
1915
  - `GET /api/skills` returns the slash catalog from the selected workspace (`.agents/skills`, then `.claude/skills`) plus `~/.claude/skills`. Nested agent folders flatten with `:`. Generated `source-command-*` duplicates stay out. Paths never leave the box.
1797
1916
  - COS Glasses 6.9.468 paints that list on Views instead of a hardcoded cheat sheet.
1798
- - A held voice can be heard before it is named. `GET /api/voice/ext-audio` lists `chunkIndices` per held session and `GET /api/voice/ext-audio/:sessionId/sample?chunk=<index>` serves that one chunk (no `chunk` still serves the newest). COS Control's Add-a-voice panel uses it to play portions of a session before naming it (Queen, 2026-09-12: "no way to listen to the voices that are here").
1917
+ - A held voice can be heard before it is named. `GET /api/voice/ext-audio` lists `chunkIndices` per held session and `GET /api/voice/ext-audio/:sessionId/sample?chunk=<index>` serves that one chunk (no `chunk` still serves the newest). COS Control's Add-a-voice panel uses it to play portions of a session before naming it (Grace, 2026-09-12: "no way to listen to the voices that are here").
1799
1918
  - Recent is a rolling window. `GET /api/sessions/today/all-messages` keeps its path for COS Control and the phone but now answers with the newest 30 messages (`?limit=` up to 100) across every live session and as many archived days as it takes, deduplicated against their archive mirrors. Messages leave Recent only by ageing out of the window; nothing is hidden by the calendar. Control's Recent view and the phone's history recovery read this without change.
1800
1919
  - Yesterday's turns no longer vanish for a day. The archive mirror ran every 24 h from boot (21:25 on the current uptime), so finished sessions from the previous local day were neither "today" for `GET /api/sessions/today/all-messages` nor in any day archive until the evening; on 2026-09-12 07:00 Control showed no turns while ten from the 11th sat live in `sessions.json`. The mirror now runs at boot, at the next local midnight, and lazily from the today and archive-listing routes, once per local day. `checkYesterdayArchive` keys on the local day like everything else.
1801
1920
 
@@ -35,6 +35,7 @@
35
35
  "COS_LIVE_CUES_V2_REPEAT_OVERLAP",
36
36
  "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
37
  "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
38
+ "COS_LIVE_CUES_V2_MAC",
38
39
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
39
40
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
40
41
  "COS_DECISION_PROVIDER_CUES",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.85.0",
3
+ "version": "6.87.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -134,6 +134,8 @@
134
134
  "docs/test-plan-6.71.5.md",
135
135
  "docs/test-plan-6.71.7.md",
136
136
  "!server/scripts/question-hold-mutation-gate.mjs",
137
+ "!server/scripts/live-meeting-6860-mutation-gate.mjs",
138
+ "!server/scripts/live-meeting-6870-mutation-gate.mjs",
137
139
  "docs/test-plan-6.71.8.md",
138
140
  "docs/test-plan-6.71.9.md",
139
141
  "docs/test-plan-6.71.10.md",
@@ -0,0 +1,64 @@
1
+ // 6.87.0: a rough level per live chunk, for COS Control's level strip on glasses meetings.
2
+ //
3
+ // The quiet gate already measures every chunk (stt-live.ts decideQuiet: RMS and, with Silero, speech seconds). Until
4
+ // 6.87.0 those numbers only reached the [perf] line. Now each chunk keeps two numbers on the active session record:
5
+ // `level`, 0 to 1 from the chunk's RMS on a dBFS scale, and `speechSec`, the Silero speech seconds (null when the gate
6
+ // fell back to RMS). Control reads them from the same active-session file its live transcript reads, so there is no
7
+ // new route and no new log line.
8
+ //
9
+ // NOT TEXT. Two numbers per chunk, bounded to the newest CHUNK_LEVELS_LIMIT chunks, living only as long as the
10
+ // active-session record (the 4-hour crash-recovery file, deleted at save). Nothing here logs.
11
+
12
+ /** The newest chunks kept: about 12 minutes of 6 s chunks, far more than the strip draws. */
13
+ export const CHUNK_LEVELS_LIMIT = 120
14
+ /** The dBFS that reads as 0: under the quiet gate's RMS 150 (about -47 dBFS) and the room floor of a G2 mic. */
15
+ export const LEVEL_FLOOR_DBFS = -60
16
+ /** The dBFS that reads as 1: loud close speech. */
17
+ export const LEVEL_CEILING_DBFS = -10
18
+
19
+ export interface ChunkLevel {
20
+ /** The chunk's own index. */
21
+ i: number
22
+ /** 0 to 1, three decimals. */
23
+ level: number
24
+ /** Silero speech seconds, two decimals; null when the quiet gate used RMS. */
25
+ speechSec: number | null
26
+ }
27
+
28
+ /** A chunk's RMS (PCM16 units) as 0 to 1: LEVEL_FLOOR_DBFS and under is 0, LEVEL_CEILING_DBFS and over is 1. */
29
+ export function normalizedLevel(rms: number): number {
30
+ if (!Number.isFinite(rms) || rms <= 0) return 0
31
+ const dbfs = 20 * Math.log10(rms / 32768)
32
+ const level = (dbfs - LEVEL_FLOOR_DBFS) / (LEVEL_CEILING_DBFS - LEVEL_FLOOR_DBFS)
33
+ return Math.round(Math.min(1, Math.max(0, level)) * 1000) / 1000
34
+ }
35
+
36
+ /** The stored entry for one chunk's quiet decision. */
37
+ export function chunkLevel(chunkIndex: number, rms: number, speechSec: number | null): ChunkLevel {
38
+ const speech = speechSec !== null && Number.isFinite(speechSec) ? Math.round(Math.max(0, speechSec) * 100) / 100 : null
39
+ return { i: chunkIndex, level: normalizedLevel(rms), speechSec: speech }
40
+ }
41
+
42
+ /** `levels` with `entry` in index order (a second entry for an index replaces the first), the newest
43
+ * CHUNK_LEVELS_LIMIT kept. Never mutates its input. */
44
+ export function withChunkLevel(levels: readonly ChunkLevel[] | undefined, entry: ChunkLevel): ChunkLevel[] {
45
+ const next = (levels ?? []).filter(l => l.i !== entry.i)
46
+ next.push(entry)
47
+ next.sort((a, b) => a.i - b.i)
48
+ return next.length > CHUNK_LEVELS_LIMIT ? next.slice(next.length - CHUNK_LEVELS_LIMIT) : next
49
+ }
50
+
51
+ /** The levels from a recovered session file: only well-formed entries, one per index, in order, bounded. */
52
+ export function restoreChunkLevels(value: unknown): ChunkLevel[] {
53
+ if (!Array.isArray(value)) return []
54
+ let levels: ChunkLevel[] = []
55
+ for (const raw of value) {
56
+ if (!raw || typeof raw !== 'object') continue
57
+ const { i, level, speechSec } = raw as Record<string, unknown>
58
+ if (!Number.isInteger(i) || (i as number) < 0) continue
59
+ if (typeof level !== 'number' || !Number.isFinite(level) || level < 0 || level > 1) continue
60
+ if (speechSec !== null && (typeof speechSec !== 'number' || !Number.isFinite(speechSec) || speechSec < 0)) continue
61
+ levels = withChunkLevel(levels, { i: i as number, level, speechSec: speechSec as number | null })
62
+ }
63
+ return levels
64
+ }
@@ -293,7 +293,6 @@ export function applyFuzzyCorrections(
293
293
 
294
294
  const targetLowerSet = new Set(canonical.map(t => t.toLowerCase()))
295
295
  let replacements = 0
296
- const fuzzyLog: Array<{ from: string; to: string }> = []
297
296
 
298
297
  // Match any 5+ char word (case-insensitive). Stop-list + strict distance
299
298
  // thresholds are the primary defenses against false positives.
@@ -301,13 +300,12 @@ export function applyFuzzyCorrections(
301
300
  const replacement = fuzzyReplacementFor(word, canonical, targetLowerSet)
302
301
  if (replacement === null) return word
303
302
  replacements++
304
- fuzzyLog.push({ from: word, to: replacement })
305
303
  return replacement
306
304
  })
307
305
 
308
- if (fuzzyLog.length > 0) {
309
- const preview = fuzzyLog.slice(0, 5).map(x => `${x.from}→${x.to}`).join(', ')
310
- console.log(`[fuzzy-correct] Applied ${replacements} correction(s): ${preview}${fuzzyLog.length > 5 ? '...' : ''}`)
306
+ if (replacements > 0) {
307
+ // 6.86.0: the count only. The pairs were the words someone said.
308
+ console.log(`[fuzzy-correct] Applied ${replacements} correction(s)`)
311
309
  }
312
310
 
313
311
  return { text: result, replacements }
@@ -22,7 +22,7 @@ import { durableAtomicWriteFileSync } from './atomic-fs.js'
22
22
  import { errMsg } from './utils.js'
23
23
  import { getMeetingStore } from './meeting-store.js'
24
24
  import { findCosOperationsMeetingBySessionId, findDirectLibraryMeetingBySessionId } from './cos-operations-meetings.js'
25
- import { CUE_FEEDBACK_REASON_MAX, type CueKind, type CueSourceRef, type LiveCuesV2MemorySource } from '../../shared/live-cues-v2.js'
25
+ import { CUE_FEEDBACK_REASON_MAX, isCueKind, type CueKind, type CueSourceRef, type LiveCuesV2MemorySource } from '../../shared/live-cues-v2.js'
26
26
  import type { WouldWithhold } from './live-cues-filter.js'
27
27
 
28
28
  // ── Wire types (server-local: shared/live-cues-v2.ts is hash-pinned with the companion copy) ──
@@ -43,6 +43,7 @@ export interface LiveCueLogRow {
43
43
  meetingTitle: string
44
44
  createdAt: number
45
45
  armedAt?: number
46
+ /** reinforce | challenge, or (6.86.0, meetings with the extended kinds) decision | promise | followUp. */
46
47
  kind: CueKind
47
48
  headline: string
48
49
  detail: string
@@ -111,7 +112,7 @@ export function isLogRow(value: unknown): value is LiveCueLogRow {
111
112
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false
112
113
  const r = value as Record<string, unknown>
113
114
  return r.v === 1 && str(r.cueId) && r.cueId.length > 0 && str(r.sessionId) && r.sessionId.length > 0 && str(r.meetingTitle)
114
- && num(r.createdAt) && (r.kind === 'reinforce' || r.kind === 'challenge')
115
+ && num(r.createdAt) && isCueKind(r.kind)
115
116
  && str(r.headline) && str(r.detail) && num(r.gate) && str(r.model) && str(r.before)
116
117
  && Array.isArray(r.sources) && r.sources.every(s => !!s && typeof s === 'object' && str((s as CueSourceRef).id) && str((s as CueSourceRef).date) && str((s as CueSourceRef).title))
117
118
  && optional(r.armedAt, num) && optional(r.prompt, p => typeof p === 'string' && /^v\d{1,2}$/.test(p))
@@ -0,0 +1,38 @@
1
+ // 6.86.0: the writer rules for a meeting armed with the extended kinds (decision, promise, followUp beside reinforce and
2
+ // challenge). Hand-written, so it lives beside the GENERATED live-cues-v2-prompts.ts and never inside it: v2 and v3 stay
3
+ // byte-identical to what the dev and held-out replays measured, and every meeting without the extended kinds (every
4
+ // glasses meeting today) still runs them.
5
+ //
6
+ // v4 is v3 with three segments swapped: rule 3 (the kinds), rule 4 (a record of what the room settled is allowed to say
7
+ // what the room said) and the kind list in the schema line. Nothing else changes: one cue, at most 200 characters, the
8
+ // same evidence rules, the same null. The swap throws when v3 no longer holds a segment, so a regenerated v3 fails the
9
+ // suite (live-cues-v2-kinds.test.ts) instead of silently shipping v3 under the v4 label.
10
+ //
11
+ // Not replay-measured. It runs only for meetings that opted in (Mac recordings in COS Control), whose cues are rated in
12
+ // Control like every other cue.
13
+
14
+ import { CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
15
+
16
+ export const V3_RULE_3 = '3. Two kinds. reinforce: they are on track; name the evidence and the next step. challenge: the flaw, the missing piece, or the contradiction.\n'
17
+ + ' Check previousCues: do not only criticize, and never repeat a previous cue. No empty praise.\n'
18
+ export const V4_RULE_3 = '3. Five kinds. reinforce: they are on track; name the evidence and the next step. challenge: the flaw, the missing piece, or the contradiction.\n'
19
+ + ' decision: the room just settled something; say what was decided. promise: someone took something on; say who owes what (rule 2 decides whether you may name them), and when only if a date was said.\n'
20
+ + ' followUp: something the room must come back to; name it and what is still open. suggestedKind is a hint; pick the kind that fits the window.\n'
21
+ + ' Check previousCues: do not only criticize, never repeat a previous cue, and never record the same decision or promise twice. No empty praise.\n'
22
+
23
+ export const V3_RULE_4 = '4. Add something the room has not said: the missing owner, deadline or criterion, a risk, a contradiction, or a past decision from memory.\n'
24
+ + ' If the cue would only restate what was just said, output null.\n'
25
+ export const V4_RULE_4 = '4. A reinforce or challenge cue must add something the room has not said: the missing owner, deadline or criterion, a risk, a contradiction, or a past decision from memory.\n'
26
+ + ' If it would only restate what was just said, output null. A decision, promise or followUp records what the room settled, took on or left open, in a few plain words.\n'
27
+
28
+ export const V3_SCHEMA_KIND = '"kind":"reinforce|challenge"'
29
+ export const V4_SCHEMA_KIND = '"kind":"reinforce|challenge|decision|promise|followUp"'
30
+
31
+ function swap(text: string, from: string, to: string): string {
32
+ const at = text.indexOf(from)
33
+ if (at < 0 || text.indexOf(from, at + 1) >= 0) throw new Error('live cues v4: the v3 writer rules changed; rebuild v4 from the new v3')
34
+ return text.slice(0, at) + to + text.slice(at + from.length)
35
+ }
36
+
37
+ /** Writer v4: v3 plus the decision, promise and followUp kinds. */
38
+ export const CUE_WRITER_SYSTEM_V4 = swap(swap(swap(CUE_WRITER_SYSTEM_V3, V3_RULE_3, V4_RULE_3), V3_RULE_4, V4_RULE_4), V3_SCHEMA_KIND, V4_SCHEMA_KIND)
@@ -43,9 +43,11 @@ import { cueBefore, liveCueVerdicts, type LiveCueLogRow } from './live-cue-verdi
43
43
  import { repeatOf, wouldWithhold } from './live-cues-filter.js'
44
44
  import { errMsg } from './utils.js'
45
45
  import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
46
+ import { CUE_WRITER_SYSTEM_V4 } from './live-cues-v2-prompts-kinds.js'
47
+ import { MAC_CAPTURE_ID } from './mac-recording.js'
46
48
  import {
47
- CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, LIVE_CUES_V2_PROTOCOL, isLiveCueV2,
48
- type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
49
+ CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, CUE_KINDS, LEGACY_CUE_KINDS, LIVE_CUES_V2_PROTOCOL, isLiveCueV2, legacyCueKind,
50
+ type CueKind, type CueKindSet, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
49
51
  type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
50
52
  } from '../../shared/live-cues-v2.js'
51
53
 
@@ -174,9 +176,23 @@ export function liveCuesV2Model(env: NodeJS.ProcessEnv = process.env): string {
174
176
  if (raw === 'sonnet' || raw === '') return 'claude-sonnet-5-5'
175
177
  return /^claude-[a-z0-9.-]{3,40}$/.test(raw) ? raw : 'claude-sonnet-5-5'
176
178
  }
177
- export function liveCuesV2WriterSystem(env: NodeJS.ProcessEnv = process.env): { version: 'v2' | 'v3'; system: string } {
179
+ /** The writer rules. A meeting with the extended kinds (6.86.0) always runs v4: v2 and v3 only know reinforce and challenge.
180
+ * Every other meeting is unchanged: COS_LIVE_CUES_V2_PROMPT=v2 picks v2, anything else v3. */
181
+ export function liveCuesV2WriterSystem(env: NodeJS.ProcessEnv = process.env, kinds: CueKindSet = 'legacy'): { version: 'v2' | 'v3' | 'v4'; system: string } {
182
+ if (kinds === 'extended') return { version: 'v4', system: CUE_WRITER_SYSTEM_V4 }
178
183
  return (env.COS_LIVE_CUES_V2_PROMPT ?? '').trim() === 'v2' ? { version: 'v2', system: CUE_WRITER_SYSTEM_V2 } : { version: 'v3', system: CUE_WRITER_SYSTEM_V3 }
179
184
  }
185
+ /** 6.86.0: which kinds a meeting gets when its armer does not say. A Mac recording (`mac_` capture id) is read only by COS
186
+ * Control, which shows any kind, so it gets the extended set; every other meeting keeps the two legacy kinds, because a
187
+ * companion that validates `kind` drops a cue whose kind it does not know. */
188
+ export function defaultCueKindSet(sessionId: string): CueKindSet {
189
+ return MAC_CAPTURE_ID.test(sessionId) ? 'extended' : 'legacy'
190
+ }
191
+ /** 6.86.0: env COS_LIVE_CUES_V2_MAC=0 refuses live cues on Mac recordings (reason mac_cues_off) without touching glasses
192
+ * meetings. Unset or anything else: Mac recordings may be armed, under the same switch and limits as every meeting. */
193
+ export function liveCuesV2MacAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
194
+ return (env.COS_LIVE_CUES_V2_MAC ?? '').trim() !== '0'
195
+ }
180
196
  export function liveCuesV2WeeklyCap(env: NodeJS.ProcessEnv = process.env): number {
181
197
  const raw = Number(env.COS_LIVE_CUES_V2_WEEKLY)
182
198
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultWeeklyWriterCalls
@@ -253,7 +269,10 @@ export interface ParsedCue { detail: string; kind: CueKind; memoryIds: string[];
253
269
  export type CueRejection = 'null' | 'unparseable' | 'empty' | 'too_long' | 'unsupported_number'
254
270
  /** `enforceNumbers` withholds a cue whose numbers are not in the source. Off by default: on the held-out replay the
255
271
  * guard fired on 3 v3 cues and only 1 was unsupported (ASR writes "300 000" and "a hundred"), so it is traced, not enforced. */
256
- export function parseWriterCue(raw: string, fallbackKind: CueKind, window: string, kept: MemoryNote[], enforceNumbers = false): ParsedCue | CueRejection {
272
+ /** `allowed` is the meeting's kind set (6.86.0): a kind outside it, or none, falls back to the gate's kind, so a legacy
273
+ * meeting can never carry decision, promise or followUp even when the writer answers with one. */
274
+ export function parseWriterCue(raw: string, fallbackKind: CueKind, window: string, kept: MemoryNote[], enforceNumbers = false,
275
+ allowed: readonly CueKind[] = LEGACY_CUE_KINDS): ParsedCue | CueRejection {
257
276
  const text = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
258
277
  if (!text || text === 'null') return 'null'
259
278
  const start = text.indexOf('{'), end = text.lastIndexOf('}')
@@ -265,7 +284,7 @@ export function parseWriterCue(raw: string, fallbackKind: CueKind, window: strin
265
284
  if (cue.length > CUE_DETAIL_MAX) return 'too_long'
266
285
  const numberFlags = unsupportedNumbers(cue, window, kept)
267
286
  if (enforceNumbers && numberFlags.length) return 'unsupported_number'
268
- const kind: CueKind = value.kind === 'reinforce' || value.kind === 'challenge' ? value.kind : fallbackKind
287
+ const kind: CueKind = typeof value.kind === 'string' && (allowed as readonly string[]).includes(value.kind) ? value.kind as CueKind : fallbackKind
269
288
  const ids = new Set(kept.map(m => m.id))
270
289
  const memoryIds = Array.isArray(value.memoryIds) ? [...new Set(value.memoryIds.filter((x): x is string => typeof x === 'string' && ids.has(x)))] : []
271
290
  return { detail: cue, kind, memoryIds, numberFlags }
@@ -296,6 +315,9 @@ interface DurableState {
296
315
  ended: Record<string, number>; paused: Record<string, boolean>; pauseSeq: Record<string, number>
297
316
  meetings: Record<string, MeetingCounters>
298
317
  day: string; today: LiveCuesV2DayUsage
318
+ /** 6.86.0: meetings armed with resume (Mac recordings): a restarted server re-arms them on their next chunk, with the
319
+ * kind set they were armed with. Cleared at end; expires with the counters (24 h). */
320
+ resume: Record<string, { at: number; kinds: CueKindSet }>
299
321
  }
300
322
  /** Local calendar day (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central. */
301
323
  export function localDay(now: Date, zone: string): string {
@@ -326,6 +348,8 @@ export class LiveCuesV2Store {
326
348
  const meetings = keep<MeetingCounters>(s.meetings, c => !!c && typeof c.at === 'number' && c.at >= nowMs - LIVE_CUES_V2_LIMITS.counterTtlMs)
327
349
  const live = (id: string) => id in meetings || !(id in ended)
328
350
  const paused = keep<boolean>(s.paused, v => v === true)
351
+ const resume = keep<{ at: number; kinds: CueKindSet }>(s.resume, r => !!r && typeof r === 'object' && typeof r.at === 'number'
352
+ && r.at >= nowMs - LIVE_CUES_V2_LIMITS.counterTtlMs && (r.kinds === 'legacy' || r.kinds === 'extended'))
329
353
  const pauseSeq = keep<number>(s.pauseSeq, v => typeof v === 'number' && Number.isFinite(v))
330
354
  for (const id of Object.keys(paused)) if (!live(id) && !(id in meetings)) delete paused[id]
331
355
  const sameWeek = s.weekStart === week
@@ -341,12 +365,12 @@ export class LiveCuesV2Store {
341
365
  else today = DAY_FIELDS.every(f => t[f] === undefined || Number(t[f]) >= 0)
342
366
  ? Object.fromEntries(DAY_FIELDS.map(f => [f, Number(t[f] ?? 0)])) as unknown as LiveCuesV2DayUsage
343
367
  : spentDay()
344
- return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings, day, today }
368
+ return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings, day, today, resume }
345
369
  } catch {
346
370
  // An unreadable file fails closed for this week and this day (no spending) but keeps cues off nothing else.
347
371
  const zone = validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC'
348
372
  return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {},
349
- day: localDay(this.now(), zone), today: spentDay() }
373
+ day: localDay(this.now(), zone), today: spentDay(), resume: {} }
350
374
  }
351
375
  }
352
376
  private write(s: DurableState): void {
@@ -393,7 +417,16 @@ export class LiveCuesV2Store {
393
417
  return m ? { cues: m.cues, writerCalls: m.writerCalls } : null
394
418
  }
395
419
  weeklyUsed(): number { return this.read().writerCalls }
396
- end(sessionId: string): void { const s = this.read(); s.ended[sessionId] = this.now().getTime(); delete s.paused[sessionId]; this.write(s) }
420
+ end(sessionId: string): void { const s = this.read(); s.ended[sessionId] = this.now().getTime(); delete s.paused[sessionId]; delete s.resume[sessionId]; this.write(s) }
421
+ /** 6.86.0: remember an armed meeting so a restarted server re-arms it on its next chunk. One write, only when new. */
422
+ setResume(sessionId: string, kinds: CueKindSet): void {
423
+ const s = this.read()
424
+ if (sessionId in s.ended || s.resume[sessionId]?.kinds === kinds) return
425
+ s.resume[sessionId] = { at: this.now().getTime(), kinds }
426
+ this.write(s)
427
+ }
428
+ /** The kind set a meeting was armed with for resume, or null. */
429
+ resumeKinds(sessionId: string): CueKindSet | null { return this.read().resume[sessionId]?.kinds ?? null }
397
430
  isEnded(sessionId: string): boolean { return sessionId in this.read().ended }
398
431
  /** Applies only when `seq` is absent or newer than the last applied one. */
399
432
  setPaused(sessionId: string, paused: boolean, seq?: number): { applied: boolean; paused: boolean; pauseSeq: number } {
@@ -600,8 +633,14 @@ interface Session {
600
633
  /** The last reason cues were not produced, cleared by the next evaluation that runs the writer. */
601
634
  stopReason: LiveCuesV2StopReason | null
602
635
  skipTracedAt: Record<string, number>
636
+ /** 6.86.0: the kinds this meeting's cues may carry, fixed at arm. */
637
+ kinds: CueKindSet
603
638
  }
604
639
 
640
+ /** 6.86.0: how a meeting is armed. `kinds` defaults by session (defaultCueKindSet); `resume` makes a restarted server re-arm
641
+ * it on its next chunk (the Mac recording start passes it). */
642
+ export interface LiveCuesV2ArmOptions { kinds?: CueKindSet; resume?: boolean }
643
+
605
644
  export class LiveCuesV2ArmError extends Error {
606
645
  constructor(readonly code: string, readonly status: number, message: string) { super(message); this.name = 'LiveCuesV2ArmError' }
607
646
  }
@@ -658,22 +697,32 @@ export class LiveCuesV2Engine {
658
697
  }
659
698
  }
660
699
 
661
- /** Idempotent. A repeat start for an armed meeting keeps its counters; an ended meeting cannot be re-armed. */
662
- arm(sessionId: string): { armed: true; sessionId: string; protocol: 2; cuesGenerated: number; paused: boolean; pauseSeq: number } {
700
+ /** Idempotent. A repeat start for an armed meeting keeps its counters and its kinds; an ended meeting cannot be re-armed.
701
+ * `kinds` (6.86.0, additive) is the kind list this meeting's cues may carry. */
702
+ arm(sessionId: string, opts: LiveCuesV2ArmOptions = {}): { armed: true; sessionId: string; protocol: 2; cuesGenerated: number; paused: boolean; pauseSeq: number; kinds: CueKind[] } {
663
703
  this.reap()
664
704
  const cap = this.capability()
665
705
  if (!cap.available) throw new LiveCuesV2ArmError(cap.reason ?? 'unavailable', cap.reason === 'master_flag_off' ? 403 : 503, `Live cues v2 unavailable: ${cap.reason}.`)
706
+ const mac = MAC_CAPTURE_ID.test(sessionId)
707
+ if (mac && !liveCuesV2MacAllowed(this.deps.env)) throw new LiveCuesV2ArmError('mac_cues_off', 403, 'Live cues are switched off for Mac recordings (COS_LIVE_CUES_V2_MAC=0).')
666
708
  if (this.deps.store.isEnded(sessionId)) throw new LiveCuesV2ArmError('meeting_ended', 409, 'This meeting has ended; live cues cannot be re-armed for it.')
667
709
  const existing = this.sessions.get(sessionId)
668
710
  const nowMs = this.deps.now().getTime()
669
- const answer = (cues: number) => ({ armed: true as const, sessionId, protocol: 2 as const, cuesGenerated: cues, paused: this.deps.store.isPaused(sessionId), pauseSeq: this.deps.store.pauseSeq(sessionId) })
670
- if (existing) { existing.lastSeenAt = nowMs; return answer(existing.cues.length) }
711
+ const answer = (cues: number, kinds: CueKindSet) => ({ armed: true as const, sessionId, protocol: 2 as const, cuesGenerated: cues, paused: this.deps.store.isPaused(sessionId),
712
+ pauseSeq: this.deps.store.pauseSeq(sessionId), kinds: [...(kinds === 'extended' ? CUE_KINDS : LEGACY_CUE_KINDS)] })
713
+ if (existing) {
714
+ existing.lastSeenAt = nowMs
715
+ if (opts.resume) this.deps.store.setResume(sessionId, existing.kinds)
716
+ return answer(existing.cues.length, existing.kinds)
717
+ }
718
+ const kinds = opts.kinds ?? defaultCueKindSet(sessionId)
671
719
  const d = this.deps.now()
672
- const title = `G2 Recording ${d.toLocaleDateString('en-CA')} ${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
720
+ const title = `${mac ? 'Mac' : 'G2'} Recording ${d.toLocaleDateString('en-CA')} ${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
673
721
  this.sessions.set(sessionId, { sessionId, title, armedAt: nowMs, lastSeenAt: nowMs, chunks: [], wordsSinceEval: 0, lastEvalAt: 0, lastCueAt: 0,
674
- inFlight: null, cues: [], evaluations: 0, failures: 0, breaker: false, stopReason: null, skipTracedAt: {} })
722
+ inFlight: null, cues: [], evaluations: 0, failures: 0, breaker: false, stopReason: null, skipTracedAt: {}, kinds })
723
+ if (opts.resume) this.deps.store.setResume(sessionId, kinds)
675
724
  this.deps.warmMemory()
676
- return answer(this.deps.store.counters(sessionId).cues)
725
+ return answer(this.deps.store.counters(sessionId).cues, kinds)
677
726
  }
678
727
 
679
728
  /** Stop or save: tombstone FIRST (so trailing chunks cannot revive it), then abort any evaluation in flight. */
@@ -683,7 +732,8 @@ export class LiveCuesV2Engine {
683
732
  s?.inFlight?.abort()
684
733
  const cues = s ? [...s.cues] : []
685
734
  return { nudgesGenerated: cues.length, protocol: 2, cues,
686
- nudges: cues.map(c => ({ nudge: c.headline, type: c.kind, priority: 2, timestamp: c.createdAt, cueId: c.cueId })) }
735
+ // v1 readers know two types: an extended kind is shown as its legacy kind there (the cues keep the real one).
736
+ nudges: cues.map(c => ({ nudge: c.headline, type: legacyCueKind(c.kind), priority: 2, timestamp: c.createdAt, cueId: c.cueId })) }
687
737
  }
688
738
 
689
739
  setPaused(sessionId: string, paused: boolean, seq?: number): { ok: true; paused: boolean; applied: boolean; pauseSeq: number } {
@@ -735,8 +785,11 @@ export class LiveCuesV2Engine {
735
785
  if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
736
786
  let s = this.sessions.get(sessionId)
737
787
  if (!s) {
738
- if (this.deps.env.COS_LIVE_CUES_AUTO !== '1') return
739
- try { this.arm(sessionId) } catch { return }
788
+ // 6.86.0: a meeting armed with resume (a Mac recording) is re-armed with its kinds after a server restart.
789
+ const resume = this.deps.store.resumeKinds(sessionId)
790
+ if (this.deps.env.COS_LIVE_CUES_AUTO !== '1' && !resume) return
791
+ try { this.arm(sessionId, resume ? { kinds: resume } : {}) } catch { return }
792
+ if (resume) this.deps.trace({ kind: 'resume', at: this.deps.now().toISOString(), sessionId, kindSet: resume })
740
793
  s = this.sessions.get(sessionId)
741
794
  if (!s) return
742
795
  }
@@ -827,7 +880,8 @@ export class LiveCuesV2Engine {
827
880
  if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) { trace.outcome = 'meeting_cap'; return }
828
881
  if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) { trace.outcome = 'daily_cap'; s.stopReason = 'budget_exhausted'; return }
829
882
  if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
830
- const { version, system } = liveCuesV2WriterSystem(this.deps.env)
883
+ const { version, system } = liveCuesV2WriterSystem(this.deps.env, s.kinds)
884
+ if (s.kinds === 'extended') trace.kindSet = 'extended'
831
885
  const model = liveCuesV2Model(this.deps.env)
832
886
  const previous = s.cues.slice(-3).map(c => c.detail)
833
887
  const promptMemory = kept.map(m => ({ id: m.id, date: m.date, title: m.title, notes: m.notes })) // the replay's exact keys
@@ -839,7 +893,8 @@ export class LiveCuesV2Engine {
839
893
  s.failures = 0
840
894
  s.stopReason = null
841
895
 
842
- const parsed = parseWriterCue(out.text, gate.kind, window, kept, this.deps.env.COS_LIVE_CUES_V2_NUMBER_GUARD === 'withhold')
896
+ const parsed = parseWriterCue(out.text, gate.kind, window, kept, this.deps.env.COS_LIVE_CUES_V2_NUMBER_GUARD === 'withhold',
897
+ s.kinds === 'extended' ? CUE_KINDS : LEGACY_CUE_KINDS)
843
898
  if (typeof parsed === 'string') { trace.outcome = parsed === 'null' ? 'writer_null' : `withheld_${parsed}`; return }
844
899
  const elapsed = this.deps.now().getTime() - started
845
900
  if (s.wordsSinceEval > L.staleWords || elapsed > L.staleMs) { trace.outcome = 'stale'; return }
@@ -869,7 +924,7 @@ export class LiveCuesV2Engine {
869
924
  s.lastCueAt = cue.createdAt
870
925
  this.deps.store.recordCue(s.sessionId)
871
926
  this.deps.emit('coaching_nudge_v2', cue as unknown as Record<string, unknown>)
872
- const v1: LiveCueV1Compat = { nudge: cue.headline, type: cue.kind, priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
927
+ const v1: LiveCueV1Compat = { nudge: cue.headline, type: legacyCueKind(cue.kind), priority: 2, cueId: cue.cueId, sessionId: s.sessionId, protocol: 2 }
873
928
  this.deps.emit('coaching_nudge', v1 as unknown as Record<string, unknown>)
874
929
  Object.assign(trace, { outcome: 'cue', cueId: cue.cueId, cueChars: cue.detail.length, sources: sources.length, ...(parsed.numberFlags.length ? { numberFlags: parsed.numberFlags.length } : {}) })
875
930
  } catch (error) {
@@ -941,6 +996,25 @@ export function liveCuesV2(): LiveCuesV2Engine {
941
996
  export function peekLiveCuesV2(): LiveCuesV2Engine | null { return engine }
942
997
  export function liveCuesV2Capability(): LiveCuesV2Capability { return liveCuesV2().capability() }
943
998
 
999
+ /** 6.86.0: what POST /api/mac-recording/start answers under `liveCues` when COS Control asked for cues. Never throws:
1000
+ * a refusal is a reason code and the recording starts regardless. */
1001
+ export type MacLiveCuesAnswer =
1002
+ | { armed: true; kinds: CueKind[]; cuesGenerated: number; paused: boolean; pauseSeq: number }
1003
+ | { armed: false; reason: string }
1004
+ /** Arms live cues for a Mac recording, under the same switch, caps and limits as every meeting, with the extended kinds and
1005
+ * resume (a restarted server re-arms it on its next chunk). With v2 off the answer is master_flag_off and the engine is
1006
+ * never built: a Mac recording never reaches the v1 Composer path. */
1007
+ export function armMacLiveCues(sessionId: string, env: NodeJS.ProcessEnv = process.env): MacLiveCuesAnswer {
1008
+ if (!liveCuesV2Enabled(env)) return { armed: false, reason: 'master_flag_off' }
1009
+ if (!liveCuesV2MacAllowed(env)) return { armed: false, reason: 'mac_cues_off' }
1010
+ try {
1011
+ const a = liveCuesV2().arm(sessionId, { resume: true })
1012
+ return { armed: true, kinds: a.kinds, cuesGenerated: a.cuesGenerated, paused: a.paused, pauseSeq: a.pauseSeq }
1013
+ } catch (error) {
1014
+ return { armed: false, reason: error instanceof LiveCuesV2ArmError ? error.code : 'unavailable' }
1015
+ }
1016
+ }
1017
+
944
1018
  /** GET/POST /api/live-cues/v2/settings (COS Control's toggle). */
945
1019
  export interface LiveCuesV2SettingsResponse { enabled: boolean; source: LiveCuesV2SettingSource; capability: LiveCuesV2Capability }
946
1020
  export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponse {
@@ -0,0 +1,87 @@
1
+ // 6.86.0: the newest provisional (grey) line of a live meeting, for COS Control's live meeting view.
2
+ //
3
+ // The preview route (POST /transcribe-stream/preview) already turns the still-open phrase into fast provisional text and
4
+ // hands it to the client that posted the audio. Control reads the meeting from the active-session file and does not post
5
+ // the glasses audio, so it never saw that text. This keeps ONE entry per live meeting, the newest preview, in memory only,
6
+ // and GET /transcribe-stream/provisional serves it. The canonical chunk that settles the same index removes it.
7
+ //
8
+ // Why memory and a route, not a field in active-sessions/<id>.json:
9
+ // - that file is the 4-hour crash-recovery record. Anything written there outlives a crash; this text must not outlive
10
+ // the live session, so it never touches disk;
11
+ // - the file is written with fsync before each chunk is acknowledged. Writing it again per preview would add a durable
12
+ // write on the recording path, and churn the stamp Control uses to skip unchanged reads.
13
+ //
14
+ // Never logged: this module has no console call, and the route answers only to an authenticated client. Bounded: at most
15
+ // LIVE_PROVISIONAL_LIMITS.maxSessions meetings, the last maxChars characters, and an entry older than ttlMs reads as none.
16
+
17
+ export const LIVE_PROVISIONAL_LIMITS = {
18
+ /** The last characters (code points) of the preview are kept: the newest words are the ones still changing. */
19
+ maxChars: 600,
20
+ maxSessions: 8,
21
+ /** A preview not replaced or settled within this long reads as none (the phone stopped posting, a stuck chunk). */
22
+ ttlMs: 30_000,
23
+ } as const
24
+
25
+ export interface LiveProvisional {
26
+ chunkIndex: number
27
+ previewGen: number
28
+ text: string
29
+ model: string
30
+ /** Epoch ms when the server stored it. */
31
+ at: number
32
+ }
33
+
34
+ export class LiveProvisionalStore {
35
+ private readonly entries = new Map<string, LiveProvisional>()
36
+ constructor(private readonly now: () => number = Date.now) {}
37
+
38
+ /** Keeps `entry` when it is newer than the one held: a later chunk, or the same chunk with a later previewGen. A
39
+ * preview that arrives after its chunk settled is refused by the caller (settledThrough), never stored here. */
40
+ record(sessionId: string, entry: Omit<LiveProvisional, 'at'>): boolean {
41
+ const text = Array.from(entry.text.replace(/\s+/g, ' ').trim()).slice(-LIVE_PROVISIONAL_LIMITS.maxChars).join('')
42
+ if (!text) return false
43
+ const held = this.entries.get(sessionId)
44
+ if (held && !this.expired(held) && (entry.chunkIndex < held.chunkIndex || (entry.chunkIndex === held.chunkIndex && entry.previewGen <= held.previewGen))) return false
45
+ this.entries.delete(sessionId) // re-insert, so Map order is oldest first for eviction
46
+ this.entries.set(sessionId, { chunkIndex: entry.chunkIndex, previewGen: entry.previewGen, text, model: entry.model, at: this.now() })
47
+ for (const [id, e] of this.entries) if (this.expired(e)) this.entries.delete(id)
48
+ while (this.entries.size > LIVE_PROVISIONAL_LIMITS.maxSessions) {
49
+ const oldest = this.entries.keys().next().value
50
+ if (oldest === undefined) break
51
+ this.entries.delete(oldest)
52
+ }
53
+ return true
54
+ }
55
+
56
+ /** The canonical answer for `chunkIndex` landed (text, silence or a drop): a preview for it or an earlier chunk goes. */
57
+ settle(sessionId: string, chunkIndex: number): void {
58
+ const held = this.entries.get(sessionId)
59
+ if (held && held.chunkIndex <= chunkIndex) this.entries.delete(sessionId)
60
+ }
61
+
62
+ /** The meeting closed (saved, discarded, expired, quarantined). */
63
+ clear(sessionId: string): void { this.entries.delete(sessionId) }
64
+
65
+ read(sessionId: string): LiveProvisional | null {
66
+ const held = this.entries.get(sessionId)
67
+ if (!held) return null
68
+ if (this.expired(held)) { this.entries.delete(sessionId); return null }
69
+ return { ...held }
70
+ }
71
+
72
+ size(): number { return this.entries.size }
73
+
74
+ private expired(e: LiveProvisional): boolean { return this.now() - e.at > LIVE_PROVISIONAL_LIMITS.ttlMs }
75
+ }
76
+
77
+ let store: LiveProvisionalStore | null = null
78
+ export function liveProvisional(): LiveProvisionalStore { return (store ??= new LiveProvisionalStore()) }
79
+
80
+ /** 6.86.0: what /api/health and /api/models publish as `capabilities.liveMeeting`, so COS Control gates the live meeting
81
+ * view by capability, never by a status code. Constants only (health is public). An older server omits the key.
82
+ * - macLiveCues: POST /api/mac-recording/start takes `liveCues: true` and answers `liveCues: {armed, ...}`.
83
+ * - provisional: GET <route>?sessionId= serves the newest preview of a live meeting.
84
+ * - chunkLevels (6.87.0): the active-session record keeps `chunkLevels[{i, level, speechSec}]` (lib/chunk-levels.ts). */
85
+ export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number }; chunkLevels: true } {
86
+ return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs }, chunkLevels: true }
87
+ }
@@ -83,7 +83,7 @@ export interface OwnerMatch { heard: string; owner: string }
83
83
  /**
84
84
  * Whether the question names the owner, allowing ASR spellings. Full name: first and last words, in
85
85
  * order and adjacent. Surname alone: only after a lead-in word ("who is Ukoma"), never after another
86
- * first name ("Isaiah Ukaoma" is someone else). First name alone: only when it is the whole subject of
86
+ * first name ("Rafael Ukaoma" is someone else). First name alone: only when it is the whole subject of
87
87
  * a who question, compared exactly.
88
88
  */
89
89
  export function matchOwnerName(question: string, ownerName: string | null | undefined): OwnerMatch | null {
@@ -151,7 +151,7 @@ const LEAD_WORDS = new Set('i im am we were are it its is starting leaving going
151
151
  export const US_STATE_CODES = new Set('AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR GU VI AS MP'.split(' '))
152
152
  /**
153
153
  * Larger US cities and the owner's Austin metro: a typed "City, ST" origin must name one of these (static, no
154
- * private data), so "Amira TX" or "Silas PM" are never places.
154
+ * private data), so "Talia TX" or "Silas PM" are never places.
155
155
  */
156
156
  const US_CITIES = new Set(`new york|los angeles|chicago|houston|phoenix|philadelphia|san antonio|san diego|dallas|san jose|austin|jacksonville|fort worth|columbus|charlotte|san francisco|indianapolis|seattle|denver|washington|boston|el paso|nashville|detroit|oklahoma city|portland|las vegas|memphis|louisville|baltimore|milwaukee|albuquerque|tucson|fresno|mesa|sacramento|atlanta|kansas city|colorado springs|omaha|raleigh|miami|long beach|virginia beach|oakland|minneapolis|tulsa|tampa|arlington|new orleans|wichita|cleveland|bakersfield|aurora|anaheim|honolulu|santa ana|riverside|corpus christi|lexington|henderson|stockton|saint paul|st paul|st louis|saint louis|cincinnati|pittsburgh|greensboro|anchorage|plano|lincoln|orlando|irvine|newark|durham|chula vista|toledo|fort wayne|st petersburg|laredo|jersey city|chandler|madison|lubbock|scottsdale|reno|buffalo|gilbert|glendale|north las vegas|winston salem|chesapeake|norfolk|fremont|garland|irving|hialeah|richmond|boise|spokane|baton rouge|tacoma|san bernardino|modesto|fontana|des moines|santa clarita|fayetteville|birmingham|oxnard|rochester|grand rapids|huntsville|salt lake city|frisco|yonkers|amarillo|huntington beach|mckinney|montgomery|augusta|akron|little rock|tempe|overland park|grand prairie|tallahassee|cape coral|mobile|knoxville|shreveport|worcester|sioux falls|chattanooga|brownsville|fort lauderdale|providence|santa rosa|peoria|salem|eugene|cary|fort collins|springfield|jackson|alexandria|lancaster|pasadena|naperville|bellevue|killeen|midland|odessa|savannah|syracuse|denton|waco|charleston|hartford|new haven|stamford|columbia|santa clara|ann arbor|cambridge|lansing|evansville|college station|beaumont|provo|boulder|tyler|las cruces|green bay|san mateo|burbank|south bend|davenport|tuscaloosa|san angelo|round rock|cedar park|georgetown|leander|pflugerville|san marcos|new braunfels|liberty hill|hutto|kyle|buda|lakeway|bee cave|temple|bryan|abilene|wichita falls|galveston|mcallen|edinburg|pearland|sugar land|the woodlands|conroe|katy|allen|richardson|carrollton|lewisville|flower mound|mesquite|harlingen|key west|palm springs|santa fe|flagstaff|billings|fargo|bismarck|cheyenne|casper|missoula|juneau|burlington|portsmouth|manchester|wilmington|dover|annapolis|trenton|albany|montpelier|concord|augusta|bangor|duluth|des plaines|evanston|topeka|lawrence|olathe|independence|st joseph|joplin|branson|springdale|bentonville|rogers|fayetteville|hot springs|jonesboro|norman|edmond|lafayette|lake charles|monroe|gulfport|biloxi|hattiesburg|pensacola|gainesville|daytona beach|sarasota|naples|fort myers|clearwater|lakeland|ocala|asheville|greenville|myrtle beach|columbia|athens|macon|dothan|chattanooga|knoxville|murfreesboro|clarksville|bowling green|owensboro|dayton|youngstown|canton|erie|scranton|allentown|reading|harrisburg|lancaster|altoona|morgantown|roanoke|lynchburg|charlottesville|norfolk|hampton|newport news`.split('|'))
157
157
  /** A full city label the user typed: a listed US city, then a real state code ("Dallas, TX", "San Antonio TX"). */
@@ -10,7 +10,7 @@
10
10
  // SCREEN AWARENESS. On-screen text lines COS Control recorded during the meeting
11
11
  // (transcript-cleanup-screen.ts), shown beside each passage by time.
12
12
  //
13
- // TERMS. Every source contributes terms: capitalised words and runs of them ("Queen
13
+ // TERMS. Every source contributes terms: capitalised words and runs of them ("Grace
14
14
  // Ukaoma", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
15
15
  // may write a term from this meeting's screen or files, the model may write any term,
16
16
  // and a term the model writes that came from the meeting (not the dictionary) becomes a
@@ -86,7 +86,7 @@ export function extractTerms(text: string, max: number = CONTEXT_LIMITS.termsPer
86
86
  const w = words[i].w
87
87
  const capital = /^\p{Lu}/u.test(w)
88
88
  if (looksLikeName(w)) out.add(w)
89
- // "Queen Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
89
+ // "Grace Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
90
90
  if (capital && i + 1 < words.length && text.slice(words[i].end, words[i + 1].at) === ' ' && looksLikeName(words[i + 1].w)) {
91
91
  out.add(`${w} ${words[i + 1].w}`)
92
92
  }
@@ -5,7 +5,7 @@
5
5
  // servers, no hooks, an empty scratch workspace, a pinned model id, low effort, a
6
6
  // bounded output budget. Never a direct SDK. The doubtful spans of a meeting go in ONE
7
7
  // batched prompt; the answer is strict JSON, one entry per span id:
8
- // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Queen Ukaoma"}]}
8
+ // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Grace Ukaoma"}]}
9
9
  //
10
10
  // THE GUARDS. Every replacement is checked before anything is applied. It is rejected if
11
11
  // unknown_span its id was not sent
@@ -19,7 +19,7 @@
19
19
  // adjacent pieces joined ("U coma"), none a function word; or the
20
20
  // leading-y rule at any confidence ("Yukoma" for Ukaoma)
21
21
  // oov_capitalized a capitalised word mid-sentence that is no known name and no common
22
- // word, within two words of a known name ("Queen Eucoma")
22
+ // word, within two words of a known name ("Grace Eucoma")
23
23
  // repeat a phrase of 2 to 4 words (or one long word) said three or more
24
24
  // times in a row: the classic decoder loop
25
25
  // Flagged words one word apart merge; a span holds at most 6 words (12 for a repeat)
@@ -405,7 +405,7 @@ export function recordSuggestions(applied: readonly AppliedEdit[], pack: Context
405
405
  for (const edit of applied) {
406
406
  const heardKeys = new Set([...edit.original.matchAll(WORDS)].map(m => fold(m[0])))
407
407
  const ws = [...edit.replacement.matchAll(WORDS)].map(m => m[0])
408
- // Longest first: "Queen Ukaoma" is one suggestion, not that plus "Ukaoma".
408
+ // Longest first: "Grace Ukaoma" is one suggestion, not that plus "Ukaoma".
409
409
  const candidates = [...new Set<string>([edit.replacement, ...ws.slice(0, -1).map((w, i) => `${w} ${ws[i + 1]}`), ...ws])]
410
410
  .sort((a, b) => b.split(' ').length - a.split(' ').length)
411
411
  const covered = new Set<string>()
@@ -73,6 +73,14 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
73
73
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
74
74
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
75
75
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
76
+ import { liveMeetingCapability } from '../lib/live-provisional.js'
77
+ import { CUE_KINDS } from '../../shared/live-cues-v2.js'
78
+
79
+ /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
80
+ * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
81
+ function withCueKinds<T extends object>(capability: T): T & { kinds: string[] } {
82
+ return { ...capability, kinds: [...CUE_KINDS] }
83
+ }
76
84
  import { transcriptCleanupAdvertised } from './transcript-cleanup.js'
77
85
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
78
86
  import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
@@ -506,8 +514,9 @@ healthRouter.get('/health', async (_req, res) => {
506
514
  carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
507
515
  },
508
516
  cliDebug: CLI_DEBUG_CAPABILITY,
509
- liveCues,
517
+ liveCues: withCueKinds(liveCues),
510
518
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
519
+ liveMeeting: liveMeetingCapability(),
511
520
  // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
512
521
  transcriptCleanup: transcriptCleanupAdvertised(),
513
522
  richMedia: {
@@ -703,8 +712,9 @@ healthRouter.get('/models', async (req, res) => {
703
712
  // Same helper as /api/health — the companion's 15s liveness poll reads
704
713
  // THIS surface, so a value present only on /api/health leaves the
705
714
  // live-cues indicator blind.
706
- liveCues: liveCuesCapability(),
715
+ liveCues: withCueKinds(liveCuesCapability()),
707
716
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
717
+ liveMeeting: liveMeetingCapability(),
708
718
  richMedia: {
709
719
  video: richMedia.video,
710
720
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -1,7 +1,9 @@
1
1
  // Live Cues routes. Authenticated by the global /api X-Cos-Token middleware
2
2
  // (index.ts:148) like every other /api route — no route-local auth needed.
3
3
  //
4
- // POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
4
+ // POST /live-cues/start { sessionId, kinds? } -> { armed, sessionId, pipelinesUsed } (v1) or the v2 arm answer
5
+ // kinds (6.86.0, v2 only): 'legacy' (reinforce, challenge) or 'extended' (plus decision, promise, followUp). Absent: a
6
+ // Mac recording (mac_ id) gets extended, every other meeting legacy, so an older companion never sees a kind it drops.
5
7
  // POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
6
8
  // GET /live-cues/status -> engine snapshot + capability
7
9
  // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
@@ -45,8 +47,12 @@ liveCuesRouter.post('/live-cues/start', async (req, res) => {
45
47
  if (!sessionId || !SESSION_ID.test(sessionId)) {
46
48
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
47
49
  }
50
+ const kinds = (req.body as { kinds?: unknown } | undefined)?.kinds
51
+ if (kinds !== undefined && kinds !== 'legacy' && kinds !== 'extended') {
52
+ return res.status(400).json({ error: "kinds must be 'legacy' or 'extended'", reason: 'invalid_kinds' })
53
+ }
48
54
  try {
49
- if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId))
55
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId, kinds ? { kinds } : {}))
50
56
  const result = await armLiveCues(sessionId)
51
57
  return res.json(result)
52
58
  } catch (error) {
@@ -414,7 +414,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
414
414
  // plus the decoder's own low confidence (prompt-tail-guard.ts).
415
415
  const guarded = guardPromptTail({ text: result.text, words: result.words, speech: speechWindowsFromWav(audio) })
416
416
  for (const drop of guarded.dropped) {
417
- console.warn(`[prompt-draft] tail_drop ${draftId}/${chunkIndex} ${purpose}/${mode} (${drop.reason}, start=${drop.startSec ?? '?'}s, lastSpeech=${guarded.lastSpeechEndSec ?? '?'}s, p=${drop.meanProbability?.toFixed(2) ?? '?'}): "${drop.text.slice(0, 100)}"`)
417
+ console.warn(`[prompt-draft] tail_drop ${draftId}/${chunkIndex} ${purpose}/${mode} (${drop.reason}, start=${drop.startSec ?? '?'}s, lastSpeech=${guarded.lastSpeechEndSec ?? '?'}s, p=${drop.meanProbability?.toFixed(2) ?? '?'}): chars=${drop.text.length}`)
418
418
  }
419
419
  if (guarded.dropped.length > 0 && !guarded.text.trim()) throw new NoSpeechDetectedError(result.text)
420
420
  const text = sanitizeTranscript(draftId, guarded.text)
@@ -115,6 +115,9 @@ import {
115
115
  type MaintenanceWorkLease,
116
116
  } from '../lib/maintenance-lifecycle.js'
117
117
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
118
+ import { armMacLiveCues, type MacLiveCuesAnswer } from '../lib/live-cues-v2.js'
119
+ import { liveProvisional } from '../lib/live-provisional.js'
120
+ import { chunkLevel, restoreChunkLevels, withChunkLevel, type ChunkLevel } from '../lib/chunk-levels.js'
118
121
  import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
119
122
  import {
120
123
  scheduleProgressiveHqCheckpoint,
@@ -346,6 +349,8 @@ interface TranscriptSession {
346
349
  /** Exact replay records for silent/filtered ASR completions. */
347
350
  emptyCompletions?: Record<string, EmptyTranscriptCompletion>
348
351
  maxChunkIndex?: number
352
+ /** 6.87.0: two numbers per chunk (level 0 to 1, Silero speech seconds) for Control's level strip. Never text. */
353
+ chunkLevels?: ChunkLevel[]
349
354
  /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
350
355
  lastActivityAt: number
351
356
  // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
@@ -711,6 +716,7 @@ function persistSessionRequired(sessionId: string): void {
711
716
  emptyCompletions: session.emptyCompletions ?? {},
712
717
  maxChunkIndex: session.maxChunkIndex ?? -1,
713
718
  providerCandidates: session.providerCandidates ?? {},
719
+ chunkLevels: session.chunkLevels ?? [],
714
720
  })
715
721
  try {
716
722
  durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
@@ -899,6 +905,7 @@ function recoverSessions(): void {
899
905
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
900
906
  ? data.providerCandidates
901
907
  : {},
908
+ chunkLevels: restoreChunkLevels(data.chunkLevels),
902
909
  }
903
910
  // Retroactively strip inline hallucinations from recovered chunks.
904
911
  // Two-pass: (1) scan all chunks to build frequency/blocklist,
@@ -1409,10 +1416,23 @@ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): vo
1409
1416
  }
1410
1417
  }
1411
1418
 
1412
- function recordAsrCompleted(session: TranscriptSession, chunkIndex: number): void {
1419
+ function recordAsrCompleted(session: TranscriptSession, chunkIndex: number, sessionId: string): void {
1413
1420
  if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
1414
1421
  if (!session.asrCompletedIndices) session.asrCompletedIndices = []
1415
1422
  insertSortedUnique(session.asrCompletedIndices, chunkIndex)
1423
+ // 6.86.0: the canonical answer replaces the grey provisional line for this chunk (live-provisional.ts).
1424
+ liveProvisional().settle(sessionId, chunkIndex)
1425
+ }
1426
+
1427
+ /** 6.86.0: keep the newest preview of a LIVE meeting for COS Control's grey line. Only a session this server holds (a
1428
+ * close removes it from `sessions`), and only while that chunk is still open: a preview that lands after its chunk (or a
1429
+ * later one) settled is stale. Memory only; never logged. */
1430
+ function noteLiveProvisional(sessionId: string, chunkIndex: number, previewGen: number, text: string, model: string): void {
1431
+ const session = sessions.get(sessionId)
1432
+ if (!session) return
1433
+ const completed = session.asrCompletedIndices ?? []
1434
+ if (completed.length && chunkIndex <= completed[completed.length - 1]) return
1435
+ liveProvisional().record(sessionId, { chunkIndex, previewGen, text, model })
1416
1436
  }
1417
1437
 
1418
1438
  function canonicalIndices(session: TranscriptSession): number[] {
@@ -1773,6 +1793,7 @@ function finishClosingTranscriptSession(
1773
1793
  sessions.delete(sessionId)
1774
1794
  sessionAudioBytes.delete(sessionId)
1775
1795
  sessionAudioWrites.delete(sessionId)
1796
+ liveProvisional().clear(sessionId)
1776
1797
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
1777
1798
  clearSessionHallucinationState(sessionId)
1778
1799
  // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
@@ -2088,7 +2109,7 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
2088
2109
  const dropReason = streamSilenceDropReason(trimmedText, isQuiet)
2089
2110
  const flagAllows = dropReason === 'thankyou_silence' ? THANKYOU_FILTER : STRIP_BRAND_URLS
2090
2111
  if (dropReason && flagAllows) {
2091
- console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): "${trimmedText.slice(0, 60)}"`)
2112
+ console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): chars=${trimmedText.length}`)
2092
2113
  return { text: '', fallbackReason: dropReason }
2093
2114
  }
2094
2115
  }
@@ -2106,7 +2127,7 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
2106
2127
  const streak = (session.vocabEchoStreak ?? 0) + 1
2107
2128
  session.vocabEchoStreak = streak
2108
2129
  if (isQuiet || streak >= 2 || isCrossChunkRepeat(session, trimmedText)) {
2109
- console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): "${trimmedText.slice(0, 60)}"`)
2130
+ console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): chars=${trimmedText.length}`)
2110
2131
  return { text: '', fallbackReason: 'vocab_echo' }
2111
2132
  }
2112
2133
  } else if (trimmedText) {
@@ -2377,7 +2398,7 @@ async function processStreamChunk(opts: {
2377
2398
  if (alreadyCanonical?.text) {
2378
2399
  session.lastActivityAt = Date.now()
2379
2400
  recordReceivedChunk(session, chunkIndex)
2380
- recordAsrCompleted(session, chunkIndex)
2401
+ recordAsrCompleted(session, chunkIndex, sessionId)
2381
2402
  if (candidate && candidateRecordKey) {
2382
2403
  session.providerCandidates![candidateRecordKey].accepted =
2383
2404
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
@@ -2390,7 +2411,7 @@ async function processStreamChunk(opts: {
2390
2411
  if (alreadyEmpty) {
2391
2412
  session.lastActivityAt = Date.now()
2392
2413
  recordReceivedChunk(session, chunkIndex)
2393
- recordAsrCompleted(session, chunkIndex)
2414
+ recordAsrCompleted(session, chunkIndex, sessionId)
2394
2415
  persistSessionRequired(sessionId)
2395
2416
  return emptyChunkResponse(alreadyEmpty, sessionId, chunkIndex)
2396
2417
  }
@@ -2423,6 +2444,8 @@ async function processStreamChunk(opts: {
2423
2444
  const quiet = decideQuiet(audioBuffer, stt.quietGate)
2424
2445
  const rms = quiet.rms
2425
2446
  const isQuiet = quiet.isQuiet
2447
+ // 6.87.0: the level strip's two numbers, on the record every terminal path below persists. Not text, never logged.
2448
+ session.chunkLevels = withChunkLevel(session.chunkLevels, chunkLevel(chunkIndex, rms, quiet.speechSec))
2426
2449
  const whisperAudio = isQuiet ? audioBuffer : await asrFrontEnd(audioBuffer, stt.frontEnd)
2427
2450
  const whisperContext = recentWhisperContext(sessionId, session)
2428
2451
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
@@ -2524,7 +2547,7 @@ async function processStreamChunk(opts: {
2524
2547
  if (labels.segments) labels.segments = labels.segments.map(segment => policyLabel(segment, capturePolicy))
2525
2548
 
2526
2549
  if (!trimmedText) {
2527
- console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
2550
+ console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): chars=${rawText.length}`)
2528
2551
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
2529
2552
  session.providerCandidates[candidateRecordKey].accepted = false
2530
2553
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
@@ -2543,7 +2566,7 @@ async function processStreamChunk(opts: {
2543
2566
  }
2544
2567
  session.emptyCompletions ??= {}
2545
2568
  session.emptyCompletions[String(chunkIndex)] = emptyCompletion
2546
- recordAsrCompleted(session, chunkIndex)
2569
+ recordAsrCompleted(session, chunkIndex, sessionId)
2547
2570
  persistSessionRequired(sessionId)
2548
2571
  scheduleSessionProgressiveHq(sessionId)
2549
2572
  return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
@@ -2578,12 +2601,12 @@ async function processStreamChunk(opts: {
2578
2601
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
2579
2602
  }
2580
2603
  session.lastActivityAt = Date.now()
2581
- recordAsrCompleted(session, chunkIndex)
2604
+ recordAsrCompleted(session, chunkIndex, sessionId)
2582
2605
  persistSessionRequired(sessionId)
2583
2606
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
2584
2607
  }
2585
2608
  session.chunks[chunkIndex] = chunk
2586
- recordAsrCompleted(session, chunkIndex)
2609
+ recordAsrCompleted(session, chunkIndex, sessionId)
2587
2610
  if (candidate && candidateRecordKey) {
2588
2611
  if (session.providerCandidates?.[candidateRecordKey]) {
2589
2612
  session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
@@ -2690,6 +2713,7 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2690
2713
  if (!cleaned || sharedIsFullHallucination(cleaned)) {
2691
2714
  return void res.status(204).send()
2692
2715
  }
2716
+ noteLiveProvisional(sessionId, chunkIndex, previewGen, cleaned, result.model)
2693
2717
  return void res.json({
2694
2718
  sessionId,
2695
2719
  chunkIndex,
@@ -2708,6 +2732,20 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2708
2732
  }
2709
2733
  })
2710
2734
 
2735
+ /**
2736
+ * 6.86.0: GET /transcribe-stream/provisional?sessionId=<id>. The newest provisional text of a live meeting, for COS
2737
+ * Control's grey line (live-provisional.ts). Token-authenticated like every /api route; private, no-store. Memory only:
2738
+ * a restart, a save or a settled chunk empties it. `live` is false when this server holds no open session by that id.
2739
+ */
2740
+ transcribeStreamRouter.get('/transcribe-stream/provisional', (req, res) => {
2741
+ res.set('Cache-Control', 'private, no-store')
2742
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''
2743
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return void res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
2744
+ const live = sessions.has(sessionId)
2745
+ const held = live ? liveProvisional().read(sessionId) : null
2746
+ res.json({ v: 1, sessionId, live, provisional: held, serverInstanceId: getServerInstanceId() })
2747
+ })
2748
+
2711
2749
  /** Unlike the received ledger, only these canonical completions acknowledge ASR.
2712
2750
  * A native stop supplies the total captured chunks, including its last tail. */
2713
2751
  export function assertMacCaptureComplete(sessionId: string, expectedChunkCount: unknown): void {
@@ -2741,6 +2779,9 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2741
2779
  try {
2742
2780
  const serverInstanceId = requireMacPin(req)
2743
2781
  if (typeof req.body?.captureId !== 'string' || !MAC_CAPTURE_ID.test(req.body.captureId)) throw macError(400, 'invalid_capture_id', 'Invalid Mac capture ID')
2782
+ // 6.86.0: `liveCues: true` asks for live cues on this recording (COS Control's live meeting view). Checked before
2783
+ // anything is written, so a bad body admits nothing.
2784
+ if (req.body.liveCues !== undefined && typeof req.body.liveCues !== 'boolean') throw macError(400, 'invalid_live_cues', 'liveCues must be true or false')
2744
2785
  if (isSessionDeleted(req.body.captureId) || getMeetingStore().findBySessionId(req.body.captureId)) throw macError(410, 'session_closed', 'Capture is closed')
2745
2786
  // An existing non-Mac session may not acquire new source metadata.
2746
2787
  if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
@@ -2749,7 +2790,13 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2749
2790
  const session = getSession(recording.sessionId)
2750
2791
  if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
2751
2792
  persistSessionRequired(recording.sessionId)
2752
- res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId })
2793
+ // Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
2794
+ // reason code instead of throwing). Without `liveCues: true` the answer is byte for byte what 6.85.0 sent.
2795
+ let liveCues: MacLiveCuesAnswer | undefined
2796
+ if (req.body.liveCues === true) {
2797
+ try { liveCues = armMacLiveCues(recording.sessionId) } catch { liveCues = { armed: false, reason: 'unavailable' } }
2798
+ }
2799
+ res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}) })
2753
2800
  } catch (error) { sendStreamError(res, error) } finally { lease?.release() }
2754
2801
  })
2755
2802
  const macChunkLocks = new Set<string>()
@@ -341,7 +341,7 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
341
341
  ageHours: parseFloat(ageHours),
342
342
  expiresIn: `${Math.max(0, 72 - parseFloat(ageHours)).toFixed(1)}h`,
343
343
  // 6.45.3 — the chunk indices a reviewer can ask to hear (`?chunk=` on
344
- // the sample route). Queen, 2026-09-12: the Add-a-voice panel let her
344
+ // the sample route). Grace, 2026-09-12: the Add-a-voice panel let her
345
345
  // name a session but not listen to it; naming is a guess without this.
346
346
  chunkIndices: listExtAudioChunks(d.name),
347
347
  }
@@ -2,13 +2,29 @@
2
2
  * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate; optional capability fields since 6.76.2).
3
3
  * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
4
  * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
+ * 6.86.0 widens CueKind (additive for every meeting that did not opt in; see CueKind). A companion that has not taken this
6
+ * copy keeps validating the two legacy kinds, which is safe because it is never sent anything else.
5
7
  */
6
8
  export const LIVE_CUES_V2_PROTOCOL = 2 as const
7
9
  export const CUE_HEADLINE_MAX = 64
8
10
  export const CUE_DETAIL_MAX = 220
9
11
  export const CUE_FEEDBACK_REASON_MAX = 200
10
12
 
11
- export type CueKind = 'reinforce' | 'challenge'
13
+ /** 6.86.0: three more kinds for COS Control's live meeting view. A meeting gets them only when its armer opts in (a Mac
14
+ * recording, or POST /live-cues/start with `kinds: 'extended'`). Every other meeting keeps the two legacy kinds, so a
15
+ * companion that validates `kind` against reinforce | challenge never sees a cue it would drop. Readers must treat any
16
+ * kind they do not know as a plain cue, never as an error. */
17
+ export type CueKind = 'reinforce' | 'challenge' | 'decision' | 'promise' | 'followUp'
18
+ export const LEGACY_CUE_KINDS = ['reinforce', 'challenge'] as const
19
+ export const CUE_KINDS = ['reinforce', 'challenge', 'decision', 'promise', 'followUp'] as const
20
+ export type CueKindSet = 'legacy' | 'extended'
21
+ export function isCueKind(value: unknown): value is CueKind {
22
+ return typeof value === 'string' && (CUE_KINDS as readonly string[]).includes(value)
23
+ }
24
+ /** The legacy kind a v1 reader is shown for any kind (the `coaching_nudge` twin and the stop answer's `nudges`). */
25
+ export function legacyCueKind(kind: CueKind): 'reinforce' | 'challenge' {
26
+ return kind === 'reinforce' || kind === 'decision' || kind === 'promise' ? 'reinforce' : 'challenge'
27
+ }
12
28
 
13
29
  /** A past meeting the cue drew on (0 to 2 per cue). */
14
30
  export interface CueSourceRef { id: string; date: string; title: string }
@@ -31,7 +47,7 @@ export interface LiveCueV2 {
31
47
  }
32
48
 
33
49
  /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
- export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
50
+ export interface LiveCueV1Compat { nudge: string; type: 'reinforce' | 'challenge'; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
51
 
36
52
  /** `reason` when unavailable: master_flag_off, api_key_in_env, decision_invalid_provider, decision_not_configured (no key
37
53
  * for the selected decision provider; jev_not_configured when Jev was chosen and only a SubQ key exists), claude_not_found,
@@ -75,7 +91,7 @@ export function isLiveCueV2(value: unknown): value is LiveCueV2 {
75
91
  return v.protocol === 2 && typeof v.cueId === 'string' && v.cueId.length > 0 && typeof v.sessionId === 'string'
76
92
  && typeof v.headline === 'string' && v.headline.length > 0 && v.headline.length <= CUE_HEADLINE_MAX
77
93
  && typeof v.detail === 'string' && v.detail.length > 0 && v.detail.length <= CUE_DETAIL_MAX
78
- && (v.kind === 'reinforce' || v.kind === 'challenge')
94
+ && isCueKind(v.kind)
79
95
  && Array.isArray(v.sources) && v.sources.length <= 2
80
96
  && v.sources.every(s => !!s && typeof (s as CueSourceRef).id === 'string' && typeof (s as CueSourceRef).date === 'string' && typeof (s as CueSourceRef).title === 'string')
81
97
  && typeof v.gate === 'number' && typeof v.createdAt === 'number' && typeof v.model === 'string'