@gotcos/glasses-server 6.85.0 → 6.86.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,98 @@
1
+ ## 6.86.0 - Decision, promise and follow-up cues, live cues on Mac recordings, and the grey provisional line
2
+
3
+ The server half of COS Control's live meeting view (plan of 2026-10-22, decisions of 15:21). Three lanes: three more cue
4
+ kinds from the same single writer call, live cues on Mac recordings when COS Control asks for them, and the newest
5
+ provisional (grey) line of a live meeting for Control to show before the final text replaces it.
6
+
7
+ - **This adds spend for Mac meetings.** Until now a Mac recording never got live cues. From this release, a Mac
8
+ recording that COS Control starts with `liveCues: true` runs the live cues pipeline (the Jev gate and judge, and the
9
+ one Claude writer call per evaluation) for that meeting. It is capped by the same budgets as every meeting: the
10
+ master switch (COS Control's Live cues toggle, or `COS_LIVE_CUES_V2`), 25 cues and 50 writer calls a meeting, the
11
+ daily writer ceiling and gate COS Control sets, the weekly writer ceiling, the Jev daily token cap and breaker, and
12
+ the per-meeting breaker. There is no new LLM caller. `COS_LIVE_CUES_V2_MAC=0` switches Mac recordings off on their
13
+ own (answer `mac_cues_off`) and leaves glasses meetings alone. An older COS Control never sends `liveCues`, so it
14
+ starts no Mac cues and spends nothing new.
15
+ - **Pairs with the COS Control live meeting view** (the Control half ships separately). An older Control and every
16
+ companion are unaffected: glasses meetings keep reinforce and challenge, byte-identical writer rules, and the
17
+ same wire shapes.
18
+ - **Privacy: five more log lines stop printing what was said.** 6.85.0 took the text out of the per-chunk
19
+ `[perf] TOTAL` line. Five others still printed it: the three `[hallucination]` lines (a dropped brand URL, thank-you
20
+ or vocabulary echo, and a chunk the filter emptied, each with up to 60 characters of the chunk), the dictation
21
+ `[prompt-draft] tail_drop` line (up to 100 characters of the dropped sentence) and `[fuzzy-correct]` (the misheard
22
+ word and its correction, up to five pairs). Each now logs its reason and a character count; fuzzy-correct logs how
23
+ many words it fixed. `transcript-log-privacy-686.test.ts` scans every server and shared source for a console call
24
+ that prints a slice of transcript text, and drives fuzzy-correct; restoring each of the five old lines fails it
25
+ (5 of 5). Nothing new logs transcript text, and the provisional line is held in memory only, never written to disk
26
+ or the log.
27
+ - **Checks (6.86.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a
28
+ scratch folder, COS_SCRIPTS_DIR unset: 582 files (581 passed, 1 skipped), 8800 tests, 8786 passed, 14 skipped, 0
29
+ 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
30
+ 63 killed, each by a named test, every target landing exactly once and restored byte for byte. The 6.79.x verdicts
31
+ gate's `schema-kind` mutant, retargeted to `isCueKind`, is still killed. `tsc --noEmit` clean. `npm pack --dry-run`:
32
+ 545 files (6.85.0's 543 plus `server/lib/live-provisional.ts` and `server/lib/live-cues-v2-prompts-kinds.ts`), no
33
+ test, gate or fixture.
34
+
35
+ ### Cue kinds: decision, promise and followUp
36
+
37
+ - **`CueKind`** (`shared/live-cues-v2.ts`) is now `reinforce | challenge | decision | promise | followUp`, with
38
+ `CUE_KINDS`, `LEGACY_CUE_KINDS`, `isCueKind` and `legacyCueKind`. `isLiveCueV2` and the cue log reader accept the
39
+ five kinds and still refuse anything else.
40
+ - **Opt-in per meeting.** A meeting gets the new kinds only when its armer asks: a Mac recording (`mac_` capture id)
41
+ by default, or `POST /api/live-cues/start {sessionId, kinds: 'extended'}`. `kinds: 'legacy'` forces the two old
42
+ kinds; any other value is 400 `invalid_kinds` and arms nothing. A glasses meeting armed as before keeps reinforce and
43
+ challenge: a companion that has not taken the new contract validates `kind` against those two and would drop
44
+ anything else. The arm answer adds `kinds` (the list this meeting may carry). A repeat arm keeps the first kinds.
45
+ - **Writer rules v4** (`server/lib/live-cues-v2-prompts-kinds.ts`) run only for those meetings: v3 with rule 3 (five
46
+ kinds: a decision says what was decided, a promise says who owes what, a followUp names what is still open), rule 4
47
+ (a decision, promise or followUp may record what the room said) and the schema kind list swapped. Everything else
48
+ is v3: one cue, at most 200 characters, the same evidence rules, null for noise. Built from v3 by exact swaps, so a
49
+ regenerated v3 fails the suite instead of shipping under the v4 label. Not replay-measured; rate them in Control.
50
+ The generated `live-cues-v2-prompts.ts` is unchanged, so the v2 and v3 replay pins hold.
51
+ - **Old readers.** The v1 `coaching_nudge` twin and the stop answer's `nudges[].type` carry a legacy type (decision
52
+ and promise read as reinforce, followUp as challenge). The v2 cue, the status route and history keep the real kind.
53
+ COS Control before this release shows an unknown kind as its raw name (`MeetingAskCore.kindLabel` default).
54
+ - **Trace.** An evaluation of an extended meeting traces `kindSet: 'extended'` and `prompt: 'v4'`; the trace still
55
+ carries no transcript text.
56
+
57
+ ### Live cues on Mac recordings
58
+
59
+ - **Arm point.** `POST /api/mac-recording/start {captureId, startTime, sourceType, liveCues: true}`. Arming runs only
60
+ after the recording is admitted, and never fails the start: the answer adds `liveCues: {armed: true, kinds,
61
+ cuesGenerated, paused, pauseSeq}` or `{armed: false, reason}` (`master_flag_off`, `mac_cues_off`, `meeting_ended`,
62
+ `budget_exhausted`, `jev_cap_reached`, and the other capability reasons). With v2 off the engine is never built,
63
+ so a Mac recording never reaches the v1 Composer path. `liveCues` that is not a boolean is 400 `invalid_live_cues`
64
+ before anything is written. Without `liveCues` the answer is byte for byte 6.85.0's.
65
+ - **Resume.** A Mac recording armed from the start route is remembered in `live-cues-v2-state.json` (`resume`, with
66
+ its kinds). A restarted server re-arms it on its next chunk and traces one `resume` row. Cleared when the meeting
67
+ ends; expires with the counters (24 h).
68
+ - **Title.** Mac meetings are titled `Mac Recording <date> <time>` in the gate, the writer prompt and the cue log.
69
+ - **Pause and stop** are the existing routes: `POST /api/live-cues/pause` and the save, which ends the meeting's
70
+ cues through `recording_stop`.
71
+
72
+ ### The grey provisional line
73
+
74
+ - **Route.** `GET /api/transcribe-stream/provisional?sessionId=<id>` (token-protected, `private, no-store`) answers
75
+ `{v: 1, sessionId, live, provisional: {chunkIndex, previewGen, text, model, at} | null, serverInstanceId}`.
76
+ - **What it holds.** The newest preview the preview route produced for a session this server holds open, one per
77
+ meeting: a later chunk, or the same chunk with a later `previewGen`, replaces it; a preview for a chunk that already
78
+ settled is refused. The canonical answer for that chunk (text, silence or a drop) removes it, and so does any close.
79
+ Bounded: the last 600 characters, 8 meetings, 30 s without an update reads as none.
80
+ - **Why a route and not the active-session file.** That file is the 4-hour crash-recovery record and is written with
81
+ fsync before each chunk is acknowledged. Provisional text must not outlive the live session, and writing the file
82
+ per preview would add a durable write on the recording path and churn the stamp Control uses to skip reads.
83
+ - **Mac recordings.** COS Control does not post previews today, so a Mac recording has no provisional text until it
84
+ does. The minimal path needs no server change: Control posts a copy of the still-open chunk (canonical 16 kHz mono
85
+ WAV, at most 512 KB, about 16 s) to the existing `POST /api/transcribe-stream/preview` with the capture id, the open
86
+ chunk index and a rising `previewGen`. The answer carries the text, and the route above serves it too. Preview runs
87
+ Whisper Turbo (or Parakeet when selected); transcription never calls an LLM.
88
+
89
+ ### Capabilities
90
+
91
+ - `capabilities.liveCues.kinds`: the five kinds, beside the unchanged v1 `available` and `reason`.
92
+ - `capabilities.liveMeeting: {protocol: 1, macLiveCues: true, provisional: {route, maxChars: 600, ttlMs: 30000}}`.
93
+ - Both on `/api/health` and `/api/models` from one helper. An older server omits them, which is how COS Control knows
94
+ to hide the inline kinds, skip `liveCues` on the Mac start and skip the grey line.
95
+
1
96
  ## 6.85.0 - Your polish instructions, and COS Control can list the questions COS is answering right now
2
97
 
3
98
  Two lanes and a privacy fix in one release. Your own polish instructions for AI dictation polish, layered after
@@ -626,7 +721,7 @@ Pairs with COS Glasses companion 6.10.645. Includes 6.77.0. No COS Control chang
626
721
 
627
722
  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
723
 
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.
724
+ - 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
725
  - 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
726
  - 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
727
  - 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 +755,7 @@ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.322. Includes 6.76
660
755
  - 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
756
  - 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
757
  - 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.
758
+ - 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
759
 
665
760
  ## 6.76.1 - Stop a glasses meeting from COS Control
666
761
 
@@ -674,10 +769,10 @@ Pairs with COS Glasses companion 6.10.644 and COS Control 0.5.321. Includes 6.76
674
769
  - 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
770
  ## 6.76.0 - Live COS Ask knows who owns this COS, looks in COS for person questions, and times every phase
676
771
 
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.
772
+ - 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
773
  - 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.
774
+ - 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.
775
+ - 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
776
  - 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
777
  - 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
778
  - 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 +1890,7 @@ The phone can list the skills this Mac will actually run.
1795
1890
 
1796
1891
  - `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
1892
  - 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").
1893
+ - 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
1894
  - 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
1895
  - 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
1896
 
@@ -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.86.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,7 @@
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",
137
138
  "docs/test-plan-6.71.8.md",
138
139
  "docs/test-plan-6.71.9.md",
139
140
  "docs/test-plan-6.71.10.md",
@@ -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,86 @@
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
+ export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number } } {
85
+ return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs } }
86
+ }
@@ -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,8 @@ 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'
118
120
  import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
119
121
  import {
120
122
  scheduleProgressiveHqCheckpoint,
@@ -1409,10 +1411,23 @@ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): vo
1409
1411
  }
1410
1412
  }
1411
1413
 
1412
- function recordAsrCompleted(session: TranscriptSession, chunkIndex: number): void {
1414
+ function recordAsrCompleted(session: TranscriptSession, chunkIndex: number, sessionId: string): void {
1413
1415
  if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
1414
1416
  if (!session.asrCompletedIndices) session.asrCompletedIndices = []
1415
1417
  insertSortedUnique(session.asrCompletedIndices, chunkIndex)
1418
+ // 6.86.0: the canonical answer replaces the grey provisional line for this chunk (live-provisional.ts).
1419
+ liveProvisional().settle(sessionId, chunkIndex)
1420
+ }
1421
+
1422
+ /** 6.86.0: keep the newest preview of a LIVE meeting for COS Control's grey line. Only a session this server holds (a
1423
+ * close removes it from `sessions`), and only while that chunk is still open: a preview that lands after its chunk (or a
1424
+ * later one) settled is stale. Memory only; never logged. */
1425
+ function noteLiveProvisional(sessionId: string, chunkIndex: number, previewGen: number, text: string, model: string): void {
1426
+ const session = sessions.get(sessionId)
1427
+ if (!session) return
1428
+ const completed = session.asrCompletedIndices ?? []
1429
+ if (completed.length && chunkIndex <= completed[completed.length - 1]) return
1430
+ liveProvisional().record(sessionId, { chunkIndex, previewGen, text, model })
1416
1431
  }
1417
1432
 
1418
1433
  function canonicalIndices(session: TranscriptSession): number[] {
@@ -1773,6 +1788,7 @@ function finishClosingTranscriptSession(
1773
1788
  sessions.delete(sessionId)
1774
1789
  sessionAudioBytes.delete(sessionId)
1775
1790
  sessionAudioWrites.delete(sessionId)
1791
+ liveProvisional().clear(sessionId)
1776
1792
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
1777
1793
  clearSessionHallucinationState(sessionId)
1778
1794
  // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
@@ -2088,7 +2104,7 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
2088
2104
  const dropReason = streamSilenceDropReason(trimmedText, isQuiet)
2089
2105
  const flagAllows = dropReason === 'thankyou_silence' ? THANKYOU_FILTER : STRIP_BRAND_URLS
2090
2106
  if (dropReason && flagAllows) {
2091
- console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): "${trimmedText.slice(0, 60)}"`)
2107
+ console.log(`[hallucination] Dropped (${dropReason}, q=${isQuiet ? 1 : 0}): chars=${trimmedText.length}`)
2092
2108
  return { text: '', fallbackReason: dropReason }
2093
2109
  }
2094
2110
  }
@@ -2106,7 +2122,7 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
2106
2122
  const streak = (session.vocabEchoStreak ?? 0) + 1
2107
2123
  session.vocabEchoStreak = streak
2108
2124
  if (isQuiet || streak >= 2 || isCrossChunkRepeat(session, trimmedText)) {
2109
- console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): "${trimmedText.slice(0, 60)}"`)
2125
+ console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): chars=${trimmedText.length}`)
2110
2126
  return { text: '', fallbackReason: 'vocab_echo' }
2111
2127
  }
2112
2128
  } else if (trimmedText) {
@@ -2377,7 +2393,7 @@ async function processStreamChunk(opts: {
2377
2393
  if (alreadyCanonical?.text) {
2378
2394
  session.lastActivityAt = Date.now()
2379
2395
  recordReceivedChunk(session, chunkIndex)
2380
- recordAsrCompleted(session, chunkIndex)
2396
+ recordAsrCompleted(session, chunkIndex, sessionId)
2381
2397
  if (candidate && candidateRecordKey) {
2382
2398
  session.providerCandidates![candidateRecordKey].accepted =
2383
2399
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
@@ -2390,7 +2406,7 @@ async function processStreamChunk(opts: {
2390
2406
  if (alreadyEmpty) {
2391
2407
  session.lastActivityAt = Date.now()
2392
2408
  recordReceivedChunk(session, chunkIndex)
2393
- recordAsrCompleted(session, chunkIndex)
2409
+ recordAsrCompleted(session, chunkIndex, sessionId)
2394
2410
  persistSessionRequired(sessionId)
2395
2411
  return emptyChunkResponse(alreadyEmpty, sessionId, chunkIndex)
2396
2412
  }
@@ -2524,7 +2540,7 @@ async function processStreamChunk(opts: {
2524
2540
  if (labels.segments) labels.segments = labels.segments.map(segment => policyLabel(segment, capturePolicy))
2525
2541
 
2526
2542
  if (!trimmedText) {
2527
- console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
2543
+ console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): chars=${rawText.length}`)
2528
2544
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
2529
2545
  session.providerCandidates[candidateRecordKey].accepted = false
2530
2546
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
@@ -2543,7 +2559,7 @@ async function processStreamChunk(opts: {
2543
2559
  }
2544
2560
  session.emptyCompletions ??= {}
2545
2561
  session.emptyCompletions[String(chunkIndex)] = emptyCompletion
2546
- recordAsrCompleted(session, chunkIndex)
2562
+ recordAsrCompleted(session, chunkIndex, sessionId)
2547
2563
  persistSessionRequired(sessionId)
2548
2564
  scheduleSessionProgressiveHq(sessionId)
2549
2565
  return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
@@ -2578,12 +2594,12 @@ async function processStreamChunk(opts: {
2578
2594
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
2579
2595
  }
2580
2596
  session.lastActivityAt = Date.now()
2581
- recordAsrCompleted(session, chunkIndex)
2597
+ recordAsrCompleted(session, chunkIndex, sessionId)
2582
2598
  persistSessionRequired(sessionId)
2583
2599
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
2584
2600
  }
2585
2601
  session.chunks[chunkIndex] = chunk
2586
- recordAsrCompleted(session, chunkIndex)
2602
+ recordAsrCompleted(session, chunkIndex, sessionId)
2587
2603
  if (candidate && candidateRecordKey) {
2588
2604
  if (session.providerCandidates?.[candidateRecordKey]) {
2589
2605
  session.providerCandidates[candidateRecordKey].accepted = asrProvider === 'iphone-whisperkit-beta'
@@ -2690,6 +2706,7 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2690
2706
  if (!cleaned || sharedIsFullHallucination(cleaned)) {
2691
2707
  return void res.status(204).send()
2692
2708
  }
2709
+ noteLiveProvisional(sessionId, chunkIndex, previewGen, cleaned, result.model)
2693
2710
  return void res.json({
2694
2711
  sessionId,
2695
2712
  chunkIndex,
@@ -2708,6 +2725,20 @@ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
2708
2725
  }
2709
2726
  })
2710
2727
 
2728
+ /**
2729
+ * 6.86.0: GET /transcribe-stream/provisional?sessionId=<id>. The newest provisional text of a live meeting, for COS
2730
+ * Control's grey line (live-provisional.ts). Token-authenticated like every /api route; private, no-store. Memory only:
2731
+ * a restart, a save or a settled chunk empties it. `live` is false when this server holds no open session by that id.
2732
+ */
2733
+ transcribeStreamRouter.get('/transcribe-stream/provisional', (req, res) => {
2734
+ res.set('Cache-Control', 'private, no-store')
2735
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''
2736
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return void res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
2737
+ const live = sessions.has(sessionId)
2738
+ const held = live ? liveProvisional().read(sessionId) : null
2739
+ res.json({ v: 1, sessionId, live, provisional: held, serverInstanceId: getServerInstanceId() })
2740
+ })
2741
+
2711
2742
  /** Unlike the received ledger, only these canonical completions acknowledge ASR.
2712
2743
  * A native stop supplies the total captured chunks, including its last tail. */
2713
2744
  export function assertMacCaptureComplete(sessionId: string, expectedChunkCount: unknown): void {
@@ -2741,6 +2772,9 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2741
2772
  try {
2742
2773
  const serverInstanceId = requireMacPin(req)
2743
2774
  if (typeof req.body?.captureId !== 'string' || !MAC_CAPTURE_ID.test(req.body.captureId)) throw macError(400, 'invalid_capture_id', 'Invalid Mac capture ID')
2775
+ // 6.86.0: `liveCues: true` asks for live cues on this recording (COS Control's live meeting view). Checked before
2776
+ // anything is written, so a bad body admits nothing.
2777
+ if (req.body.liveCues !== undefined && typeof req.body.liveCues !== 'boolean') throw macError(400, 'invalid_live_cues', 'liveCues must be true or false')
2744
2778
  if (isSessionDeleted(req.body.captureId) || getMeetingStore().findBySessionId(req.body.captureId)) throw macError(410, 'session_closed', 'Capture is closed')
2745
2779
  // An existing non-Mac session may not acquire new source metadata.
2746
2780
  if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
@@ -2749,7 +2783,13 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2749
2783
  const session = getSession(recording.sessionId)
2750
2784
  if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
2751
2785
  persistSessionRequired(recording.sessionId)
2752
- res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId })
2786
+ // Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
2787
+ // reason code instead of throwing). Without `liveCues: true` the answer is byte for byte what 6.85.0 sent.
2788
+ let liveCues: MacLiveCuesAnswer | undefined
2789
+ if (req.body.liveCues === true) {
2790
+ try { liveCues = armMacLiveCues(recording.sessionId) } catch { liveCues = { armed: false, reason: 'unavailable' } }
2791
+ }
2792
+ res.json({ ...MAC_AUDIO, ...recording, replayed, serverInstanceId, ...(liveCues ? { liveCues } : {}) })
2753
2793
  } catch (error) { sendStreamError(res, error) } finally { lease?.release() }
2754
2794
  })
2755
2795
  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'