@gotcos/glasses-server 6.10.0 → 6.11.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,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.11.0
4
+
5
+ Local-first meeting recovery for COS Glasses build 209+.
6
+
7
+ - **Record through network loss.** The server advertises a versioned
8
+ `localFirstMeetings` capability with its stable instance ID. Compatible
9
+ clients can keep audio locally, reconnect to the same server, and reconcile
10
+ the exact sparse set of chunks it durably received.
11
+ - **Durable means acknowledged.** Raw meeting WAVs and the received-index
12
+ ledger are committed atomically before a chunk receives success. Storage
13
+ failures return typed retryable errors; capacity exhaustion returns `507`
14
+ instead of silently discarding audio.
15
+ - **Long meetings stay alive.** Active-session retention is measured from the
16
+ last durable activity, not the meeting start time, so recordings longer than
17
+ four hours are not mistaken for abandoned sessions.
18
+ - **Safe reconnect and close.** Authenticated session-status responses expose
19
+ exact compressed receive ranges, retention, and closed/saved state. Durable
20
+ tombstones prevent a late or replaying client from recreating a completed
21
+ meeting after a restart.
22
+ - **Idempotent finalization.** Repeating `POST /api/meeting/save` for an already
23
+ saved session returns the original versioned receipt and filename without
24
+ creating a second meeting.
25
+ - **Backward compatible.** Existing live transcription, meeting save, prompt
26
+ recovery, durable queries, and older clients retain their prior routes and
27
+ fields. The new capability, receipt fields, and status route are additive.
28
+
3
29
  ## 6.10.0
4
30
 
5
31
  Opt-in server-owned durable query jobs for COS Glasses build 204+.
package/README.md CHANGED
@@ -72,6 +72,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
72
72
  compatible app builds, their warm transcript also appears live while speaking;
73
73
  final HQ transcription remains authoritative.
74
74
  - Live voice capture + transcription during meetings
75
+ - With COS Glasses build 209+ and server 6.11.0+, meetings continue recording
76
+ locally through a network interruption. Reconnecting reconciles the exact
77
+ chunks already stored by the Mac, uploads only missing audio, and finalizes
78
+ through an idempotent save receipt without duplicating the meeting.
75
79
  - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
76
80
  - Tasks / calendar / people context **if** you run the
77
81
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
@@ -108,6 +112,11 @@ BIND_HOST=0.0.0.0 npm run start:server
108
112
  `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
109
113
  `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
110
114
  back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
115
+ - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
116
+ Restart once, then confirm `/api/health` reports
117
+ `features.localFirstMeetings: true` and
118
+ `capabilities.localFirstMeetings.protocolVersion: 1`. Older app builds keep
119
+ using their existing live-transcription and meeting-save paths.
111
120
 
112
121
  ## License
113
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,53 @@
1
+ export const LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION = 1 as const
2
+ export const LOCAL_FIRST_MEETING_IDLE_RETENTION_MS = 4 * 60 * 60 * 1000
3
+
4
+ export interface LocalFirstMeetingsCapability {
5
+ protocolVersion: typeof LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION
6
+ serverInstanceId: string
7
+ idempotentSave: true
8
+ sessionStatus: true
9
+ retentionMs: number
10
+ }
11
+
12
+ export type MeetingSessionState = 'active' | 'closed' | 'saved' | 'missing'
13
+ export type IndexRange = [start: number, end: number]
14
+
15
+ export function localFirstMeetingsCapability(serverInstanceId: string | null): LocalFirstMeetingsCapability | null {
16
+ if (!serverInstanceId) return null
17
+ return {
18
+ protocolVersion: LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION,
19
+ serverInstanceId,
20
+ idempotentSave: true,
21
+ sessionStatus: true,
22
+ retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
23
+ }
24
+ }
25
+
26
+ /** Exact, compact representation of a sparse received-index ledger. */
27
+ export function compressIndexRanges(indices: readonly number[]): IndexRange[] {
28
+ const sorted = Array.from(new Set(
29
+ indices.filter(value => Number.isInteger(value) && value >= 0),
30
+ )).sort((a, b) => a - b)
31
+ if (sorted.length === 0) return []
32
+
33
+ const ranges: IndexRange[] = []
34
+ let start = sorted[0]
35
+ let end = start
36
+ for (let index = 1; index < sorted.length; index++) {
37
+ const value = sorted[index]
38
+ if (value === end + 1) {
39
+ end = value
40
+ continue
41
+ }
42
+ ranges.push([start, end])
43
+ start = value
44
+ end = value
45
+ }
46
+ ranges.push([start, end])
47
+ return ranges
48
+ }
49
+
50
+ export function retainedUntilIso(lastActivityAt: number | null): string | null {
51
+ if (lastActivityAt == null || !Number.isFinite(lastActivityAt)) return null
52
+ return new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString()
53
+ }
@@ -5,6 +5,7 @@ import { resolve } from 'node:path'
5
5
  import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
6
6
  import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
+ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
8
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
9
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
10
11
  import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
@@ -138,6 +139,7 @@ healthRouter.get('/health', async (_req, res) => {
138
139
  // work can decide whether to prompt for a key.
139
140
  const keyStatus = getKeyStatus()
140
141
  const durableJobs = durableQueryJobStatus()
142
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
141
143
  const features = {
142
144
  claude: claudeAvailable,
143
145
  codex: codexAvailable,
@@ -151,6 +153,7 @@ healthRouter.get('/health', async (_req, res) => {
151
153
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
152
154
  durableQueryJobs: durableJobs.enabled,
153
155
  durableQueryJobsProtocol: durableJobs.protocolVersion,
156
+ localFirstMeetings: localFirstMeetings !== null,
154
157
  }
155
158
  const voice = {
156
159
  hasKey: keyStatus.hasKey,
@@ -170,6 +173,7 @@ healthRouter.get('/health', async (_req, res) => {
170
173
  whisper_health,
171
174
  openai_whisper_budget,
172
175
  codex_models,
176
+ capabilities: localFirstMeetings ? { localFirstMeetings } : {},
173
177
  // /api/health is intentionally unauthenticated for setup diagnostics.
174
178
  // Publish capability only; job counts, retention identities, subscriber
175
179
  // counts, and the storage fingerprint remain internal.
@@ -187,6 +191,7 @@ healthRouter.get('/health', async (_req, res) => {
187
191
  healthRouter.get('/models', async (req, res) => {
188
192
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
189
193
  const durableJobs = durableQueryJobStatus()
194
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
190
195
  res.json({
191
196
  ...catalog,
192
197
  serverInstanceId: getServerInstanceId(),
@@ -195,6 +200,7 @@ healthRouter.get('/models', async (req, res) => {
195
200
  enabled: durableJobs.enabled,
196
201
  protocolVersion: durableJobs.protocolVersion,
197
202
  },
203
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
198
204
  },
199
205
  })
200
206
  })
@@ -31,6 +31,7 @@ import {
31
31
  getSessionProviderCandidates,
32
32
  getSessionStartTime,
33
33
  getSessionTranscript,
34
+ getMeetingSessionStatus,
34
35
  hasSessionAudio,
35
36
  moveSessionAudioToPending,
36
37
  type IndexedTranscriptChunk,
@@ -38,6 +39,7 @@ import {
38
39
  type TranscriptChunk,
39
40
  type TranscriptGapReport,
40
41
  } from './transcribe-stream.js'
42
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
41
43
 
42
44
  interface MeetingSessionSource {
43
45
  getTranscript(sessionId: string): string | null
@@ -98,6 +100,8 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
98
100
  ? Math.floor(integrity.completeness * 1_000) / 10
99
101
  : 100
100
102
  return {
103
+ receiptVersion: 1,
104
+ serverInstanceId: getServerInstanceId(),
101
105
  saved: true,
102
106
  // Keep the build199 string field without leaking an absolute host path.
103
107
  filepath: `recordings/${saved.month}/${saved.filename}`,
@@ -125,6 +129,33 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
125
129
  const router = Router()
126
130
  const savingSessions = new Set<string>()
127
131
 
132
+ router.get('/meeting/sessions/:sessionId/status', (req, res) => {
133
+ const sessionId = String(req.params.sessionId ?? '')
134
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
135
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
136
+ return
137
+ }
138
+ const serverInstanceId = getServerInstanceId()
139
+ if (!serverInstanceId) {
140
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
141
+ return
142
+ }
143
+ const saved = store.findBySessionId(sessionId)
144
+ const live = getMeetingSessionStatus(sessionId)
145
+ res.set('Cache-Control', 'private, no-store')
146
+ res.json({
147
+ sessionId,
148
+ state: saved ? 'saved' : live.state,
149
+ serverInstanceId,
150
+ receivedRanges: live.receivedRanges,
151
+ receivedCount: live.receivedCount,
152
+ maxChunkIndex: live.maxChunkIndex,
153
+ lastActivityAt: live.lastActivityAt,
154
+ retainedUntil: saved ? null : live.retainedUntil,
155
+ saveReceipt: saved ? publicSaveResponse(saved) : null,
156
+ })
157
+ })
158
+
128
159
  router.post('/meeting/save', async (req, res) => {
129
160
  let lockedSessionId: string | null = null
130
161
  try {
@@ -32,6 +32,13 @@ import {
32
32
  isVocabEchoOnly,
33
33
  } from '../lib/hallucination-filter.js'
34
34
  import { dataPath } from '../lib/data-dir.js'
35
+ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
36
+ import {
37
+ LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
38
+ compressIndexRanges,
39
+ retainedUntilIso,
40
+ type IndexRange,
41
+ } from '../lib/local-first-meetings-contract.js'
35
42
 
36
43
  function ensurePrivateDirectory(path: string): void {
37
44
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -200,6 +207,8 @@ interface TranscriptSession {
200
207
  // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
201
208
  receivedIndices?: number[]
202
209
  maxChunkIndex?: number
210
+ /** Persisted idle-retention clock. Meeting date/duration still use startTime. */
211
+ lastActivityAt: number
203
212
  // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
204
213
  // any real-content chunk. Used to drop a RUN of echoed brand names while keeping
205
214
  // a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
@@ -207,22 +216,60 @@ interface TranscriptSession {
207
216
  }
208
217
 
209
218
  const sessions = new Map<string, TranscriptSession>()
210
- const CLOSED_SESSION_TTL_MS = 4 * 60 * 60 * 1000
219
+ const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
211
220
  const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
212
221
 
222
+ interface ClosedTranscriptSession {
223
+ closedAt: number
224
+ lastActivityAt: number
225
+ receivedIndices: number[]
226
+ maxChunkIndex: number
227
+ reason: 'saved' | 'expired' | 'closed'
228
+ }
229
+
230
+ const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
231
+
213
232
  // Incremental chunk persistence — survive server restarts
214
233
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
215
234
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)
216
235
 
217
- function readClosedSessions(): Record<string, number> {
236
+ function readClosedSessions(): Record<string, ClosedTranscriptSession> {
218
237
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
219
238
  try {
220
239
  const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
221
240
  if (!parsed || typeof parsed !== 'object') return {}
222
- return Object.fromEntries(
223
- Object.entries(parsed as Record<string, unknown>)
224
- .filter(([id, ts]) => /^[A-Za-z0-9:_-]{3,96}$/.test(id) && typeof ts === 'number'),
225
- ) as Record<string, number>
241
+ const normalized: Record<string, ClosedTranscriptSession> = {}
242
+ for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
243
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id)) continue
244
+ if (typeof value === 'number' && Number.isFinite(value)) {
245
+ normalized[id] = {
246
+ closedAt: value,
247
+ lastActivityAt: value,
248
+ receivedIndices: [],
249
+ maxChunkIndex: -1,
250
+ reason: 'closed',
251
+ }
252
+ continue
253
+ }
254
+ if (!value || typeof value !== 'object') continue
255
+ const raw = value as Record<string, unknown>
256
+ const closedAt = typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) ? raw.closedAt : null
257
+ if (closedAt == null) continue
258
+ const lastActivityAt = typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
259
+ ? raw.lastActivityAt
260
+ : closedAt
261
+ const receivedIndices = Array.isArray(raw.receivedIndices)
262
+ ? Array.from(new Set(
263
+ raw.receivedIndices.filter((entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0),
264
+ )).sort((a, b) => a - b)
265
+ : []
266
+ const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
267
+ ? raw.maxChunkIndex
268
+ : (receivedIndices.at(-1) ?? -1)
269
+ const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
270
+ normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
271
+ }
272
+ return normalized
226
273
  } catch {
227
274
  try {
228
275
  renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
@@ -234,25 +281,31 @@ function readClosedSessions(): Record<string, number> {
234
281
  function persistClosedSessions(): void {
235
282
  const now = Date.now()
236
283
  const merged = readClosedSessions()
237
- for (const id of deletedSessions) merged[id] = now
238
- for (const [id, closedAt] of Object.entries(merged)) {
239
- if (now - closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
284
+ for (const id of deletedSessions) {
285
+ merged[id] = closedSessionRecords.get(id) ?? {
286
+ closedAt: now,
287
+ lastActivityAt: now,
288
+ receivedIndices: [],
289
+ maxChunkIndex: -1,
290
+ reason: 'closed',
291
+ }
292
+ }
293
+ for (const [id, record] of Object.entries(merged)) {
294
+ if (now - record.closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
240
295
  }
241
296
  try {
242
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
243
- writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: 'utf-8', mode: 0o600 })
244
- renameSync(tmp, CLOSED_SESSIONS_FILE)
245
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
246
- } catch { /* best-effort tombstones */ }
297
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
298
+ } catch { /* best-effort tombstones; saved receipts remain authoritative */ }
247
299
  }
248
300
 
249
301
  function recoverClosedSessions(): void {
250
302
  const now = Date.now()
251
303
  const closed = readClosedSessions()
252
304
  let dirty = false
253
- for (const [id, closedAt] of Object.entries(closed)) {
254
- if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
255
- deletedSessions.add(id)
305
+ for (const [id, record] of Object.entries(closed)) {
306
+ if (now - record.closedAt <= CLOSED_SESSION_TTL_MS) {
307
+ rememberDeletedSession(id)
308
+ closedSessionRecords.set(id, record)
256
309
  } else {
257
310
  delete closed[id]
258
311
  dirty = true
@@ -260,20 +313,17 @@ function recoverClosedSessions(): void {
260
313
  }
261
314
  if (dirty) {
262
315
  try {
263
- const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
264
- writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
265
- renameSync(tmp, CLOSED_SESSIONS_FILE)
266
- try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
316
+ durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(closed, null, 2), { mode: 0o600 })
267
317
  } catch {}
268
318
  }
269
319
  }
270
320
 
271
- /** Persist a session's chunks to disk (called after each new chunk) */
272
- function persistSession(sessionId: string): void {
273
- try {
274
- const session = sessions.get(sessionId)
275
- if (!session) return
276
- const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
321
+ /** Persist a session before acknowledging any chunk. Throws on failure so a
322
+ * client never interprets a non-durable index as accepted. */
323
+ function persistSessionRequired(sessionId: string): void {
324
+ const session = sessions.get(sessionId)
325
+ if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
326
+ const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
277
327
  // chunksIndexed preserves each chunk's original index (a plain filter()
278
328
  // would collapse the sparse array and destroy gap positions on recovery).
279
329
  const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
@@ -281,9 +331,10 @@ function persistSession(sessionId: string): void {
281
331
  const c = session.chunks[i]
282
332
  if (c && c.text) chunksIndexed.push({ i, c })
283
333
  }
284
- const data = JSON.stringify({
334
+ const data = JSON.stringify({
285
335
  sessionId,
286
336
  startTime: session.startTime,
337
+ lastActivityAt: session.lastActivityAt,
287
338
  title: session.title,
288
339
  // `chunks` = legacy dense form, kept for backward compatibility with
289
340
  // existing readers; `chunksIndexed` preserves original indices so gap
@@ -294,9 +345,16 @@ function persistSession(sessionId: string): void {
294
345
  maxChunkIndex: session.maxChunkIndex ?? -1,
295
346
  providerCandidates: session.providerCandidates ?? {},
296
347
  })
297
- writeFileSync(filePath, data, { encoding: 'utf-8', mode: 0o600 })
298
- try { chmodSync(filePath, 0o600) } catch { /* best effort on recovered installs */ }
299
- } catch { /* non-critical — don't break transcription for persistence */ }
348
+ try {
349
+ durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
350
+ } catch (error) {
351
+ console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
352
+ throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
353
+ }
354
+ }
355
+
356
+ function persistSessionBestEffort(sessionId: string): void {
357
+ try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
300
358
  }
301
359
 
302
360
  /** Recover sessions from disk on server restart */
@@ -315,9 +373,14 @@ function recoverSessions(): void {
315
373
  Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
316
374
  const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
317
375
  const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
318
- if (data.sessionId && hasChunks) {
319
- // Only recover sessions less than 4 hours old
320
- if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
376
+ const persistedStat = statSync(resolve(CHUNK_PERSIST_DIR, file))
377
+ const lastActivityAt = typeof data.lastActivityAt === 'number' && Number.isFinite(data.lastActivityAt)
378
+ ? data.lastActivityAt
379
+ : (Number.isFinite(persistedStat.mtimeMs) ? persistedStat.mtimeMs : data.startTime)
380
+ if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
381
+ // Active retention is idle-based. Long meetings are not purged merely
382
+ // because their original start time is old.
383
+ if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
321
384
  const chunks: TranscriptChunk[] = []
322
385
  if (indexed) {
323
386
  for (const e of indexed) {
@@ -353,6 +416,7 @@ function recoverSessions(): void {
353
416
  chunks,
354
417
  startTime: data.startTime,
355
418
  title: data.title || '',
419
+ lastActivityAt,
356
420
  receivedIndices,
357
421
  maxChunkIndex,
358
422
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
@@ -380,12 +444,32 @@ function recoverSessions(): void {
380
444
  recoveredIds.add(data.sessionId)
381
445
  if (cleaned > 0) {
382
446
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
383
- persistSession(data.sessionId) // re-persist cleaned data to disk
447
+ persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
384
448
  }
385
449
  const gaps = computeGapReport(session).missingIndices.length
386
450
  console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
387
451
  } else {
388
- // Stale clean up
452
+ // A stale unsaved session is a real closed state, not a missing
453
+ // session that a late/zombie client may silently recreate. Keep
454
+ // its exact receive ledger for one tombstone horizon after boot.
455
+ const receivedIndices = Array.isArray(data.receivedIndices)
456
+ ? Array.from(new Set(
457
+ (data.receivedIndices as unknown[])
458
+ .filter((value): value is number => Number.isInteger(value) && (value as number) >= 0),
459
+ )).sort((left, right) => left - right)
460
+ : []
461
+ const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
462
+ ? data.maxChunkIndex
463
+ : (receivedIndices.at(-1) ?? -1)
464
+ closedSessionRecords.set(data.sessionId, {
465
+ closedAt: Date.now(),
466
+ lastActivityAt,
467
+ receivedIndices,
468
+ maxChunkIndex,
469
+ reason: 'expired',
470
+ })
471
+ rememberDeletedSession(data.sessionId)
472
+ persistClosedSessions()
389
473
  unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
390
474
  }
391
475
  }
@@ -410,6 +494,12 @@ function recoverSessions(): void {
410
494
  // Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
411
495
  const deletedSessions = new Set<string>()
412
496
  const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
497
+ function rememberDeletedSession(sessionId: string): void {
498
+ deletedSessions.add(sessionId)
499
+ if (deletedSessions.size <= DELETED_SESSION_CAP) return
500
+ const entries = Array.from(deletedSessions)
501
+ for (const id of entries.slice(0, entries.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
502
+ }
413
503
  export function isSessionDeleted(sessionId: string): boolean {
414
504
  return deletedSessions.has(sessionId)
415
505
  }
@@ -418,19 +508,12 @@ export function isSessionDeleted(sessionId: string): boolean {
418
508
  recoverClosedSessions()
419
509
  recoverSessions()
420
510
 
421
- // Auto-cleanup sessions older than 4 hours
511
+ // Auto-cleanup sessions idle for the advertised retention horizon.
422
512
  setInterval(() => {
423
- const cutoff = Date.now() - 4 * 60 * 60 * 1000
513
+ const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
424
514
  for (const [id, session] of sessions) {
425
- if (session.startTime < cutoff) {
426
- sessions.delete(id)
427
- sessionAudioBytes.delete(id)
428
- sessionAudioWrites.delete(id)
429
- clearSessionHallucinationState(id)
430
- // Clean up persisted file too
431
- try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
432
- // Clean up session audio
433
- try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
515
+ if (session.lastActivityAt < cutoff) {
516
+ closeTranscriptSession(id, 'expired')
434
517
  }
435
518
  }
436
519
  // Purge orphaned session-audio dirs (no matching active session)
@@ -493,7 +576,8 @@ setInterval(() => {
493
576
  export function getSession(sessionId: string): TranscriptSession {
494
577
  let session = sessions.get(sessionId)
495
578
  if (!session) {
496
- session = { chunks: [], startTime: Date.now(), title: '', providerCandidates: {} }
579
+ const now = Date.now()
580
+ session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
497
581
  sessions.set(sessionId, session)
498
582
  }
499
583
  if (!session.providerCandidates) session.providerCandidates = {}
@@ -677,19 +761,81 @@ export function getSessionProviderCandidates(sessionId: string): Record<string,
677
761
  return sessions.get(sessionId)?.providerCandidates ?? {}
678
762
  }
679
763
 
764
+ export interface MeetingSessionStatusSnapshot {
765
+ state: 'active' | 'closed' | 'missing'
766
+ receivedRanges: IndexRange[]
767
+ receivedCount: number
768
+ maxChunkIndex: number
769
+ lastActivityAt: string | null
770
+ retainedUntil: string | null
771
+ }
772
+
773
+ export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
774
+ const active = sessions.get(sessionId)
775
+ if (active) {
776
+ const received = active.receivedIndices ?? []
777
+ return {
778
+ state: 'active',
779
+ receivedRanges: compressIndexRanges(received),
780
+ receivedCount: received.length,
781
+ maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
782
+ lastActivityAt: new Date(active.lastActivityAt).toISOString(),
783
+ retainedUntil: retainedUntilIso(active.lastActivityAt),
784
+ }
785
+ }
786
+ const closed = closedSessionRecords.get(sessionId)
787
+ if (closed) {
788
+ return {
789
+ state: 'closed',
790
+ receivedRanges: compressIndexRanges(closed.receivedIndices),
791
+ receivedCount: closed.receivedIndices.length,
792
+ maxChunkIndex: closed.maxChunkIndex,
793
+ lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
794
+ retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
795
+ }
796
+ }
797
+ return {
798
+ state: 'missing',
799
+ receivedRanges: [],
800
+ receivedCount: 0,
801
+ maxChunkIndex: -1,
802
+ lastActivityAt: null,
803
+ retainedUntil: null,
804
+ }
805
+ }
806
+
807
+ function closeTranscriptSession(
808
+ sessionId: string,
809
+ reason: ClosedTranscriptSession['reason'],
810
+ options: { preserveAudio?: boolean } = {},
811
+ ): void {
812
+ const session = sessions.get(sessionId)
813
+ const now = Date.now()
814
+ const receivedIndices = [...(session?.receivedIndices ?? [])]
815
+ const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
816
+ closedSessionRecords.set(sessionId, {
817
+ closedAt: now,
818
+ lastActivityAt: session?.lastActivityAt ?? now,
819
+ receivedIndices,
820
+ maxChunkIndex,
821
+ reason,
822
+ })
823
+ finishClosingTranscriptSession(sessionId, options)
824
+ }
825
+
680
826
  /** Delete session after save */
681
827
  export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
828
+ closeTranscriptSession(sessionId, 'saved', options)
829
+ }
830
+
831
+ function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
682
832
  sessions.delete(sessionId)
683
833
  sessionAudioBytes.delete(sessionId)
834
+ sessionAudioWrites.delete(sessionId)
684
835
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
685
836
  clearSessionHallucinationState(sessionId)
686
837
  // Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
687
- deletedSessions.add(sessionId)
688
- if (deletedSessions.size > DELETED_SESSION_CAP) {
689
- // Trim oldest entries to prevent unbounded growth
690
- const arr = Array.from(deletedSessions)
691
- for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
692
- }
838
+ rememberDeletedSession(sessionId)
693
839
  persistClosedSessions()
694
840
  // Clean up persisted file
695
841
  try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
@@ -768,14 +914,27 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
768
914
  ensurePrivateDirectory(sessionDir)
769
915
  const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
770
916
  const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
771
- const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
917
+ let currentBytes = sessionAudioBytes.get(sessionId)
918
+ if (currentBytes == null) {
919
+ currentBytes = 0
920
+ try {
921
+ for (const filename of readdirSync(sessionDir)) {
922
+ if (!/^chunk_\d{4}\.wav$/.test(filename)) continue
923
+ currentBytes += statSync(resolve(sessionDir, filename)).size
924
+ }
925
+ } catch (error) {
926
+ throw makeHttpError(503, `meeting audio inventory failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
927
+ }
928
+ }
772
929
  const nextBytes = currentBytes - existingSize + audioBuffer.length
773
- if (nextBytes > MAX_SESSION_AUDIO_BYTES && !existsSync(chunkPath)) {
774
- if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
775
- return
930
+ if (nextBytes > MAX_SESSION_AUDIO_BYTES) {
931
+ throw makeHttpError(507, 'meeting audio capacity exceeded', 'meeting_audio_capacity_exceeded')
932
+ }
933
+ try {
934
+ durableAtomicWriteFileSync(chunkPath, audioBuffer, { mode: 0o600 })
935
+ } catch (error) {
936
+ throw makeHttpError(503, `meeting audio persistence failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
776
937
  }
777
- const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
778
- await trackSessionAudioWrite(sessionId, writeJob)
779
938
  sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
780
939
  }
781
940
 
@@ -995,9 +1154,6 @@ async function processStreamChunk(opts: {
995
1154
  if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
996
1155
  session.startTime = opts.startTimeOverride
997
1156
  }
998
- // Transfer integrity: log this index as delivered before any text filtering,
999
- // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
1000
- recordReceivedChunk(session, chunkIndex)
1001
1157
  const alreadyCanonical = session.chunks[chunkIndex]
1002
1158
 
1003
1159
  let candidateRecordKey: string | undefined
@@ -1020,21 +1176,24 @@ async function processStreamChunk(opts: {
1020
1176
  // Do not let late duplicate/replayed candidates replace canonical raw audio.
1021
1177
  // Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
1022
1178
  if (alreadyCanonical?.canonical) {
1179
+ session.lastActivityAt = Date.now()
1180
+ recordReceivedChunk(session, chunkIndex)
1023
1181
  if (candidate && candidateRecordKey) {
1024
1182
  session.providerCandidates![candidateRecordKey].accepted =
1025
1183
  alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
1026
1184
  session.providerCandidates![candidateRecordKey].fallbackReason =
1027
1185
  session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
1028
- persistSession(sessionId)
1029
1186
  }
1187
+ persistSessionRequired(sessionId)
1030
1188
  return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
1031
1189
  }
1032
1190
 
1033
1191
  await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
1034
-
1035
- if (candidate) {
1036
- persistSession(sessionId)
1037
- }
1192
+ // Commit the received-index ledger only after the canonical raw WAV is
1193
+ // durable. A failure is typed non-2xx and a retry remains safe.
1194
+ session.lastActivityAt = Date.now()
1195
+ recordReceivedChunk(session, chunkIndex)
1196
+ persistSessionRequired(sessionId)
1038
1197
 
1039
1198
  const pcmData = audioBuffer.subarray(44)
1040
1199
  let sumSq = 0
@@ -1105,8 +1264,8 @@ async function processStreamChunk(opts: {
1105
1264
  if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
1106
1265
  session.providerCandidates[candidateRecordKey].accepted = false
1107
1266
  session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
1108
- persistSession(sessionId)
1109
1267
  }
1268
+ persistSessionRequired(sessionId)
1110
1269
  return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
1111
1270
  }
1112
1271
 
@@ -1132,8 +1291,9 @@ async function processStreamChunk(opts: {
1132
1291
  finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
1133
1292
  session.providerCandidates[candidateRecordKey].fallbackReason =
1134
1293
  session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
1135
- persistSession(sessionId)
1136
1294
  }
1295
+ session.lastActivityAt = Date.now()
1296
+ persistSessionRequired(sessionId)
1137
1297
  return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
1138
1298
  }
1139
1299
  session.chunks[chunkIndex] = chunk
@@ -1144,7 +1304,8 @@ async function processStreamChunk(opts: {
1144
1304
  }
1145
1305
  }
1146
1306
  const tPersist = performance.now()
1147
- persistSession(sessionId)
1307
+ session.lastActivityAt = Date.now()
1308
+ persistSessionRequired(sessionId)
1148
1309
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1149
1310
 
1150
1311
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
@@ -1207,7 +1368,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
1207
1368
  : undefined
1208
1369
  if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
1209
1370
  if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
1210
- persistSession(sessionId)
1371
+ session.lastActivityAt = Date.now()
1372
+ persistSessionRequired(sessionId)
1211
1373
  res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
1212
1374
  } catch (err: unknown) {
1213
1375
  sendStreamError(res, err)