@gotcos/glasses-server 6.88.0 → 6.90.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/.env.example CHANGED
@@ -53,6 +53,20 @@ BIND_HOST=0.0.0.0
53
53
  # server after Wi-Fi/Tailscale changes and process restarts.
54
54
  # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
55
55
 
56
+ # 6.89.0 Tailscale self-heal. After a restart, a wake from sleep, a Tailscale
57
+ # restart or a network change, or when a recently active phone goes quiet while
58
+ # Tailscale says it is online, the server runs a bounded `tailscale ping` at it
59
+ # (at most one a minute per phone, 30 an hour). On by default; 0 turns it off.
60
+ # See docs/tailscale-self-heal.md.
61
+ # COS_TAILSCALE_NUDGE=0
62
+
63
+ # 6.90.0 Mac recording voice labels. After a Mac recording is saved, one Nemotron
64
+ # pass over the whole meeting labels its voices Voice 1, Voice 2, ... (unnamed,
65
+ # stable for the meeting). No voice profile is read or written, and renaming a
66
+ # Voice N is text only. Crowded or unclear rooms stay Ext. On by default; 0 turns
67
+ # it off and Mac recordings stay all Ext.
68
+ # COS_MAC_VOICE_LABELS=0
69
+
56
70
  # Build 204+ uses server-owned durable query jobs by default. The server fsyncs an
57
71
  # accepted prompt before returning 202, then keeps provider work running if the
58
72
  # phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
package/CHANGELOG.md CHANGED
@@ -1,3 +1,70 @@
1
+ ## 6.90.0 - Mac recordings tell voices apart: unnamed Voice 1, Voice 2, renamed as text only
2
+
3
+ Miles, 2026-10-23 09:35: "Voice 1/2 + rename", split out of 6.87.0 at 10:06. Every saved Mac recording was 100% `Ext`:
4
+ Nemotron heard a second voice in about 1 chunk in 10, and the 0.88 naming floor and same-label turn merge threw that
5
+ away. This keeps Mac audio unnamed (no calibration exists to name anyone from call audio) and keeps its voices apart.
6
+
7
+ - **Unnamed meeting-wide voices** (`server/lib/nemotron-final.ts`, `unnamedVoicePass`). At save, a Mac recording (the
8
+ `mac_system_audio` sidecar or a `mac_` id, every chunk with words strict `high-confidence`, none carrying a real name)
9
+ now takes the meeting-wide Nemotron final pass in an unnamed mode instead of `skipped: speaker_policy`. A channel that
10
+ holds the floor, one-sided, in 3 or more chunks becomes `Voice 1`, `Voice 2`, ... in order of first speech. No voice
11
+ profile is read or written, the wearer is never consulted, and `speakers` is left as saved. A crowded room (more than
12
+ 4 such voices, or all 8 Nemotron channels in use) is `voices_crowded`, and a room with no such channel is
13
+ `voices_unconfident`: both stay `Ext`, byte for byte. A voice that would have turns but lead no chunk (or the reverse)
14
+ is dropped back to `Ext` and the rest renumbered, so every `Voice N` can be renamed in one action. Words, line breaks
15
+ and the human-correction rule are the final pass's own guarantees, unchanged; the original transcript stays under
16
+ `diarization.originalTranscript` with `labels: "unnamed"`. Glasses and every other strict capture keep the 6.73.6 skip.
17
+ - **Switch:** `COS_MAC_VOICE_LABELS`, on by default; `0`, `false`, `off` or `no` gives 6.89.0's behaviour. Forwarded by
18
+ the managed runtime contract.
19
+ - **`Voice N` is a placeholder everywhere a name is checked:** `isUnattributed` (review panel, final pass), the relabel
20
+ `PLACEHOLDER_LABEL`, the sidecar `speakers` filter at save, the batch segment `speakers` filters
21
+ (`isNamedSegmentSpeaker`), `isRealLabel` in live naming, and a merge into a Voice label never writes it into `speakers`.
22
+ - **Rename is text only.** The existing `POST /meeting/:id/relabel` renames `Voice 2` to a person. `enrolNamedVoice` now
23
+ refuses any Mac recording with `skipped: "mac_capture"` before it reads a store, so a Mac rename can never enrol or
24
+ train a profile (until now that was true only because Mac banks no embeddings).
25
+ - **Shadow scoring (calibration only).** A Mac chunk the 0.88 floor leaves `Ext` also logs its best profile match at the
26
+ ordinary 0.55 floor to `speaker-calibration.jsonl` under `event: "mac-shadow"`, `matched: false`. The label stays
27
+ `Ext`; nothing is enrolled; nothing goes to server.log.
28
+ - **Mac Nemotron tracks** are identified with `prioritizeOwner: false` and tagged `mac-nemotron-track`, so their
29
+ calibration rows are separable from the glasses' `nemotron-track`.
30
+ - **Replay** (read-only, clones of six 10/16 to 10/22 Mac sidecars against Miles's private audio copy, scratch data dir):
31
+ 3 of 6 took voices (4, 1 and 1 voices; 76 of 370 chunks with words split into two or more voices in the 4-voice call),
32
+ 3 stayed `Ext` as crowded (all 8 channels in use, 7, 5 and 7 confident channels). Found and fixed on the way: text-less
33
+ Mac entries carry no policy marker, which refused every real recording.
34
+ - **Tailscale nudge follow-up (6.89.0):** a boot, wake, network or tailscale trigger that finds the phone already back logs
35
+ one line, `[tailscale-nudge] trigger=<t> outcome=already_reconnected`. Trigger and outcome only, never an address.
36
+
37
+ ## 6.89.0 - LAN first removed; the Mac nudges a stale Tailscale tunnel back to life
38
+
39
+ Miles, 2026-10-23 19:57: no LAN-only code, because he uses COS away from home; and checks around the nudge so the
40
+ Tailscale connection always comes back. After the 10/22 Mac restart the phone's Tailscale extension kept a stale tunnel:
41
+ Tailscale's own ping went through, no COS request did, and only toggling Tailscale on the phone fixed it. Contract and
42
+ field canary: `docs/tailscale-self-heal.md`.
43
+
44
+ - **LAN first is gone.** Removed: public `GET /api/route-proof`, `routes` on `/api/models`, `capabilities.routeProof` and
45
+ `capabilities.lanRoutes`, `COS_LAN_FIRST`, `server/lib/lan-first.ts`, `server/routes/route-proof.ts`,
46
+ `docs/lan-first.md`, the SECURITY.md note, their tests and gate (reverts c593b91). `/api/models` is the 6.87.0 shape
47
+ path for path, and `/api/health` is 6.87.0 plus `tailscaleNudge`, both checked against goldens captured from
48
+ 601d807. `/api/route-proof` is an unknown path behind the token again. `COS_LAN_FIRST` is read by nothing.
49
+ - **Kept:** 6.88.0's transfer integrity against the phone's final index and the origin-wipe recovery save that waits
50
+ for it (fc52f49), and the older opt-in "Allow pairing on this Wi-Fi" pairing switch (6.67.0).
51
+ - **The nudge** (`server/lib/tailscale-nudge.ts`). `tailscale ping -c 3 --timeout 5s <peer>` (disco: both ends redo
52
+ path discovery), then, only after a pong, `tailscale ping --icmp -c 1 --timeout 5s <peer>` (one packet through
53
+ WireGuard, which starts a fresh handshake on a stale session). `execFile` from a fixed path, never a shell.
54
+ - **Triggers:** 30 s after boot; a wake from sleep (the wall clock outran the monotonic clock by 20 s); a change in
55
+ this Mac's IPv4 interfaces; a change in Tailscale's own state (BackendState, this Mac's Tailscale IPs, or peer byte
56
+ counters going backwards, meaning tailscaled restarted); and a phone that made an authenticated request in the last
57
+ 5 minutes, or sent meeting chunks in the last 15 minutes while a meeting is open, silent for 45 s. Every one needs
58
+ the peer `Online` in a status read at most 15 s old and Tailscale `Running`: absence of requests alone never nudges.
59
+ - **Caps:** one nudge per peer per 60 s, doubling per unanswered nudge to 15 minutes, reset by any request; 30 an hour
60
+ in all; one pass at a time; hard timeouts (5, 20 and 10 s, SIGKILL). `COS_TAILSCALE_NUDGE=0` turns it off, read
61
+ every tick, forwarded by the managed runtime contract. No LLM, no disk write, no new route.
62
+ - **What it says.** `[tailscale-nudge]` log lines with trigger, outcome, timing, handshake age and byte movement, never
63
+ an address; `tailscaleNudge { enabled, available, lastAt, lastTrigger, lastOutcome, lastDataPath, lastRecoveredAt,
64
+ nudgesLastHour }` on public `/api/health`.
65
+ - **Phone contract for glasses 6.10.653** (not built here): retry with fresh `no-store` requests on a 2, 4, 8, 15, 30 s
66
+ ladder, show 6.10.652's stuck-Tailscale message only after 90 s, read `tailscaleNudge`, and the iOS VPN On Demand note.
67
+
1
68
  ## 6.88.0 - LAN first: a pre-token route proof and the Mac's addresses for the phone
2
69
 
3
70
  The server half of the glasses "LAN first" reconnect plan (plan of 2026-10-22, Miles's decisions of 2026-10-23: "Go
package/SECURITY.md CHANGED
@@ -43,16 +43,11 @@ reproduction you have. Do not include real transcripts.
43
43
  turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
44
44
  - Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
45
45
  every claim is refused (`not_allowed_network`): pairing fails closed there.
46
- - `GET /api/route-proof` (6.88.0, `docs/lan-first.md`) is public by design. It returns
47
- the server instance id and HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" +
48
- instance id), never the token, so the phone can check which Mac answers at a home
49
- Wi-Fi address before sending the token there. The caller picks only a 22 to 64
50
- character base64url nonce. It is served only for a token of 32 characters or more,
51
- because a proof lets anyone who reaches the port test guesses offline. Limits: 20 a
52
- minute per connection address, 240 a minute in all. It does not stop an active
53
- attacker on the same Wi-Fi who relays the request to the real Mac: the LAN path is
54
- plain HTTP, as above. `COS_LAN_FIRST=0` turns it off. The Mac's addresses are
55
- published only on authenticated `/api/models`, never on `/api/health`.
46
+ - 6.89.0 Tailscale self-heal (`docs/tailscale-self-heal.md`): the server runs `tailscale status --json`
47
+ and `tailscale ping` at a peer that Tailscale reports online, with `execFile` from a fixed path and a
48
+ fixed argument list (the only variable is a 100.64.0.0/10 address read from Tailscale's own status).
49
+ Never a shell. Public `/api/health` shows when it last ran and the outcome, never an address.
50
+ `COS_TAILSCALE_NUDGE=0` turns it off.
56
51
  - `GET /api/display-stream` is public by design and returns lifecycle events
57
52
  only. Content requires a ticket or the token header. See
58
53
  `server/routes/display.ts` for the allowlist.
@@ -0,0 +1,159 @@
1
+ # Tailscale self-heal (server 6.89.0)
2
+
3
+ COS is used away from home, over Tailscale. The problem this solves: after the Mac restarts, wakes
4
+ from sleep, restarts Tailscale or changes network, the phone can stay on a stale Tailscale tunnel.
5
+ Tailscale says the phone is online and its own ping gets through, yet no COS request arrives, and
6
+ the only fix used to be toggling Tailscale on the phone. 6.89.0 adds a bounded Mac-side "nudge" and
7
+ a contract for the phone side. Code: `server/lib/tailscale-nudge.ts`.
8
+
9
+ 6.89.0 also removes 6.88.0's LAN-first pieces (`GET /api/route-proof`, `routes` on `/api/models`,
10
+ `capabilities.routeProof` and `capabilities.lanRoutes`, `COS_LAN_FIRST`). Tailscale already takes
11
+ the direct path over the home Wi-Fi when both ends are home, so a separate LAN route added nothing
12
+ for the away-from-home case. `COS_LAN_FIRST` is now read by nothing.
13
+
14
+ ## What the Mac can see (read-only probes, 2026-10-23, Tailscale 1.102.2 on macOS)
15
+
16
+ `tailscale status --json`, the phone's peer entry while COS was working normally:
17
+
18
+ | Field | Value seen | What it tells us |
19
+ |---|---|---|
20
+ | `Online` | `true` | Control-plane presence. It stays true while the iOS extension runs, stale tunnel or not, so on its own it proves nothing about the data path. |
21
+ | `Active` | `true` | Recent traffic in the engine. |
22
+ | `CurAddr` | the phone's home Wi-Fi address and port | A direct path. Away from home it is a public address, or empty when relayed. |
23
+ | `Relay` | the home DERP region | The fallback relay. |
24
+ | `LastHandshake` | 60 s old, refreshing about every 2 minutes | The WireGuard session is being renewed. A handshake much older than about 3 minutes while `Active` is true is suspicious. |
25
+ | `LastSeen` | zero time | Only set while the peer is offline. |
26
+ | `RxBytes` / `TxBytes` | grew by about 9 KB and 18 KB in 12 s | Per-peer counters for the life of tailscaled. They reset when tailscaled restarts, which is how the server detects a Mac-side Tailscale restart. |
27
+
28
+ `tailscale ping -c 3 --timeout 5s <phone>` answered once, `via <home address>:41641 in 91ms`, and
29
+ stopped: it stops at the first direct pong. `tailscale ping --icmp -c 1 --timeout 5s <phone>`
30
+ answered `via ICMP in 27ms`. The CLI's own help says the plain (disco) ping does not go through the
31
+ tunnel; the `--icmp` ping does go through WireGuard.
32
+
33
+ The local signal for "the peer looks online but no COS request has arrived": the server's own last
34
+ request time from that Tailscale address (any `/api` request, public routes included, because any
35
+ request that arrives proves the tunnel carries COS traffic), together with `Online: true` from a
36
+ status read no more than 15 s old. Each nudge log line also records the peer's handshake age and its
37
+ `RxBytes` movement between the last two status reads, so the field canary can show which of them
38
+ tells a stuck tunnel apart. The stuck state itself was not reproduced here.
39
+
40
+ ## The nudge
41
+
42
+ A nudge is two bounded commands at one peer, run with `execFile` from a fixed path (never a shell,
43
+ never a PATH lookup):
44
+
45
+ 1. `tailscale ping -c 3 --timeout 5s <peer>`: a disco ping. Both ends redo path discovery.
46
+ 2. Only after a pong: `tailscale ping --icmp -c 1 --timeout 5s <peer>`: one packet through
47
+ WireGuard, which makes WireGuard start a fresh handshake when its session is stale. On 10/22 the
48
+ disco ping got through while COS did not, so the disco ping alone is not enough.
49
+
50
+ The CLI is looked for at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`,
51
+ `/opt/homebrew/bin/tailscale`, `/usr/local/bin/tailscale` and `/usr/bin/tailscale`, in that order.
52
+ Without one the loop does nothing and health says `available: false`.
53
+
54
+ Whether a Mac-side nudge un-sticks the iOS extension is not proven by tests. The field canary below
55
+ is the proof.
56
+
57
+ ### Triggers
58
+
59
+ Every trigger needs the positive signal: the peer is `Online` in a status read at most 15 s old, and
60
+ Tailscale's `BackendState` is `Running`. Absence of requests alone never nudges.
61
+
62
+ | Trigger | When | Who |
63
+ |---|---|---|
64
+ | `boot` | 30 s after the server starts | Event peers (below) |
65
+ | `wake` | The wall clock moved 20 s or more past the monotonic clock between two 10 s ticks, which only happens across a sleep. Runs 15 s later. | Event peers |
66
+ | `network` | This Mac's external IPv4 interfaces changed, Tailscale's tunnel interface included. Runs 10 s later. | Event peers |
67
+ | `tailscale` | Tailscale's own state changed between status reads: `BackendState`, this Mac's Tailscale IPs, or the peer byte counters went backwards (tailscaled restarted). Runs 10 s later. | Event peers |
68
+ | `silent` | A Tailscale address that made an authenticated request in the last 5 minutes, or sent meeting chunks in the last 15 minutes while a meeting is open, has made no request at all for 45 s. | That address |
69
+
70
+ Event peers: Online peers this server has seen authenticate in the last 12 hours, plus Online iOS
71
+ and Android peers (after a restart the server remembers no one), at most 4, known ones first. A peer
72
+ that has already made a request since the event is skipped. An event that cannot run because
73
+ Tailscale is not `Running` waits, and is dropped (with one log line) after 2 minutes.
74
+
75
+ ### Caps
76
+
77
+ - One nudge per peer per 60 s. Each nudge that brings no request doubles the gap: 60, 120, 240,
78
+ 480 s, then 900 s. Any request from that peer resets it.
79
+ - 30 nudges an hour in all, across peers.
80
+ - One pass at a time; each command has a hard timeout (status 5 s, disco 20 s, ICMP 10 s, killed
81
+ with SIGKILL).
82
+ - A quiet phone outside a meeting is nudged at most 3 times (at about 45 s, 1:50 and 3:50) before its
83
+ 5-minute window closes. In a meeting, at most 4 in 15 minutes.
84
+ - Status is read once a minute while the loop is on, and otherwise only when a nudge could run.
85
+ - `COS_TAILSCALE_NUDGE=0` (or `false`, `off`) stops all of it, read every tick. On by default.
86
+ Forwarded by the managed runtime contract.
87
+ - No LLM, no disk write, no new route.
88
+
89
+ ### What it says
90
+
91
+ Log lines, outcome and timing only, never an address:
92
+
93
+ ```
94
+ [tailscale-nudge] trigger=silent outcome=pong_direct dataPath=ok pingMs=91 icmpMs=27 silentS=50 handshakeAgeS=61 rxDelta=9040/60s inRow=1 lastHour=1
95
+ [tailscale-nudge] peer answered 4.2s after the silent nudge
96
+ [tailscale-nudge] trigger=wake dropped: Tailscale not running for 121s
97
+ ```
98
+
99
+ `outcome` is `pong_direct`, `pong_relay`, `no_pong`, `timeout`, `error` or `unavailable`.
100
+ `dataPath` is `ok`, `failed` or `skipped` (no ICMP ping without a disco pong). A failed ping also
101
+ logs `code`, `signal` and `killed`.
102
+
103
+ Public `/api/health` carries:
104
+
105
+ ```json
106
+ "tailscaleNudge": {
107
+ "enabled": true,
108
+ "available": true,
109
+ "lastAt": "2026-10-23T20:05:10.000Z",
110
+ "lastTrigger": "silent",
111
+ "lastOutcome": "pong_direct",
112
+ "lastDataPath": "ok",
113
+ "lastRecoveredAt": "2026-10-23T20:05:14.200Z",
114
+ "nudgesLastHour": 1
115
+ }
116
+ ```
117
+
118
+ `available` is `null` until the first look for the CLI. `lastRecoveredAt` is when a nudged peer next
119
+ made a request, if that was within 90 s of the nudge. No address or device name is published.
120
+
121
+ ## The phone side (contract for glasses 6.10.653; not built in this release)
122
+
123
+ - **Retry with a fresh request.** On a reconnect failure, retry `/api/health` and then the
124
+ authenticated `/api/models` with `cache: 'no-store'` and a cache-busting query, on a fresh
125
+ connection where the WebView allows it (do not reuse a stalled keep-alive socket; a new
126
+ `EventSource` for the display stream). Backoff ladder: 2, 4, 8, 15, then every 30 s while the app
127
+ is in the foreground. Never a tight loop.
128
+ - **Wait out the Mac-side window before blaming Tailscale.** The Mac's nudge fires 45 s into a
129
+ silence (or 30 s after a Mac boot, 15 s after a wake) and its pings take up to about 30 s. Show
130
+ 6.10.652's stuck-Tailscale message only after 90 s of failed fresh requests while the phone has
131
+ network (the gotcos.com reachability check). Before that, show the ordinary reconnecting state.
132
+ - **Read the outcome.** `/api/health` is public, so once the phone reaches it, `tailscaleNudge`
133
+ tells it whether the Mac nudged and when. If health is reachable but authenticated calls are not,
134
+ the tunnel is fine and the problem is elsewhere: do not show the Tailscale message.
135
+ - **Keep working offline.** Local-first capture and backfill are unchanged: record locally, upload
136
+ when the Mac answers.
137
+ - **What to tell Miles about VPN On Demand.** In the iOS Tailscale app (Settings, VPN On Demand),
138
+ turning it on makes iOS bring the tunnel back by itself after it drops, for example after a network
139
+ change. It is a phone setting he can choose; COS cannot change it. It does not replace the nudge: a
140
+ tunnel iOS considers "up" but stale is not a drop, which is the case the Mac-side nudge targets.
141
+
142
+ ## Field canary (Miles)
143
+
144
+ 1. Note the time. Phone on cellular or away from home, COS open on the phone, Tailscale on.
145
+ 2. On the Mac, either quit and reopen Tailscale (menu bar, Quit, then open it), or restart the Mac.
146
+ 3. Do not touch Tailscale on the phone. Use COS on the phone within about two minutes.
147
+
148
+ Success:
149
+
150
+ - **Log** (the server log; with COS Control, `~/Library/Logs/COS Glasses/server.log`): a `[tailscale-nudge] trigger=tailscale` (Tailscale restart) or
151
+ `trigger=boot` (Mac restart) line with `outcome=pong_direct` or `pong_relay` and `dataPath=ok`,
152
+ then `peer answered ...s after the ... nudge`.
153
+ - **Health:** `curl -s http://127.0.0.1:3141/api/health` shows `tailscaleNudge.lastTrigger` matching,
154
+ `lastOutcome` a pong, and `lastRecoveredAt` set a few seconds after `lastAt`.
155
+ - **Phone:** COS reconnects by itself, without a Tailscale toggle, and a meeting started before the
156
+ restart keeps its chunks (transfer integrity 100%).
157
+
158
+ If the phone still needs a toggle: record the nudge log line (its `handshakeAgeS` and `rxDelta` are
159
+ the evidence for the next step) and the time of the toggle.
@@ -36,6 +36,8 @@
36
36
  "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
37
  "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
38
38
  "COS_LIVE_CUES_V2_MAC",
39
+ "COS_TAILSCALE_NUDGE",
40
+ "COS_MAC_VOICE_LABELS",
39
41
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
40
42
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
41
43
  "COS_DECISION_PROVIDER_CUES",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.88.0",
3
+ "version": "6.90.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": {
@@ -136,9 +136,10 @@
136
136
  "!server/scripts/question-hold-mutation-gate.mjs",
137
137
  "!server/scripts/live-meeting-6860-mutation-gate.mjs",
138
138
  "!server/scripts/live-meeting-6870-mutation-gate.mjs",
139
- "!server/scripts/lan-first-mutation-gate.mjs",
140
139
  "!server/scripts/integrity-6880-mutation-gate.mjs",
141
- "docs/lan-first.md",
140
+ "!server/scripts/tailscale-nudge-6890-mutation-gate.mjs",
141
+ "!server/scripts/mac-voices-6900-mutation-gate.mjs",
142
+ "docs/tailscale-self-heal.md",
142
143
  "docs/test-plan-6.71.8.md",
143
144
  "docs/test-plan-6.71.9.md",
144
145
  "docs/test-plan-6.71.10.md",
package/server/index.ts CHANGED
@@ -14,7 +14,8 @@ import { execSync } from 'node:child_process'
14
14
  import { randomBytes, randomUUID } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
- import { createClientInstanceRouter } from './routes/client-instance.js'
17
+ import { createClientInstanceRouter, isMeetingChunkPost } from './routes/client-instance.js'
18
+ import { installTailscaleNudger } from './lib/tailscale-nudge.js'
18
19
  import { queryRouter } from './routes/query.js'
19
20
  import { providerProofRouter } from './routes/provider-proof.js'
20
21
  import { transcribeRouter } from './routes/transcribe.js'
@@ -147,7 +148,7 @@ import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversati
147
148
  import { getMediaStore } from './lib/media-store.js'
148
149
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
149
150
  import { serverMetrics } from './lib/server-metrics.js'
150
- import { getServerInstanceId, initializeServerInstanceId } from './lib/server-instance-id.js'
151
+ import { initializeServerInstanceId } from './lib/server-instance-id.js'
151
152
  import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
152
153
  import { getTranscriptionProfileStatus } from './lib/profile.js'
153
154
  import { createQueryJobsRouter } from './routes/query-jobs.js'
@@ -188,10 +189,9 @@ import {
188
189
  isLoopbackSocket,
189
190
  reachableIpv4Addresses,
190
191
  } from './lib/network-policy.js'
191
- import { requireApiToken } from './lib/api-auth.js'
192
+ import { isPublicApiRequest, requireApiToken } from './lib/api-auth.js'
192
193
  import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
193
194
  import { createPairingRouter } from './routes/pairing.js'
194
- import { createRouteProofRouter } from './routes/route-proof.js'
195
195
  import { isManagedRuntime } from './lib/managed-runtime.js'
196
196
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
197
197
  import {
@@ -356,9 +356,23 @@ const pairingState = new PairingState({
356
356
  serverName: () => hostname().replace(/\.local$/i, ''),
357
357
  hosts: lanArmed => pairingHostsFrom(reachableIpv4Addresses(networkInterfaces()), PORT, lanArmed),
358
358
  })
359
+ // 6.89.0 Tailscale self-heal (lib/tailscale-nudge.ts, docs/tailscale-self-heal.md). Every /api
360
+ // request from a Tailscale address is noted BEFORE the token check, public routes included: any
361
+ // request that arrives proves the tunnel carries COS traffic. Authenticated ones also mark the
362
+ // address as a paired phone. Notes only, never answers; noteRequest never throws.
363
+ const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
364
+ app.use('/api', (req, _res, next) => {
365
+ tailscaleNudger.noteRequest(req.socket?.remoteAddress, { meetingChunk: isMeetingChunkPost(req.method, req.path) })
366
+ next()
367
+ })
359
368
  app.use('/api', requireApiToken(API_TOKEN, {
360
369
  onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
361
370
  }))
371
+ // Past the token gate, a request on a non-public path carried the token: mark a paired phone.
372
+ app.use('/api', (req, _res, next) => {
373
+ if (!isPublicApiRequest(req.method, req.path)) tailscaleNudger.noteRequest(req.socket?.remoteAddress, { authenticated: true })
374
+ next()
375
+ })
362
376
 
363
377
  // Pairing routes sit HERE on purpose: after the token gate (mint, status and decision
364
378
  // need it; the claim and its poll are exact public doors in api-auth.ts), and BEFORE the
@@ -369,14 +383,6 @@ app.use('/api', createPairingRouter({
369
383
  state: () => pairingState,
370
384
  isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
371
385
  }))
372
- // 6.88.0 LAN first (docs/lan-first.md): the public pre-token identity proof. Writes nothing,
373
- // so it sits beside pairing, ahead of the lease catch-all and the global parser, and is
374
- // served during a drain. The instance id is read per request: it is minted only after
375
- // every listener binds, and until then the route answers 503 `not_ready`.
376
- app.use('/api', createRouteProofRouter({
377
- token: () => API_TOKEN,
378
- serverInstanceId: getServerInstanceId,
379
- }))
380
386
 
381
387
  // Fail-closed catch-all for mutation routes that do not own a more specific
382
388
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -1283,6 +1289,7 @@ async function gracefulShutdown(): Promise<void> {
1283
1289
  await meetingAskRuntime.shutdown()
1284
1290
  stopMorningBriefScheduler()
1285
1291
  stopRoutineScheduler()
1292
+ tailscaleNudger.stop()
1286
1293
  await workReviewRuntime?.close()
1287
1294
  workIntakeStore?.close()
1288
1295
  workHandoffRequestStore?.close()
@@ -1485,6 +1492,8 @@ listenRequiredServers(listeners).then(() => {
1485
1492
  // Hash the pinned FluidAudio CLI once at boot so the first Settings GET already knows `installable`.
1486
1493
  void findPinnedCli().catch(() => null)
1487
1494
  startPreviewWarmLoop()
1495
+ // 6.89.0: the Tailscale nudge loop. Its boot trigger is due 30 s from here. COS_TAILSCALE_NUDGE=0 stops it.
1496
+ tailscaleNudger.start()
1488
1497
  }
1489
1498
 
1490
1499
  // Durable recovery starts with the admitted runtime by default. A literal
@@ -43,17 +43,10 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
43
43
  // Minting, status and decisions stay behind the token AND a loopback socket.
44
44
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
45
45
 
46
- // 6.88.0 LAN first (docs/lan-first.md): GET /route-proof is public because it exists to
47
- // prove which Mac answers at an address BEFORE the phone sends its token there. Exact path,
48
- // GET only (HEAD and every other verb stay behind the token). It returns an HMAC keyed by
49
- // the token over a fixed, domain-separated message, never the token (routes/route-proof.ts).
50
- const ROUTE_PROOF_PATH = '/route-proof'
51
-
52
46
  export function isPublicApiRequest(method: string, path: string): boolean {
53
47
  if (PUBLIC_API_PATHS.has(path)) return true
54
48
  if (method === 'POST' && path === '/pairing/claim') return true
55
49
  if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
56
- if (method === 'GET' && path === ROUTE_PROOF_PATH) return true
57
50
  if (method !== 'GET' && method !== 'HEAD') return false
58
51
  return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
59
52
  || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
@@ -0,0 +1,50 @@
1
+ // 6.90.0: unnamed, meeting-stable voice labels for Mac recordings.
2
+ //
3
+ // A Mac recording hears call audio, video and podcasts through one global tap.
4
+ // Naming a voice from that audio is not safe (no calibration exists, see
5
+ // CHANGELOG 6.73.6 and the 2026-10-14 Mac plan section 5), so every Mac chunk is
6
+ // `Ext`. What IS safe is telling voices apart: one Nemotron pass over the whole
7
+ // saved meeting gives channels that are consistent for the call, and each
8
+ // confident channel becomes `Voice 1`, `Voice 2`, ... in order of first speech.
9
+ //
10
+ // A `Voice N` label asserts NO identity. Everywhere a real name is checked it is
11
+ // a placeholder, exactly like `Ext` and `Unidentified N`: it is never a profile
12
+ // name, never enrolled, never trained, never written into the meeting's
13
+ // `speakers` list. A person may rename it (text only) through the relabel route.
14
+ //
15
+ // Switch: COS_MAC_VOICE_LABELS, on by default; 0, false, off or no turns it off
16
+ // and Mac recordings stay all `Ext` exactly as 6.89.0 saved them.
17
+
18
+ // No imports on purpose: placeholder checks are read from the speaker-review,
19
+ // relabel and store layers, and this must never pull the Mac registry into them.
20
+ const MAC_SOURCE_TYPE = 'mac_system_audio'
21
+
22
+ /** `Voice 1` .. `Voice 99`. No leading zero, single space, nothing after. */
23
+ export const VOICE_LABEL = /^Voice [1-9]\d?$/
24
+
25
+ export function isVoiceLabel(label: unknown): boolean {
26
+ return typeof label === 'string' && VOICE_LABEL.test(label)
27
+ }
28
+
29
+ export function voiceLabel(n: number): string {
30
+ if (!Number.isInteger(n) || n < 1 || n > 99) throw new Error('voice number out of range')
31
+ return `Voice ${n}`
32
+ }
33
+
34
+ /** On unless explicitly switched off. Read at use, so a managed restart is not needed to turn it off. */
35
+ export function macVoiceLabelsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
36
+ const raw = String(env.COS_MAC_VOICE_LABELS ?? '').trim().toLowerCase()
37
+ return !['0', 'false', 'off', 'no'].includes(raw)
38
+ }
39
+
40
+ /**
41
+ * A sidecar written by the Mac recording save path (`sourceType`), or any `mac_`
42
+ * id. Deliberately broader than MAC_CAPTURE_ID: this answers "could this be Mac
43
+ * audio", and a guard that asks it must fail closed on a malformed Mac id too.
44
+ */
45
+ export function isMacCaptureSidecar(sidecar: Record<string, unknown> | null | undefined, sessionId?: string): boolean {
46
+ if (typeof sessionId === 'string' && sessionId.startsWith('mac_')) return true
47
+ if (!sidecar || typeof sidecar !== 'object') return false
48
+ if (sidecar.sourceType === MAC_SOURCE_TYPE) return true
49
+ return typeof sidecar.sessionId === 'string' && sidecar.sessionId.startsWith('mac_')
50
+ }
@@ -2,6 +2,7 @@
2
2
  // larger windows so Whisper gets enough context to improve the live stream.
3
3
  // The candidate is never canonical until batch-transcript-quality accepts it.
4
4
 
5
+ import { isVoiceLabel } from './mac-voice-labels.js'
5
6
  import { createHash } from 'node:crypto'
6
7
  import { existsSync, readFileSync, readdirSync, utimesSync, writeFileSync } from 'node:fs'
7
8
  import { availableParallelism } from 'node:os'
@@ -109,6 +110,11 @@ export function concatenateWavChunks(audioDir: string, startChunk: number, endCh
109
110
  return Buffer.concat([createWavHeader(pcm.length), pcm])
110
111
  }
111
112
 
113
+ /** A segment's `speakers` list holds names only: never `Ext`, and (6.90.0) never a Mac `Voice N` placeholder. */
114
+ export function isNamedSegmentSpeaker(speaker: string): boolean {
115
+ return Boolean(speaker) && speaker !== 'Ext' && !isVoiceLabel(speaker)
116
+ }
117
+
112
118
  /** Group live transcript chunks into roughly 30-second Whisper windows. */
113
119
  export function segmentTranscriptChunks(
114
120
  entries: IndexedTranscriptChunk[],
@@ -135,7 +141,7 @@ export function segmentTranscriptChunks(
135
141
  segments.push({
136
142
  startChunkIdx: first.chunkIndex, endChunkIdx: last.chunkIndex,
137
143
  startElapsed: first.chunk.elapsed, endElapsed: last.chunk.elapsed,
138
- speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
144
+ speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(isNamedSegmentSpeaker))],
139
145
  speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
140
146
  })
141
147
  segmentStartPosition = position
@@ -148,7 +154,7 @@ export function segmentTranscriptChunks(
148
154
  endChunkIdx: entry.chunkIndex,
149
155
  startElapsed: first.chunk.elapsed,
150
156
  endElapsed: chunk.elapsed,
151
- speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
157
+ speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(isNamedSegmentSpeaker))],
152
158
  speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
153
159
  })
154
160
  segmentStartPosition = position + 1
@@ -165,7 +171,7 @@ export function segmentTranscriptChunks(
165
171
  endChunkIdx: last.chunkIndex,
166
172
  startElapsed: first.chunk.elapsed,
167
173
  endElapsed: last.chunk.elapsed,
168
- speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
174
+ speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(isNamedSegmentSpeaker))],
169
175
  speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
170
176
  })
171
177
  }
@@ -48,6 +48,7 @@ import {
48
48
  readVoiceProfiles,
49
49
  } from './speaker-embeddings.js'
50
50
  import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
51
+ import { isMacCaptureSidecar } from './mac-voice-labels.js'
51
52
  import {
52
53
  MAX_ENROL_PER_CORRECTION,
53
54
  dominantCoherentCluster,
@@ -56,7 +57,8 @@ import {
56
57
 
57
58
  /** Labels the diariser invents when it does not know who is speaking. Naming one
58
59
  * of these is a FIRST TRAINING RUN for a new person, not a correction. */
59
- const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+)$/i
60
+ // 6.90.0: `Voice N`, the unnamed Mac recording voice, is a placeholder too.
61
+ const PLACEHOLDER_LABEL = /^(ext|unknown|unidentified(\s+\d+)?|speaker\s*\d+|voice\s+[1-9]\d?)$/i
60
62
 
61
63
  export const isPlaceholderLabel = (label: string): boolean => PLACEHOLDER_LABEL.test(label.trim())
62
64
 
@@ -81,6 +83,8 @@ export type EnrolmentSkipReason =
81
83
  | 'disabled'
82
84
  | 'expired'
83
85
  | 'store_unavailable'
86
+ /** 6.90.0: a Mac recording. Its audio is call or video playback and never trains a profile. */
87
+ | 'mac_capture'
84
88
 
85
89
  export interface EnrolmentReport {
86
90
  /** Samples the voice store actually accepted. */
@@ -217,6 +221,13 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
217
221
  // placeholder-only `from` labelled the meeting and taught the store nothing.
218
222
  if (isPlaceholderLabel(to) || changed.length === 0) return IDLE
219
223
 
224
+ // 6.90.0: a Mac recording renames TEXT ONLY, always. Mac audio is call, video
225
+ // and podcast playback through one global tap; naming `Voice 2` must never
226
+ // turn that into a voice profile. Today Mac banks no embeddings, so this would
227
+ // also stop at `no_embeddings`, but only by accident of the store. This guard
228
+ // says it on purpose, before any store is read.
229
+ if (isMacCaptureSidecar(sidecar, sessionId)) return { ...IDLE, skipped: 'mac_capture' }
230
+
220
231
  if (!chunkEmbeddingsEnabled()) return { ...IDLE, skipped: 'disabled' }
221
232
  // No extractor/manager means every enrollEmbedding would return success:false.
222
233
  // Naming that beats looping twenty times and reporting a bare zero.
@@ -26,6 +26,8 @@
26
26
  // a first-name substitution in narrative text would rewrite sentences about
27
27
  // somebody else. Reported as `proseStale` instead.
28
28
 
29
+ import { isVoiceLabel } from './mac-voice-labels.js'
30
+
29
31
  /** A label that would corrupt the file formats it gets written into. */
30
32
  export function invalidLabelReason(label: string): string | null {
31
33
  if (label.trim() === '') return 'label is empty'
@@ -126,7 +128,12 @@ export function relabelSidecarJson(
126
128
  // attributed anything.
127
129
  const existing = Array.isArray(doc.speakers) ? doc.speakers.filter((s): s is string => typeof s === 'string') : []
128
130
  const speakers = [...existing]
129
- if (!speakers.includes(to)) {
131
+ if (isVoiceLabel(to)) {
132
+ // 6.90.0: a Mac `Voice N` placeholder never enters `speakers` (the store
133
+ // keeps it out too), so merging into one adds nothing. Retire `from` only.
134
+ const at = speakers.indexOf(from)
135
+ if (at >= 0 && remainingWithFrom === 0) speakers.splice(at, 1)
136
+ } else if (!speakers.includes(to)) {
130
137
  // Replace in place when `from` is being fully retired, so the list keeps its
131
138
  // original ordering rather than moving the person to the end.
132
139
  const at = speakers.indexOf(from)
@@ -22,6 +22,8 @@
22
22
  //
23
23
  // Everything here is pure over a chunk array so it can be tested by execution.
24
24
 
25
+ import { isVoiceLabel } from './mac-voice-labels.js'
26
+
25
27
  /** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
26
28
  export interface ReviewChunk {
27
29
  text?: string
@@ -64,7 +66,8 @@ export const DEATTRIBUTED_PREFIX = 'Unidentified'
64
66
  /** True when a label asserts no identity — the exact set, or a numbered
65
67
  * de-attribution. Prefix-aware so `Unidentified 3` is treated as unnamed. */
66
68
  export function isUnattributed(label: string): boolean {
67
- return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label)
69
+ // 6.90.0: `Voice N` (an unnamed, meeting-stable Mac recording voice) asserts no identity either.
70
+ return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label) || isVoiceLabel(label)
68
71
  }
69
72
 
70
73
  /** Calibrated from the control pair above. A pair must be BOTH flip-happy and
@@ -1,3 +1,4 @@
1
+ import { isVoiceLabel } from './mac-voice-labels.js'
1
2
  import { MAC_SOURCE_TYPE, readMacRecording, closeMacRecording } from './mac-recording.js'
2
3
  import {
3
4
  chmodSync,
@@ -429,8 +430,9 @@ export class MeetingStore {
429
430
  providers,
430
431
  providerCandidates: input.providerCandidates ?? {},
431
432
  // 6.61.0: a chunk can hold two named voices; its segments count too.
433
+ // 6.90.0: a Mac `Voice N` placeholder is no more a speaker than `Ext` is.
432
434
  speakers: [...new Set(input.chunks.flatMap(chunk => [chunk.speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)])
433
- .filter(speaker => speaker && speaker !== 'Ext'))],
435
+ .filter(speaker => speaker && speaker !== 'Ext' && !isVoiceLabel(speaker)))],
434
436
  chunks: input.chunks,
435
437
  chunkEntries: input.chunkEntries ?? input.chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk })),
436
438
  transferIntegrity: input.transferIntegrity ?? null,