@gotcos/glasses-server 6.80.0 → 6.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
2
+
3
+ Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
4
+
5
+ - **Client matrix.** Server only. Nothing changes until the companion sends the new Stop (a companion release, not in this one). Older companions never call the new route and save exactly as before.
6
+ - **The phone's Stop, durable.** `POST /api/meeting/sessions/:id/stop` `{finalChunkIndex, serverInstanceId}`: one small fetch a hidden phone can still send. The server pin is required. It is written to `data/meeting-stop-intents.json` (0600, atomic) before the 202, so a restart keeps it. A repeat is idempotent; a higher index raises it, a lower one never lowers it. A Stop behind what the Mac already holds is refused 409 `final_index_behind_server`. It never closes the session and never refuses a chunk.
7
+ - **The Mac saves only a complete capture.** Once every chunk 0..finalChunkIndex has arrived and finished ASR, and 45 s have passed since the Stop and since the last chunk (so a phone on screen saves first), the Mac runs the same save the phone's `POST /meeting/save` runs: one lock, one idempotent receipt. A gap means audio exists only on the phone, so the Mac waits and the late chunks land in the SAME live session. There is no timeout that saves with a gap: a gap that never fills is left to the existing 4 h stranded sweep, unchanged. A chunk past the Stop revokes it (the meeting is still recording). Checked every 15 s while a Stop waits, only while admissions are open and session recovery has run.
8
+ - **The phone coming back.** Status answers `saved` with the receipt; `POST /meeting/save` replays it; and a chunk the phone re-sends because its answer was lost while hidden is answered from the saved sidecar instead of 410, so the phone no longer reads that state as a collision. This replay is only for meetings the Mac saved itself; every other closed session still answers 410.
9
+ - **Telemetry.** `[meeting/auto-save]` lines and `data/meeting-autonomous-save.jsonl`: `stop_received`, `waiting` (once per change, with missing count and sample), `finalized` (chunks, canonical, gaps, waited ms), `phone_saved` (or `client_saved` for an unpinned save such as the stranded sweep's), `phone_confirmed` (how and how long after), `late_replay`, `late_chunk_refused`, `revoked`, `stood_down`, `finalize_failed`. The status route carries `stopIntent` additively. `recording_stop` carries `savedBy: "server"` for the Mac's own saves.
10
+ - **Unchanged.** Transcription engines, the G2 matching thresholds, the restart and maintenance gate semantics, the stranded sweeper, and the save route's answers (its body moved into one shared `performSave`; the mutation gate anchor `cx-6533-save-nothing-to-save` follows it).
11
+ - **Checks.** Full suite 8,462 passed, 0 failed, 3 skipped (557 files); `tsc --noEmit` clean. `server/scripts/autonomous-save-mutation-gate.mjs` (not packaged): green baseline (63 tests in 4 files), 42 of 42 mutants killed, each by a named test. The moved `cx-6533-save-nothing-to-save` anchor re-runs killed. Journeys: phone hidden after Stop (the Mac saves alone), phone back late with missing chunks (merged into one meeting), phone and Mac racing (one meeting), against the real chunk and save routes. Needs the companion release to send the Stop before it does anything on a real phone.
12
+
1
13
  ## 6.80.0 - Answer with your own key (opt in, off by default)
2
14
 
3
15
  You can now give COS your own provider key (OpenRouter, Anthropic, OpenAI, or a compatible endpoint) and pick a key profile for a thread, so that thread answers on the key instead of your Claude or ChatGPT plan. **This is opt in. Without a key, nothing changes:** with `COS_KEY_LANES` unset or `0`, or with lanes on but no key profile saved, the server answers, spawns and writes exactly as 6.79.1 did. Turning it on and saving keys happens in COS Control on this Mac.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.80.0",
3
+ "version": "6.81.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": {
@@ -54,6 +54,7 @@
54
54
  "!server/scripts/work-search-mutation-gate.mjs",
55
55
  "!server/scripts/fact-check-mutation-gate.mjs",
56
56
  "!server/scripts/stale-session-mutation-gate.mjs",
57
+ "!server/scripts/autonomous-save-mutation-gate.mjs",
57
58
  "!server/scripts/stt-mutation-gate.mjs",
58
59
  "!server/scripts/dictation-stt-mutation-gate.mjs",
59
60
  "!server/scripts/pairing-mutation-gate.mjs",
package/server/index.ts CHANGED
@@ -74,7 +74,7 @@ import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './
74
74
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
75
75
  import { displayRouter } from './routes/display.js'
76
76
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
77
- import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
77
+ import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
78
78
  import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
79
79
  import { meetingsRouter } from './routes/meetings.js'
80
80
  import { openaiCompatRouter } from './routes/openai-compat.js'
@@ -1473,6 +1473,8 @@ listenRequiredServers(listeners).then(() => {
1473
1473
  // Resume already-saved meetings whose post-response HQ/operations work
1474
1474
  // was interrupted by a prior server update or process exit.
1475
1475
  resumeMeetingFinalizationJobs()
1476
+ // 6.81.0: Stops the phone sent before the last exit; the server saves them once complete.
1477
+ resumeAutonomousMeetingSaves()
1476
1478
  // 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
1477
1479
  scheduleTranscriptCleanupSweep()
1478
1480
 
@@ -0,0 +1,267 @@
1
+ // The server finishes a stopped G2 meeting on its own (6.81.0).
2
+ //
3
+ // Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the
4
+ // meetings". Once the phone has told the server its meeting stopped (lib/meeting-stop-
5
+ // intents.ts) and the server holds every chunk it captured, nothing is left that needs the
6
+ // phone, so the server runs the same save the phone would have run. The phone, when it
7
+ // comes back on screen, finds a saved meeting and adopts the receipt.
8
+ //
9
+ // THE ONE RULE: NEVER SAVE A CAPTURE THAT IS NOT COMPLETE. The server saves on its own only
10
+ // when its ledger holds every index 0..N (N = the phone's last captured index) and every one
11
+ // of them has finished ASR. A gap means audio exists only on the phone, so the server waits
12
+ // and the session stays live: the phone's late upload lands in the SAME session (the merge)
13
+ // and the next check saves. There is no shorter timeout that saves with the gap, because a
14
+ // save closes the session, and a chunk that arrives after the close is refused (410) and the
15
+ // phone reads "server closed, local audio pending" as a collision it cannot resolve: the
16
+ // meeting splits or the tail is lost. A gap that never fills is handed to the existing
17
+ // stranded sweeper at its existing 4 h horizon, exactly as before this release. Waiting
18
+ // costs only time; saving early costs audio.
19
+ //
20
+ // Why a settle delay as well: a phone that is on screen saves within a few seconds of Stop.
21
+ // The server stands back for AUTONOMOUS_FINALIZE_SETTLE_MS after the Stop and after the last
22
+ // chunk arrived, so a healthy phone's own save is the normal path and the server's is the
23
+ // fallback. Both racing is safe anyway (one save lock, an idempotent save route), but not
24
+ // racing is quieter.
25
+
26
+ import type { StopIntent } from './meeting-stop-intents.js'
27
+
28
+ /** Quiet time after the Stop and after the last chunk before the server saves on its own. */
29
+ export const AUTONOMOUS_FINALIZE_SETTLE_MS = 45_000
30
+ /** How often a waiting Stop is checked again. Cheap: an in-memory ledger read. */
31
+ export const AUTONOMOUS_FINALIZE_POLL_MS = 15_000
32
+ /** Missing indices kept in a log line. */
33
+ const MISSING_SAMPLE = 8
34
+
35
+ export interface SessionLedger {
36
+ state: 'active' | 'closed' | 'missing'
37
+ received: readonly number[]
38
+ asrCompleted: readonly number[]
39
+ maxChunkIndex: number
40
+ /** ms since epoch of the last chunk arrival, or null. */
41
+ lastActivityAt: number | null
42
+ }
43
+
44
+ export type AutonomousWaitReason = 'settling' | 'missing_chunks' | 'asr_pending' | 'session_unknown' | 'saving' | 'not_ready'
45
+ export type AutonomousStandDownReason = 'already_saved' | 'closed_unsaved' | 'nothing_captured' | 'server_instance_mismatch'
46
+
47
+ export type AutonomousDecision =
48
+ | { action: 'finalize'; chunks: number }
49
+ | { action: 'wait'; reason: AutonomousWaitReason; missing: number[]; missingCount: number; retryInMs: number }
50
+ | { action: 'revoke'; reason: 'chunk_beyond_final'; maxChunkIndex: number }
51
+ | { action: 'stand_down'; reason: AutonomousStandDownReason }
52
+
53
+ function missingFrom(finalIndex: number, have: readonly number[]): { sample: number[]; count: number } {
54
+ const present = new Set(have)
55
+ const sample: number[] = []
56
+ let count = 0
57
+ for (let index = 0; index <= finalIndex; index++) {
58
+ if (present.has(index)) continue
59
+ count++
60
+ if (sample.length < MISSING_SAMPLE) sample.push(index)
61
+ }
62
+ return { sample, count }
63
+ }
64
+
65
+ /**
66
+ * What to do with a waiting Stop right now. Pure: every input is passed in.
67
+ *
68
+ * Order matters and each step is pinned by a test: a saved meeting is never saved again, a
69
+ * chunk past the Stop means the meeting is still recording (revoke, never save), and only a
70
+ * complete, fully transcribed, quiet capture is saved.
71
+ */
72
+ export function decideAutonomousFinalize(input: {
73
+ intent: Pick<StopIntent, 'finalChunkIndex' | 'receivedAt'>
74
+ ledger: SessionLedger
75
+ now: number
76
+ alreadySaved: boolean
77
+ saving: boolean
78
+ }): AutonomousDecision {
79
+ const { intent, ledger, now } = input
80
+ if (input.alreadySaved) return { action: 'stand_down', reason: 'already_saved' }
81
+ if (input.saving) return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
82
+ // A session closed WITHOUT a save receipt (expired, quarantined) is the stranded and
83
+ // quarantine paths' to finish. Saving here would race them.
84
+ if (ledger.state === 'closed') return { action: 'stand_down', reason: 'closed_unsaved' }
85
+ if (intent.finalChunkIndex < 0) return { action: 'stand_down', reason: 'nothing_captured' }
86
+ // The server holds audio the phone says it never captured: the phone resumed after this
87
+ // Stop, or the Stop is stale. Either way the meeting may still be recording.
88
+ if (ledger.maxChunkIndex > intent.finalChunkIndex) {
89
+ return { action: 'revoke', reason: 'chunk_beyond_final', maxChunkIndex: ledger.maxChunkIndex }
90
+ }
91
+ // An offline capture: the Stop arrived before any chunk did. Wait for the upload.
92
+ if (ledger.state === 'missing') {
93
+ return { action: 'wait', reason: 'session_unknown', missing: [], missingCount: intent.finalChunkIndex + 1, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
94
+ }
95
+ const absent = missingFrom(intent.finalChunkIndex, ledger.received)
96
+ if (absent.count > 0) {
97
+ return { action: 'wait', reason: 'missing_chunks', missing: absent.sample, missingCount: absent.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
98
+ }
99
+ const pending = missingFrom(intent.finalChunkIndex, ledger.asrCompleted)
100
+ if (pending.count > 0) {
101
+ return { action: 'wait', reason: 'asr_pending', missing: pending.sample, missingCount: pending.count, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
102
+ }
103
+ const quietSince = Math.max(intent.receivedAt, ledger.lastActivityAt ?? 0)
104
+ const settleLeft = quietSince + AUTONOMOUS_FINALIZE_SETTLE_MS - now
105
+ if (settleLeft > 0) {
106
+ return { action: 'wait', reason: 'settling', missing: [], missingCount: 0, retryInMs: Math.min(settleLeft, AUTONOMOUS_FINALIZE_POLL_MS) }
107
+ }
108
+ return { action: 'finalize', chunks: intent.finalChunkIndex + 1 }
109
+ }
110
+
111
+ /** Expand `[start, end]` ranges (the status route's shape) into indices. */
112
+ export function indicesFromRanges(ranges: ReadonlyArray<readonly [number, number]>): number[] {
113
+ const out: number[] = []
114
+ for (const [start, end] of ranges) for (let index = start; index <= end; index++) out.push(index)
115
+ return out
116
+ }
117
+
118
+ export type AutonomousSaveOutcome =
119
+ | { ok: true; filename: string; sidecarPath: string; chunkCount: number; canonicalCount: number; missingIndices: number[] }
120
+ | { ok: false; status: number; reason: string }
121
+
122
+ export interface AutonomousFinalizerDeps {
123
+ waiting(): StopIntent[]
124
+ get(sessionId: string): StopIntent | null
125
+ update(sessionId: string, patch: Partial<StopIntent>): void
126
+ ledger(sessionId: string): SessionLedger
127
+ alreadySaved(sessionId: string): boolean
128
+ saving(sessionId: string): boolean
129
+ save(intent: StopIntent): Promise<AutonomousSaveOutcome>
130
+ /** Maintenance admissions open AND durable session recovery has run. */
131
+ ready(): boolean
132
+ now(): number
133
+ /** Returns a cancel function. Production timers are unref'd so they never hold the process. */
134
+ schedule(fn: () => void, ms: number): () => void
135
+ log(event: string, fields: Record<string, unknown>): void
136
+ }
137
+
138
+ export interface AutonomousFinalizer {
139
+ /** Check one session now (a Stop arrived, a boot). Never throws. */
140
+ check(sessionId: string, trigger: string): Promise<AutonomousDecision | null>
141
+ /** Check every waiting Stop, then re-arm while any remain. */
142
+ tick(trigger?: string): Promise<void>
143
+ /** Make sure a tick is scheduled. */
144
+ arm(ms?: number): void
145
+ }
146
+
147
+ export function createAutonomousFinalizer(deps: AutonomousFinalizerDeps): AutonomousFinalizer {
148
+ const inFlight = new Set<string>()
149
+ let cancel: (() => void) | null = null
150
+ let armedFor = Infinity
151
+
152
+ const arm = (ms = AUTONOMOUS_FINALIZE_POLL_MS) => {
153
+ const due = deps.now() + Math.max(0, ms)
154
+ if (cancel && armedFor <= due) return
155
+ cancel?.()
156
+ armedFor = due
157
+ cancel = deps.schedule(() => {
158
+ cancel = null
159
+ armedFor = Infinity
160
+ void finalizer.tick('poll')
161
+ }, Math.max(0, ms))
162
+ }
163
+
164
+ const check = async (sessionId: string, trigger: string): Promise<AutonomousDecision | null> => {
165
+ if (inFlight.has(sessionId)) return null
166
+ const intent = deps.get(sessionId)
167
+ if (!intent || intent.state !== 'waiting') return null
168
+ // A boot behind a closed maintenance gate has not adopted the persisted sessions yet,
169
+ // so "the server holds no chunks" is not evidence of anything. Wait, never decide.
170
+ if (!deps.ready()) {
171
+ return { action: 'wait', reason: 'not_ready', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
172
+ }
173
+ inFlight.add(sessionId)
174
+ try {
175
+ const now = deps.now()
176
+ const decision = decideAutonomousFinalize({
177
+ intent,
178
+ ledger: deps.ledger(sessionId),
179
+ now,
180
+ alreadySaved: deps.alreadySaved(sessionId),
181
+ saving: deps.saving(sessionId),
182
+ })
183
+ if (decision.action === 'stand_down') {
184
+ // A save the phone (or anyone) already made is the phone's save, not ours.
185
+ deps.update(sessionId, decision.reason === 'already_saved'
186
+ ? { state: 'saved', savedBy: intent.savedBy ?? 'phone', savedAt: intent.savedAt ?? now }
187
+ : { state: 'stood_down', reason: decision.reason })
188
+ deps.log('stood_down', { sessionId, reason: decision.reason, trigger, waitedMs: now - intent.receivedAt })
189
+ return decision
190
+ }
191
+ if (decision.action === 'revoke') {
192
+ deps.update(sessionId, { state: 'revoked', reason: decision.reason })
193
+ deps.log('revoked', { sessionId, reason: decision.reason, finalChunkIndex: intent.finalChunkIndex, maxChunkIndex: decision.maxChunkIndex, trigger })
194
+ return decision
195
+ }
196
+ if (decision.action === 'wait') {
197
+ const last = intent.lastWait
198
+ // One line per change of state, not one per poll.
199
+ if (!last || last.reason !== decision.reason || last.missingCount !== decision.missingCount) {
200
+ deps.update(sessionId, { lastWait: { reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, at: now } })
201
+ deps.log('waiting', { sessionId, reason: decision.reason, missingCount: decision.missingCount, missingSample: decision.missing, finalChunkIndex: intent.finalChunkIndex, trigger })
202
+ }
203
+ return decision
204
+ }
205
+ const outcome = await deps.save(intent)
206
+ const at = deps.now()
207
+ if (outcome.ok) {
208
+ deps.update(sessionId, {
209
+ state: 'saved', savedBy: 'server', savedAt: at,
210
+ filename: outcome.filename, sidecarPath: outcome.sidecarPath,
211
+ chunkCount: outcome.chunkCount, canonicalCount: outcome.canonicalCount, lastWait: null,
212
+ })
213
+ deps.log('finalized', {
214
+ sessionId, reason: 'stop_received_complete', trigger,
215
+ finalChunkIndex: intent.finalChunkIndex, chunks: outcome.chunkCount, canonical: outcome.canonicalCount,
216
+ gaps: outcome.missingIndices.length, filename: outcome.filename,
217
+ waitedMs: at - intent.receivedAt, phoneConfirmed: false,
218
+ })
219
+ return decision
220
+ }
221
+ // The phone saved between the decision and the lock: its save, not ours.
222
+ if (outcome.reason === 'already_saved') {
223
+ deps.update(sessionId, { state: 'saved', savedBy: 'phone', savedAt: at, lastWait: null })
224
+ deps.log('stood_down', { sessionId, reason: 'already_saved', trigger, waitedMs: at - intent.receivedAt })
225
+ return { action: 'stand_down', reason: 'already_saved' }
226
+ }
227
+ // The phone pinned another server instance: this Mac is not where it meant to save.
228
+ if (outcome.reason === 'server_instance_mismatch') {
229
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
230
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
231
+ return { action: 'stand_down', reason: 'server_instance_mismatch' }
232
+ }
233
+ // The phone's own save holds the lock: it wins, and the next check reads its receipt.
234
+ if (outcome.status === 409 && outcome.reason === 'save_in_progress') {
235
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
236
+ }
237
+ // Nothing to save (every chunk was silence): the phone's own Stop says so on the lens.
238
+ if (outcome.status === 404) {
239
+ deps.update(sessionId, { state: 'stood_down', reason: outcome.reason })
240
+ deps.log('stood_down', { sessionId, reason: outcome.reason, trigger })
241
+ return { action: 'stand_down', reason: 'nothing_captured' }
242
+ }
243
+ deps.log('finalize_failed', { sessionId, status: outcome.status, reason: outcome.reason, trigger })
244
+ return { action: 'wait', reason: 'saving', missing: [], missingCount: 0, retryInMs: AUTONOMOUS_FINALIZE_POLL_MS }
245
+ } catch (error) {
246
+ deps.log('finalize_failed', { sessionId, status: 500, reason: error instanceof Error ? error.message.slice(0, 200) : 'error', trigger })
247
+ return null
248
+ } finally {
249
+ inFlight.delete(sessionId)
250
+ }
251
+ }
252
+
253
+ const finalizer: AutonomousFinalizer = {
254
+ check,
255
+ async tick(trigger = 'poll') {
256
+ let soonest = Infinity
257
+ for (const intent of deps.waiting()) {
258
+ const decision = await check(intent.sessionId, trigger)
259
+ if (decision?.action === 'wait') soonest = Math.min(soonest, decision.retryInMs)
260
+ else if (decision === null && deps.get(intent.sessionId)?.state === 'waiting') soonest = Math.min(soonest, AUTONOMOUS_FINALIZE_POLL_MS)
261
+ }
262
+ if (Number.isFinite(soonest)) arm(soonest)
263
+ },
264
+ arm,
265
+ }
266
+ return finalizer
267
+ }
@@ -0,0 +1,277 @@
1
+ // The phone's Stop, as the server hears it (6.81.0).
2
+ //
3
+ // WHY THIS EXISTS. Until 6.81.0 the server learned that a G2 meeting had ended only from
4
+ // POST /api/meeting/save, and the phone sends that save only after its own finalize has
5
+ // read IndexedDB, reconciled the server's ledger and drained every chunk. iOS suspends
6
+ // IndexedDB while the companion is off screen, so a Stop pressed with the phone in a pocket
7
+ // sat unsaved until the app came back on screen (2026-10-20: 11:19 meeting qbbgyq, Stop at
8
+ // 12:43, `stop_blocked` and `foreground_reconcile_failed` "Load failed", saved only by a new
9
+ // WebView at 12:47; 14:07 meeting mip9qb, Stop at 15:13:50, saved at 15:14:37 only once
10
+ // the phone was visible). A fetch works while hidden; IndexedDB does not.
11
+ //
12
+ // WHAT A STOP INTENT IS. One small POST the phone can send the moment Stop is pressed,
13
+ // before any IndexedDB work: "this meeting has ended, and the last chunk I captured is
14
+ // index N". The server records it durably and from then on can finish the save itself
15
+ // once it holds every chunk 0..N (lib/meeting-autonomous-finalize.ts decides when).
16
+ //
17
+ // WHAT IT IS NOT. It never closes the session, never refuses a chunk, and never saves by
18
+ // itself. Chunks keep arriving into the same live session after it (that is the merge),
19
+ // and a chunk past N cancels it, because a meeting that is still recording must never be
20
+ // finalized.
21
+
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
23
+ import { dirname } from 'node:path'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { dataPath } from './data-dir.js'
26
+ import { LOCAL_FIRST_MEETING_IDLE_RETENTION_MS } from './local-first-meetings-contract.js'
27
+
28
+ export const STOP_INTENTS_FILE_NAME = 'meeting-stop-intents.json'
29
+ export const AUTONOMOUS_SAVE_LOG_NAME = 'meeting-autonomous-save.jsonl'
30
+
31
+ /** A finished intent stays readable this long, so a late phone can still be answered and logged. */
32
+ export const STOP_INTENT_TERMINAL_RETAIN_MS = 24 * 60 * 60_000
33
+ /**
34
+ * A waiting intent outlives the session it describes by one retention horizon. By then the
35
+ * stranded sweeper (4 h idle) has promoted or quarantined the session and owns it.
36
+ */
37
+ export const STOP_INTENT_WAITING_RETAIN_MS = 2 * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
38
+
39
+ export type StopIntentState = 'waiting' | 'saved' | 'revoked' | 'stood_down'
40
+ /** `other`: an unpinned save (the stranded sweep's own POST, a manual curl). */
41
+ export type StopIntentSavedBy = 'server' | 'phone' | 'other'
42
+
43
+ export interface StopIntentWait {
44
+ reason: string
45
+ missingCount: number
46
+ /** The first few missing indices, for the log. Never the whole list. */
47
+ missingSample: number[]
48
+ at: number
49
+ }
50
+
51
+ export interface StopIntent {
52
+ sessionId: string
53
+ /** The highest chunk index the phone captured. -1 = it captured nothing. */
54
+ finalChunkIndex: number
55
+ source: string
56
+ title?: string
57
+ domain?: string
58
+ serverInstanceId: string
59
+ /** Server clock, first receipt. The phone's clock is never read. */
60
+ receivedAt: number
61
+ updatedAt: number
62
+ /** How many times the phone sent it (a hidden phone retries). */
63
+ posts: number
64
+ state: StopIntentState
65
+ /** Why it is revoked or stood down. */
66
+ reason?: string
67
+ savedBy?: StopIntentSavedBy
68
+ savedAt?: number
69
+ filename?: string
70
+ sidecarPath?: string
71
+ chunkCount?: number
72
+ canonicalCount?: number
73
+ lastWait?: StopIntentWait | null
74
+ /** Set when the phone first reports back after the server saved on its own. */
75
+ phoneConfirmedAt?: number
76
+ phoneConfirmedBy?: string
77
+ /** Chunks the phone re-sent after the save that the saved meeting already held. */
78
+ lateReplays?: number
79
+ /** Chunks the phone sent after the save that the saved meeting did NOT hold. Should stay 0. */
80
+ lateRefused?: number
81
+ /** The state of an earlier Stop for this session that this one replaced. */
82
+ replaced?: StopIntentState
83
+ }
84
+
85
+ export type RecordStopIntentResult =
86
+ | { ok: true; intent: StopIntent; created: boolean; raised: boolean }
87
+ | { ok: false; reason: 'terminal'; intent: StopIntent }
88
+
89
+ export interface StopIntentStore {
90
+ readonly path: string
91
+ get(sessionId: string): StopIntent | null
92
+ /** Waiting intents, oldest first. */
93
+ waiting(): StopIntent[]
94
+ /**
95
+ * Record or refresh the phone's Stop. Re-posting is idempotent; a higher final index
96
+ * raises it (never lowers it: the server must never finalize before the highest index the
97
+ * phone ever claimed has arrived).
98
+ */
99
+ record(input: { sessionId: string; finalChunkIndex: number; source: string; title?: string; domain?: string; serverInstanceId: string }): RecordStopIntentResult
100
+ update(sessionId: string, patch: Partial<StopIntent>): StopIntent | null
101
+ }
102
+
103
+ function isRecord(value: unknown): value is Record<string, unknown> {
104
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
105
+ }
106
+
107
+ function readIntents(path: string): Record<string, StopIntent> {
108
+ if (!existsSync(path)) return {}
109
+ try {
110
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
111
+ if (!isRecord(parsed) || !isRecord(parsed.intents)) return {}
112
+ const out: Record<string, StopIntent> = {}
113
+ for (const [id, raw] of Object.entries(parsed.intents)) {
114
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id) || !isRecord(raw)) continue
115
+ const intent = raw as unknown as StopIntent
116
+ if (intent.sessionId !== id) continue
117
+ if (!Number.isInteger(intent.finalChunkIndex) || intent.finalChunkIndex < -1) continue
118
+ if (!Number.isFinite(intent.receivedAt) || !Number.isFinite(intent.updatedAt)) continue
119
+ if (!['waiting', 'saved', 'revoked', 'stood_down'].includes(intent.state)) continue
120
+ out[id] = intent
121
+ }
122
+ return out
123
+ } catch {
124
+ // A corrupt ledger is set aside, never silently trusted or deleted.
125
+ try { renameSync(path, `${path}.corrupt.${Date.now()}`) } catch {}
126
+ return {}
127
+ }
128
+ }
129
+
130
+ export function createStopIntentStore(options: { path?: string; now?: () => number } = {}): StopIntentStore {
131
+ const path = options.path ?? dataPath(STOP_INTENTS_FILE_NAME)
132
+ const now = options.now ?? Date.now
133
+ let cache: Record<string, StopIntent> | null = null
134
+
135
+ const load = (): Record<string, StopIntent> => {
136
+ if (!cache) cache = readIntents(path)
137
+ return cache
138
+ }
139
+ const prune = (intents: Record<string, StopIntent>, at: number) => {
140
+ for (const [id, intent] of Object.entries(intents)) {
141
+ const retain = intent.state === 'waiting' ? STOP_INTENT_WAITING_RETAIN_MS : STOP_INTENT_TERMINAL_RETAIN_MS
142
+ if (at - intent.updatedAt > retain) delete intents[id]
143
+ }
144
+ }
145
+ // Persisted before the caller answers, so a 2xx means a restart still knows the Stop.
146
+ const persist = (intents: Record<string, StopIntent>) => {
147
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
148
+ durableAtomicWriteFileSync(path, JSON.stringify({ schemaVersion: 1, intents }, null, 2), { mode: 0o600 })
149
+ }
150
+
151
+ return {
152
+ path,
153
+ get(sessionId) {
154
+ const intent = load()[sessionId]
155
+ return intent ? { ...intent } : null
156
+ },
157
+ waiting() {
158
+ return Object.values(load())
159
+ .filter(intent => intent.state === 'waiting')
160
+ .sort((a, b) => a.receivedAt - b.receivedAt)
161
+ .map(intent => ({ ...intent }))
162
+ },
163
+ record(input) {
164
+ const at = now()
165
+ const intents = load()
166
+ prune(intents, at)
167
+ const prior = intents[input.sessionId]
168
+ // A saved Stop is final. A revoked or stood-down one is history: the phone resumed and
169
+ // stopped again, so this is a new Stop and replaces it.
170
+ if (prior && prior.state === 'saved') return { ok: false, reason: 'terminal', intent: { ...prior } }
171
+ const existing = prior?.state === 'waiting' ? prior : undefined
172
+ if (existing) {
173
+ const raised = input.finalChunkIndex > existing.finalChunkIndex
174
+ const next: StopIntent = {
175
+ ...existing,
176
+ finalChunkIndex: Math.max(existing.finalChunkIndex, input.finalChunkIndex),
177
+ title: input.title ?? existing.title,
178
+ domain: input.domain ?? existing.domain,
179
+ updatedAt: at,
180
+ posts: existing.posts + 1,
181
+ }
182
+ intents[input.sessionId] = next
183
+ persist(intents)
184
+ return { ok: true, intent: { ...next }, created: false, raised }
185
+ }
186
+ const intent: StopIntent = {
187
+ sessionId: input.sessionId,
188
+ finalChunkIndex: input.finalChunkIndex,
189
+ source: input.source,
190
+ ...(input.title ? { title: input.title } : {}),
191
+ ...(input.domain ? { domain: input.domain } : {}),
192
+ serverInstanceId: input.serverInstanceId,
193
+ receivedAt: at,
194
+ updatedAt: at,
195
+ posts: 1,
196
+ state: 'waiting',
197
+ lastWait: null,
198
+ ...(prior ? { replaced: prior.state } : {}),
199
+ }
200
+ intents[input.sessionId] = intent
201
+ persist(intents)
202
+ return { ok: true, intent: { ...intent }, created: true, raised: false }
203
+ },
204
+ update(sessionId, patch) {
205
+ const intents = load()
206
+ const existing = intents[sessionId]
207
+ if (!existing) return null
208
+ const next: StopIntent = { ...existing, ...patch, sessionId, updatedAt: now() }
209
+ intents[sessionId] = next
210
+ try { persist(intents) } catch (error) {
211
+ console.error(`[meeting/auto-save] Could not persist the stop intent for ${sessionId}: ${error instanceof Error ? error.message : error}`)
212
+ }
213
+ return { ...next }
214
+ },
215
+ }
216
+ }
217
+
218
+ let defaultStore: StopIntentStore | null = null
219
+ /** The process-wide store, created on first use (never at import: tests import this module). */
220
+ export function getStopIntentStore(): StopIntentStore {
221
+ if (!defaultStore) defaultStore = createStopIntentStore()
222
+ return defaultStore
223
+ }
224
+
225
+ /** One JSON line per autonomous-save event, beside the server log line that says the same. */
226
+ export function appendAutonomousSaveLog(event: string, fields: Record<string, unknown>, path = dataPath(AUTONOMOUS_SAVE_LOG_NAME)): void {
227
+ try {
228
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
229
+ appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), event, ...fields })}\n`, { mode: 0o600 })
230
+ } catch { /* the console line is the fallback record */ }
231
+ }
232
+
233
+ export interface SavedChunkText {
234
+ text: string
235
+ speaker: string
236
+ elapsed: number
237
+ [key: string]: unknown
238
+ }
239
+ export type SavedChunkReplay = { kind: 'canonical'; chunk: SavedChunkText } | { kind: 'empty' } | null
240
+
241
+ let sidecarCache: { path: string; mtimeMs: number; entries: Map<number, SavedChunkText> } | null = null
242
+
243
+ /**
244
+ * What a meeting the server saved on its own holds at one chunk index, for answering a
245
+ * phone that re-sends that chunk after the save (its upload response was lost while it was
246
+ * hidden). `canonical`: the saved text. `empty`: the chunk arrived and was silence. `null`:
247
+ * the saved meeting never held this index, so the replay is refused as before (410).
248
+ *
249
+ * Read from the saved sidecar, not from memory, so it survives a restart. One sidecar is
250
+ * cached at a time: replays arrive in a burst for one meeting.
251
+ */
252
+ export function savedChunkReplay(sidecarPath: string, chunkIndex: number, asrCompleted: readonly number[]): SavedChunkReplay {
253
+ let entries: Map<number, SavedChunkText>
254
+ try {
255
+ const mtimeMs = statSync(sidecarPath).mtimeMs
256
+ if (sidecarCache && sidecarCache.path === sidecarPath && sidecarCache.mtimeMs === mtimeMs) {
257
+ entries = sidecarCache.entries
258
+ } else {
259
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as { chunkEntries?: unknown }
260
+ entries = new Map()
261
+ for (const entry of Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries : []) {
262
+ const candidate = entry as { chunkIndex?: unknown; chunk?: unknown }
263
+ const chunk = candidate?.chunk as Partial<SavedChunkText> | undefined
264
+ if (!Number.isInteger(candidate?.chunkIndex) || !chunk) continue
265
+ if (typeof chunk.text !== 'string' || !chunk.text || typeof chunk.speaker !== 'string') continue
266
+ if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) continue
267
+ entries.set(candidate.chunkIndex as number, chunk as SavedChunkText)
268
+ }
269
+ sidecarCache = { path: sidecarPath, mtimeMs, entries }
270
+ }
271
+ } catch {
272
+ return null
273
+ }
274
+ const chunk = entries.get(chunkIndex)
275
+ if (chunk) return { kind: 'canonical', chunk }
276
+ return asrCompleted.includes(chunkIndex) ? { kind: 'empty' } : null
277
+ }
@@ -11,6 +11,16 @@ import { createHash } from 'node:crypto'
11
11
  import { Router } from 'express'
12
12
  import { emitDisplay } from '../lib/display-bus.js'
13
13
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
14
+ import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
15
+ import {
16
+ AUTONOMOUS_FINALIZE_POLL_MS,
17
+ AUTONOMOUS_FINALIZE_SETTLE_MS,
18
+ createAutonomousFinalizer,
19
+ indicesFromRanges,
20
+ type AutonomousFinalizer,
21
+ type AutonomousSaveOutcome,
22
+ type SessionLedger,
23
+ } from '../lib/meeting-autonomous-finalize.js'
14
24
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
15
25
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
16
26
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
@@ -231,10 +241,25 @@ interface MeetingSessionSource {
231
241
  delete(sessionId: string, options?: { preserveAudio?: boolean }): void
232
242
  /** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
233
243
  getState?(sessionId: string): 'active' | 'closed' | 'missing'
244
+ /** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
245
+ getLedger?(sessionId: string): SessionLedger
234
246
  }
235
247
 
236
248
  const defaultSessionState = (sessionId: string): 'active' | 'closed' | 'missing' => getMeetingSessionStatus(sessionId).state
237
249
 
250
+ /** 6.81.0: the live ledger as the autonomous finalizer reads it (the status route's own numbers). */
251
+ function defaultSessionLedger(sessionId: string): SessionLedger {
252
+ const status = getMeetingSessionStatus(sessionId)
253
+ const lastActivityAt = status.lastActivityAt ? Date.parse(status.lastActivityAt) : NaN
254
+ return {
255
+ state: status.state,
256
+ received: indicesFromRanges(status.receivedRanges),
257
+ asrCompleted: indicesFromRanges(status.asrCompletedRanges),
258
+ maxChunkIndex: status.maxChunkIndex,
259
+ lastActivityAt: Number.isFinite(lastActivityAt) ? lastActivityAt : null,
260
+ }
261
+ }
262
+
238
263
  export interface MeetingRouteDependencies {
239
264
  store?: MeetingStore
240
265
  sessions?: MeetingSessionSource
@@ -252,6 +277,16 @@ export interface MeetingRouteDependencies {
252
277
  stopRequests?: StopRequestStore
253
278
  /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
254
279
  transcriptCleanup?: TranscriptCleanupRunner
280
+ /** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
281
+ stopIntents?: StopIntentStore
282
+ /** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
283
+ autonomousFinalize?: {
284
+ now?: () => number
285
+ schedule?: (fn: () => void, ms: number) => () => void
286
+ ready?: () => boolean
287
+ log?: (event: string, fields: Record<string, unknown>) => void
288
+ onCreate?: (finalizer: AutonomousFinalizer) => void
289
+ }
255
290
  }
256
291
 
257
292
  const activeFinalizationJobs = new Set<string>()
@@ -584,6 +619,21 @@ function publicDisposition(disposition: CaptureDisposition | null): Record<strin
584
619
  }
585
620
  }
586
621
 
622
+ /** 6.81.0: the Stop the server holds, as the status and stop routes show it. Never a path. */
623
+ function publicStopIntent(intent: StopIntent): Record<string, unknown> {
624
+ return {
625
+ finalChunkIndex: intent.finalChunkIndex,
626
+ state: intent.state,
627
+ receivedAt: new Date(intent.receivedAt).toISOString(),
628
+ savedBy: intent.savedBy ?? null,
629
+ savedAt: intent.savedAt ? new Date(intent.savedAt).toISOString() : null,
630
+ reason: intent.reason ?? null,
631
+ waitingFor: intent.state === 'waiting' && intent.lastWait
632
+ ? { reason: intent.lastWait.reason, missingCount: intent.lastWait.missingCount, missingSample: intent.lastWait.missingSample }
633
+ : null,
634
+ }
635
+ }
636
+
587
637
  function suppliedServerPin(
588
638
  req: { get: (name: string) => string | undefined },
589
639
  fallback: unknown,
@@ -608,6 +658,83 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
608
658
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
609
659
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
610
660
 
661
+ // ── 6.81.0: the server finishes a stopped meeting on its own ─────────────────────
662
+ // lib/meeting-stop-intents.ts says what a Stop intent is; lib/meeting-autonomous-
663
+ // finalize.ts says when the server may save (only a complete capture, never early).
664
+ const stopIntents = deps.stopIntents ?? getStopIntentStore()
665
+ const autoDeps = deps.autonomousFinalize ?? {}
666
+ const autoNow = autoDeps.now ?? Date.now
667
+ const ledgerFor = (sessionId: string): SessionLedger => (sessions.getLedger ?? defaultSessionLedger)(sessionId)
668
+ const autoLog = autoDeps.log ?? ((event: string, fields: Record<string, unknown>) => {
669
+ const line = `[meeting/auto-save] ${event} ${String(fields.sessionId ?? '')} ${JSON.stringify(fields)}`
670
+ if (event === 'finalize_failed' || event === 'revoked' || event === 'late_chunk_refused') console.warn(line)
671
+ else console.log(line)
672
+ appendAutonomousSaveLog(event, fields)
673
+ })
674
+
675
+ /** Someone saved a meeting the phone had told us about before the server did: theirs, not ours. */
676
+ const notePhoneSavedFirst = (sessionId: string, savedBy: 'phone' | 'other') => {
677
+ const intent = stopIntents.get(sessionId)
678
+ if (!intent || intent.state !== 'waiting') return
679
+ const at = autoNow()
680
+ stopIntents.update(sessionId, { state: 'saved', savedBy, savedAt: at, lastWait: null })
681
+ autoLog(savedBy === 'phone' ? 'phone_saved' : 'client_saved', { sessionId, waitedMs: at - intent.receivedAt, finalChunkIndex: intent.finalChunkIndex })
682
+ }
683
+ /** The phone came back after the server saved on its own. Logged once, with how it showed up. */
684
+ const notePhoneAfterAutonomousSave = (sessionId: string, via: string) => {
685
+ const intent = stopIntents.get(sessionId)
686
+ if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || intent.phoneConfirmedAt) return
687
+ const at = autoNow()
688
+ stopIntents.update(sessionId, { phoneConfirmedAt: at, phoneConfirmedBy: via })
689
+ autoLog('phone_confirmed', { sessionId, via, sinceSaveMs: at - (intent.savedAt ?? at), lateReplays: intent.lateReplays ?? 0, lateRefused: intent.lateRefused ?? 0 })
690
+ }
691
+
692
+ const autonomousSave = async (intent: StopIntent): Promise<AutonomousSaveOutcome> => {
693
+ // The phone pinned one server instance. A different one (a reinstall) is not its Mac.
694
+ if (intent.serverInstanceId !== getServerInstanceId()) return { ok: false, status: 409, reason: 'server_instance_mismatch' }
695
+ const entries = sessions.getChunkEntries(intent.sessionId) ?? []
696
+ const canonicalCount = entries.filter(entry => entry?.chunk?.text).length
697
+ const integrity = sessions.getIntegrity(intent.sessionId)
698
+ let status = 0
699
+ let payload: Record<string, unknown> = {}
700
+ const saved = await performSave(
701
+ intent.sessionId,
702
+ { ...(intent.title ? { title: intent.title } : {}), ...(intent.domain ? { domain: intent.domain } : {}) },
703
+ (code, body) => { status = code; payload = body },
704
+ 'server',
705
+ )
706
+ if (!saved || status !== 200) return { ok: false, status, reason: String(payload.reason ?? 'meeting_save_error') }
707
+ // Someone saved it between the decision and the lock: theirs, not ours.
708
+ if (payload.replayed === true) return { ok: false, status: 200, reason: 'already_saved' }
709
+ return {
710
+ ok: true,
711
+ filename: saved.filename,
712
+ sidecarPath: saved.sidecarPath,
713
+ chunkCount: intent.finalChunkIndex + 1,
714
+ canonicalCount,
715
+ missingIndices: integrity?.missingIndices ?? [],
716
+ }
717
+ }
718
+
719
+ const autonomousFinalizer = createAutonomousFinalizer({
720
+ waiting: () => stopIntents.waiting(),
721
+ get: sessionId => stopIntents.get(sessionId),
722
+ update: (sessionId, patch) => { stopIntents.update(sessionId, patch) },
723
+ ledger: ledgerFor,
724
+ alreadySaved: sessionId => store.findBySessionId(sessionId) !== null,
725
+ saving: sessionId => savingSessions.has(sessionId),
726
+ save: autonomousSave,
727
+ ready: autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()),
728
+ now: autoNow,
729
+ schedule: autoDeps.schedule ?? ((fn, ms) => {
730
+ const timer = setTimeout(fn, ms)
731
+ timer.unref?.()
732
+ return () => clearTimeout(timer)
733
+ }),
734
+ log: autoLog,
735
+ })
736
+ autoDeps.onCreate?.(autonomousFinalizer)
737
+
611
738
  let recoveryPendingLogged = false
612
739
  router.get('/meeting/sessions/:sessionId/status', (req, res) => {
613
740
  const sessionId = String(req.params.sessionId ?? '')
@@ -651,6 +778,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
651
778
  // A saved meeting or a live session is the truth for itself; the quarantine is
652
779
  // read only when neither is.
653
780
  const disposition = state === 'saved' || state === 'active' ? null : readCaptureDisposition(sessionId)
781
+ // 6.81.0: a pinned status read is the phone; one after the server saved on its own is its return.
782
+ if (saved && pin) notePhoneAfterAutonomousSave(sessionId, 'status')
783
+ const intent = stopIntents.get(sessionId)
654
784
  res.set('Cache-Control', 'private, no-store')
655
785
  res.json({
656
786
  sessionId,
@@ -670,6 +800,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
670
800
  // (null otherwise). Older clients ignore both.
671
801
  outcome: captureOutcome(state, disposition),
672
802
  disposition: publicDisposition(disposition),
803
+ // 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
804
+ stopIntent: intent ? publicStopIntent(intent) : null,
673
805
  })
674
806
  })
675
807
 
@@ -781,43 +913,124 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
781
913
  res.json(stopRequestBody(request, savingSessions.has(sessionId) ? { finalizing: true } : {}))
782
914
  })
783
915
 
784
- router.post('/meeting/save', async (req, res) => {
916
+ // ── 6.81.0: the phone's Stop ──────────────────────────────────────────────────────
917
+ // POST /meeting/sessions/:id/stop {finalChunkIndex, serverInstanceId}. One small fetch the
918
+ // phone can send the moment Stop is pressed, before any IndexedDB work, so a phone that is
919
+ // off screen still tells the Mac. It never closes the session and never refuses a chunk:
920
+ // the server saves on its own only once it holds every chunk 0..finalChunkIndex.
921
+ router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
922
+ const sessionId = String(req.params.sessionId ?? '')
923
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
924
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
925
+ return
926
+ }
927
+ if (MAC_CAPTURE_ID.test(sessionId)) {
928
+ res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
929
+ return
930
+ }
931
+ const body = req.body as Record<string, unknown> | undefined
932
+ const finalChunkIndex = body?.finalChunkIndex
933
+ if (!Number.isInteger(finalChunkIndex) || (finalChunkIndex as number) < -1 || (finalChunkIndex as number) > 1_000_000) {
934
+ res.status(400).json({ error: 'finalChunkIndex must be a whole number, -1 or more', reason: 'invalid_final_chunk_index' })
935
+ return
936
+ }
937
+ if ((body?.title !== undefined && typeof body.title !== 'string') || (body?.domain !== undefined && typeof body.domain !== 'string')) {
938
+ res.status(400).json({ error: 'title and domain must be strings', reason: 'invalid_metadata' })
939
+ return
940
+ }
941
+ const serverInstanceId = getServerInstanceId()
942
+ if (!serverInstanceId) {
943
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
944
+ return
945
+ }
946
+ // Required, not optional: only a phone pinned to THIS Mac may hand it a Stop.
947
+ const pin = suppliedServerPin(req, body?.serverInstanceId)
948
+ if (!pin) {
949
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
950
+ return
951
+ }
952
+ if (pin !== serverInstanceId) {
953
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
954
+ return
955
+ }
956
+ res.set('Cache-Control', 'private, no-store')
957
+ const saved = store.findBySessionId(sessionId)
958
+ if (saved) {
959
+ notePhoneAfterAutonomousSave(sessionId, 'stop_retry')
960
+ res.json({ sessionId, state: 'saved', saveReceipt: publicSaveResponse(saved, true) })
961
+ return
962
+ }
963
+ const ready = (autoDeps.ready ?? (() => maintenanceAdmissionsOpen() && durableSessionRecoveryHasRun()))()
964
+ // Before recovery has run the ledger is empty by construction, so it proves nothing:
965
+ // record the Stop and let the finalizer judge it once the gate opens.
966
+ const ledger = ready ? ledgerFor(sessionId) : null
967
+ if (ledger?.state === 'closed') {
968
+ res.status(409).json({ error: 'This meeting is closed without a save', reason: 'not_live', state: 'closed' })
969
+ return
970
+ }
971
+ if (ledger && ledger.maxChunkIndex > (finalChunkIndex as number)) {
972
+ // The Mac holds audio past the phone's last chunk. Never take that Stop.
973
+ res.status(409).json({ error: 'The Mac holds chunks past finalChunkIndex', reason: 'final_index_behind_server', maxChunkIndex: ledger.maxChunkIndex })
974
+ return
975
+ }
976
+ const source = typeof body?.source === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(body.source) ? body.source : 'companion'
977
+ let result: ReturnType<StopIntentStore['record']>
978
+ try {
979
+ result = stopIntents.record({
980
+ sessionId,
981
+ finalChunkIndex: finalChunkIndex as number,
982
+ source,
983
+ ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.slice(0, 160) } : {}),
984
+ ...(typeof body?.domain === 'string' && body.domain.trim() ? { domain: body.domain } : {}),
985
+ serverInstanceId,
986
+ })
987
+ } catch (error) {
988
+ // Not durable means not received: the phone must keep its own Stop path.
989
+ console.error(`[meeting/auto-save] Could not record the Stop for ${sessionId}: ${error instanceof Error ? error.message : error}`)
990
+ res.status(503).json({ error: 'Stop could not be recorded; retry', reason: 'stop_persistence_failed', retryable: true })
991
+ return
992
+ }
993
+ if (!result.ok) {
994
+ res.json({ sessionId, state: 'saved', stopIntent: publicStopIntent(result.intent) })
995
+ return
996
+ }
997
+ const intent = result.intent
998
+ if (result.created || result.raised) {
999
+ autoLog(result.created ? 'stop_received' : 'stop_raised', {
1000
+ sessionId, source, finalChunkIndex: intent.finalChunkIndex, posts: intent.posts,
1001
+ received: ledger?.received.length ?? null, replaced: intent.replaced ?? null,
1002
+ })
1003
+ }
1004
+ void autonomousFinalizer.tick('stop_received')
1005
+ res.status(202).json({
1006
+ sessionId,
1007
+ state: 'stop_received',
1008
+ finalChunkIndex: intent.finalChunkIndex,
1009
+ receivedAt: new Date(intent.receivedAt).toISOString(),
1010
+ posts: intent.posts,
1011
+ autonomousSave: { settleMs: AUTONOMOUS_FINALIZE_SETTLE_MS, pollMs: AUTONOMOUS_FINALIZE_POLL_MS },
1012
+ })
1013
+ })
1014
+
1015
+ /**
1016
+ * The save itself, shared by the phone's POST /meeting/save and the server's own save of a
1017
+ * stopped meeting (6.81.0). One body so the two can never drift: the same lock, the same
1018
+ * idempotent receipt, the same durable sidecar-then-job order, and the same maintenance
1019
+ * lease order (the finalization job's lease is taken before the save lease is released).
1020
+ *
1021
+ * `respond` is called exactly once, before any background work is scheduled, as the route
1022
+ * always did. `origin` changes only bookkeeping: who is recorded as having saved, and the
1023
+ * phone-confirmed line when the phone replays a save the server already made.
1024
+ */
1025
+ async function performSave(
1026
+ sessionId: string,
1027
+ body: Record<string, unknown> | undefined,
1028
+ respond: (status: number, payload: Record<string, unknown>, noStore?: boolean) => void,
1029
+ origin: 'phone' | 'client' | 'server',
1030
+ ): Promise<SavedMeeting | null> {
785
1031
  let lockedSessionId: string | null = null
786
1032
  let maintenanceLease: MaintenanceWorkLease | undefined
787
1033
  try {
788
- const body = req.body as Record<string, unknown> | undefined
789
- const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
790
- if (!sessionId) {
791
- res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
792
- return
793
- }
794
- if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
795
- res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
796
- return
797
- }
798
- if (body?.title !== undefined && typeof body.title !== 'string') {
799
- res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
800
- return
801
- }
802
- if (body?.domain !== undefined && typeof body.domain !== 'string') {
803
- res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
804
- return
805
- }
806
- const serverInstanceId = getServerInstanceId()
807
- if (!serverInstanceId) {
808
- res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
809
- return
810
- }
811
- const pin = suppliedServerPin(req, body?.serverInstanceId)
812
- if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
813
- res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
814
- return
815
- }
816
- if (pin && pin !== serverInstanceId) {
817
- res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
818
- return
819
- }
820
-
821
1034
  // A response can be lost after both durable files were committed. Find
822
1035
  // the sidecar by session ID so client retry/restart is idempotent.
823
1036
  const alreadySaved = store.findBySessionId(sessionId)
@@ -833,16 +1046,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
833
1046
  sessions,
834
1047
  finalDiarize,
835
1048
  })
836
- res.set('Cache-Control', 'private, no-store')
837
- res.json(publicSaveResponse(alreadySaved, true))
838
- return
1049
+ if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
1050
+ respond(200, publicSaveResponse(alreadySaved, true), true)
1051
+ return alreadySaved
839
1052
  }
840
1053
  if (MAC_CAPTURE_ID.test(sessionId)) {
841
1054
  requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
842
1055
  }
843
1056
  if (savingSessions.has(sessionId)) {
844
- res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
845
- return
1057
+ respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
1058
+ return null
846
1059
  }
847
1060
  // Saving/finalizing an already-recording session is a drain
848
1061
  // continuation. It must remain admitted so existing audio can reach a
@@ -858,10 +1071,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
858
1071
  // lets a client stop asking ("nothing to save") instead of retrying a 404 all day.
859
1072
  // Still 404, so every existing client reads it exactly as before.
860
1073
  const known = (sessions.getState ?? defaultSessionState)(sessionId)
861
- res.status(404).json(known === 'active' || known === 'closed'
1074
+ respond(404, known === 'active' || known === 'closed'
862
1075
  ? { error: `Nothing to save for session ${sessionId}`, reason: 'nothing_to_save', state: known }
863
1076
  : { error: `No transcript found for session ${sessionId}`, reason: 'session_not_found' })
864
- return
1077
+ return null
865
1078
  }
866
1079
 
867
1080
  const chunks = sessions.getChunks(sessionId) ?? []
@@ -913,6 +1126,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
913
1126
  finalizationRequired,
914
1127
  claimPending,
915
1128
  })
1129
+ // 6.81.0: who saved. A pinned save is the phone's; an unpinned one (the stranded sweep's
1130
+ // own POST, a manual curl) is someone else's. Either closes a waiting Stop. The server's
1131
+ // own save is recorded by its finalizer.
1132
+ if (origin !== 'server') notePhoneSavedFirst(sessionId, origin === 'phone' ? 'phone' : 'other')
916
1133
 
917
1134
  // Two-phase replay intent: the canonical sidecar above is the recovery
918
1135
  // source of truth, and this indexed job is its fast execution ledger.
@@ -965,6 +1182,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
965
1182
  filename: saved.filename,
966
1183
  durationMin: saved.durationMin,
967
1184
  domain: saved.domain,
1185
+ // 6.81.0, additive: the server saved this on its own after the phone's Stop.
1186
+ ...(origin === 'server' ? { savedBy: 'server' } : {}),
968
1187
  },
969
1188
  })
970
1189
  } catch (error) {
@@ -994,8 +1213,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
994
1213
  finalizationJob = null
995
1214
  }
996
1215
 
997
- res.set('Cache-Control', 'private, no-store')
998
- res.json(publicSaveResponse(saved))
1216
+ respond(200, publicSaveResponse(saved), true)
999
1217
 
1000
1218
  if (finalizationJob) {
1001
1219
  scheduleFinalizationJob(finalizationJob, {
@@ -1007,17 +1225,68 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1007
1225
  transcriptCleanup,
1008
1226
  })
1009
1227
  }
1228
+ return saved
1010
1229
  } catch (error) {
1011
1230
  if (error instanceof MeetingStoreError) {
1012
- res.status(error.status).json({ error: error.message, reason: error.code })
1013
- return
1231
+ respond(error.status, { error: error.message, reason: error.code })
1232
+ return null
1014
1233
  }
1015
1234
  console.error('[meeting/save] Finalization failed:', error)
1016
- res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
1235
+ respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
1236
+ return null
1017
1237
  } finally {
1018
1238
  maintenanceLease?.release()
1019
1239
  if (lockedSessionId) savingSessions.delete(lockedSessionId)
1020
1240
  }
1241
+ }
1242
+
1243
+ router.post('/meeting/save', async (req, res) => {
1244
+ let responded = false
1245
+ const respond = (status: number, payload: Record<string, unknown>, noStore = false) => {
1246
+ if (responded) return
1247
+ responded = true
1248
+ if (noStore) res.set('Cache-Control', 'private, no-store')
1249
+ res.status(status).json(payload)
1250
+ }
1251
+ try {
1252
+ const body = req.body as Record<string, unknown> | undefined
1253
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
1254
+ if (!sessionId) {
1255
+ res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
1256
+ return
1257
+ }
1258
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1259
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1260
+ return
1261
+ }
1262
+ if (body?.title !== undefined && typeof body.title !== 'string') {
1263
+ res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
1264
+ return
1265
+ }
1266
+ if (body?.domain !== undefined && typeof body.domain !== 'string') {
1267
+ res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
1268
+ return
1269
+ }
1270
+ const serverInstanceId = getServerInstanceId()
1271
+ if (!serverInstanceId) {
1272
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
1273
+ return
1274
+ }
1275
+ const pin = suppliedServerPin(req, body?.serverInstanceId)
1276
+ if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
1277
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
1278
+ return
1279
+ }
1280
+ if (pin && pin !== serverInstanceId) {
1281
+ res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
1282
+ return
1283
+ }
1284
+ // Only the phone pins its saves (the local-first contract); the stranded sweep does not.
1285
+ await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
1286
+ } catch (error) {
1287
+ console.error('[meeting/save] Finalization failed:', error)
1288
+ respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
1289
+ }
1021
1290
  })
1022
1291
 
1023
1292
  // ── Record identity for the speaker surfaces (6.47.0) ─────────────────
@@ -2669,7 +2938,17 @@ async function finalizeBatch(options: {
2669
2938
  }
2670
2939
  }
2671
2940
 
2672
- export const meetingRouter = createMeetingRouter()
2941
+ let defaultAutonomousFinalizer: AutonomousFinalizer | null = null
2942
+ export const meetingRouter = createMeetingRouter({ autonomousFinalize: { onCreate: finalizer => { defaultAutonomousFinalizer = finalizer } } })
2943
+
2944
+ /**
2945
+ * 6.81.0: at boot (once admissions open), pick up every Stop the server was holding when it
2946
+ * last exited and check it again. A Stop is durable, so a restart between the Stop and the
2947
+ * last chunk loses nothing: the finalizer waits for recovery, then for the chunks.
2948
+ */
2949
+ export function resumeAutonomousMeetingSaves(): void {
2950
+ void defaultAutonomousFinalizer?.tick('boot')
2951
+ }
2673
2952
 
2674
2953
  /** 6.77.0: what the transcript cleanup routes need from the meeting layer. */
2675
2954
  export function transcriptCleanupRouteDeps(store: MeetingStore = getMeetingStore()): TranscriptCleanupRouteDeps {
@@ -1,5 +1,6 @@
1
1
  import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
2
2
  import { getMeetingStore } from '../lib/meeting-store.js'
3
+ import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
3
4
  import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
4
5
  import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
5
6
  import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
@@ -2164,6 +2165,47 @@ interface StreamCandidateInput {
2164
2165
  mode?: string
2165
2166
  }
2166
2167
 
2168
+ /**
2169
+ * 6.81.0: the phone re-sends a chunk after the server saved its meeting on its own.
2170
+ *
2171
+ * WHY. The server saves a stopped meeting itself only once it holds every chunk, so any chunk
2172
+ * the phone still lists as unconfirmed is one whose upload reached the Mac and whose answer
2173
+ * was lost while the phone was off screen. Before 6.81.0 such a replay was refused 410 and the
2174
+ * phone, holding a closed meeting with "unconfirmed" audio, called it a collision and kept the
2175
+ * audio forever. The saved meeting already holds that chunk, so it answers with the saved
2176
+ * text, exactly as a live session answers a replayed index, and the phone adopts the receipt.
2177
+ *
2178
+ * ONLY for meetings the SERVER saved after a Stop: every other closed session answers 410 as
2179
+ * before. A chunk the saved meeting never held is still refused, and is logged loudly, because
2180
+ * the finalizer's rule (never save an incomplete capture) says it cannot happen.
2181
+ */
2182
+ function replayAfterAutonomousSave(sessionId: string, chunkIndex: number): StreamChunkCompletionResponse | null {
2183
+ let intent: StopIntent | null
2184
+ try { intent = getStopIntentStore().get(sessionId) } catch { return null }
2185
+ if (!intent || intent.state !== 'saved' || intent.savedBy !== 'server' || !intent.sidecarPath) return null
2186
+ const closed = closedSessionRecords.get(sessionId)
2187
+ const replay = savedChunkReplay(intent.sidecarPath, chunkIndex, closed?.asrCompletedIndices ?? [])
2188
+ const store = getStopIntentStore()
2189
+ if (!replay) {
2190
+ store.update(sessionId, { lateRefused: (intent.lateRefused ?? 0) + 1 })
2191
+ const fields = { sessionId, chunkIndex, finalChunkIndex: intent.finalChunkIndex, lateRefused: (intent.lateRefused ?? 0) + 1 }
2192
+ console.warn(`[meeting/auto-save] late_chunk_refused ${sessionId} ${JSON.stringify(fields)}`)
2193
+ appendAutonomousSaveLog('late_chunk_refused', fields)
2194
+ return null
2195
+ }
2196
+ const at = Date.now()
2197
+ store.update(sessionId, {
2198
+ lateReplays: (intent.lateReplays ?? 0) + 1,
2199
+ ...(intent.phoneConfirmedAt ? {} : { phoneConfirmedAt: at, phoneConfirmedBy: 'chunk_replay' }),
2200
+ })
2201
+ const fields = { sessionId, chunkIndex, kind: replay.kind, lateReplays: (intent.lateReplays ?? 0) + 1, sinceSaveMs: at - (intent.savedAt ?? at) }
2202
+ console.log(`[meeting/auto-save] late_replay ${sessionId} ${JSON.stringify(fields)}`)
2203
+ appendAutonomousSaveLog('late_replay', fields)
2204
+ if (!intent.phoneConfirmedAt) appendAutonomousSaveLog('phone_confirmed', { sessionId, via: 'chunk_replay', sinceSaveMs: at - (intent.savedAt ?? at) })
2205
+ if (replay.kind === 'canonical') return canonicalChunkResponse(replay.chunk as unknown as TranscriptChunk, sessionId, chunkIndex)
2206
+ return emptyChunkResponse({ text: '', speaker: 'Ext', elapsed: 0, canonical: false }, sessionId, chunkIndex)
2207
+ }
2208
+
2167
2209
  async function processStreamChunk(opts: {
2168
2210
  sessionId: string
2169
2211
  chunkIndex: number
@@ -2182,7 +2224,12 @@ async function processStreamChunk(opts: {
2182
2224
  const tReq = performance.now()
2183
2225
  validateSessionId(sessionId)
2184
2226
  validateChunkIndex(chunkIndex)
2185
- if (isSessionDeleted(sessionId)) throw makeHttpError(410, 'session is closed', 'session_closed')
2227
+ if (isSessionDeleted(sessionId)) {
2228
+ // 6.81.0: a meeting the server saved on its own answers a chunk it already holds.
2229
+ const replay = replayAfterAutonomousSave(sessionId, chunkIndex)
2230
+ if (replay) return replay
2231
+ throw makeHttpError(410, 'session is closed', 'session_closed')
2232
+ }
2186
2233
  if (audioBuffer.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
2187
2234
 
2188
2235
  const audioSha256 = sha256Hex(audioBuffer)