@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 +125 -6
- package/managed-runtime-contract.json +1 -0
- package/package.json +3 -1
- package/server/lib/chunk-levels.ts +64 -0
- package/server/lib/fuzzy-correct.ts +3 -5
- package/server/lib/live-cue-verdicts.ts +3 -2
- package/server/lib/live-cues-v2-prompts-kinds.ts +38 -0
- package/server/lib/live-cues-v2.ts +95 -21
- package/server/lib/live-provisional.ts +87 -0
- package/server/lib/meeting-ask-owner.ts +1 -1
- package/server/lib/meeting-fact-check.ts +1 -1
- package/server/lib/transcript-cleanup-context.ts +2 -2
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +1 -1
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/routes/health.ts +12 -2
- package/server/routes/live-cues.ts +8 -2
- package/server/routes/prompt-drafts.ts +1 -1
- package/server/routes/transcribe-stream.ts +57 -10
- package/server/routes/voice.ts +1 -1
- package/shared/live-cues-v2.ts +19 -3
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 ("
|
|
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-
|
|
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 (
|
|
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 ("
|
|
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
|
|
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 (
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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 (
|
|
309
|
-
|
|
310
|
-
console.log(`[fuzzy-correct] Applied ${replacements} correction(s)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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),
|
|
670
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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 ("
|
|
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 "
|
|
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 ("
|
|
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
|
-
// "
|
|
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":"
|
|
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 ("
|
|
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: "
|
|
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>()
|
package/server/routes/health.ts
CHANGED
|
@@ -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) ?? '?'}):
|
|
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}):
|
|
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}):
|
|
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'}):
|
|
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
|
-
|
|
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>()
|
package/server/routes/voice.ts
CHANGED
|
@@ -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).
|
|
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
|
}
|
package/shared/live-cues-v2.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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'
|