@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 +26 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/lib/local-first-meetings-contract.ts +53 -0
- package/server/routes/health.ts +6 -0
- package/server/routes/meeting.ts +31 -0
- package/server/routes/transcribe-stream.ts +234 -72
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
|
@@ -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
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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)
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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,
|
|
254
|
-
if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
} catch {
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
511
|
+
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
422
512
|
setInterval(() => {
|
|
423
|
-
const cutoff = Date.now() -
|
|
513
|
+
const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
424
514
|
for (const [id, session] of sessions) {
|
|
425
|
-
if (session.
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|