@gotcos/glasses-server 6.71.12 → 6.71.14
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/.cos-profile.example.json +1 -0
- package/.env.example +10 -0
- package/CHANGELOG.md +26 -0
- package/README.md +21 -2
- package/package.json +3 -1
- package/server/index.ts +2 -0
- package/server/lib/meeting-ask-review.ts +3 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +44 -3
- package/server/lib/meeting-ask-store.ts +25 -1
- package/server/lib/meeting-ask-web.ts +51 -12
- package/server/lib/meeting-fact-check.ts +810 -0
- package/server/lib/meeting-public-topic.ts +2 -2
- package/server/lib/meeting-web-page.ts +25 -8
- package/server/lib/profile.ts +13 -0
- package/server/lib/stale-active-session-files.ts +178 -0
- package/server/lib/unsaved-audio-quarantine.ts +114 -0
- package/server/routes/health.ts +3 -3
- package/server/routes/home-area.ts +37 -0
- package/server/routes/meeting.ts +75 -1
- package/server/routes/transcribe-stream.ts +145 -2
- package/shared/meeting-ask.ts +15 -0
|
@@ -77,8 +77,16 @@ import {
|
|
|
77
77
|
listUnsavedCaptures,
|
|
78
78
|
purgeExpiredQuarantine,
|
|
79
79
|
quarantineSessionAudio,
|
|
80
|
+
readCaptureDisposition,
|
|
80
81
|
sweepOrphanedSessionAudio,
|
|
81
82
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
83
|
+
import {
|
|
84
|
+
STALE_ACTIVE_SESSION_DIR_NAME,
|
|
85
|
+
moveActiveSessionFileAside,
|
|
86
|
+
purgeExpiredStaleActiveSessionFiles,
|
|
87
|
+
sweepStaleActiveSessionFiles,
|
|
88
|
+
type StaleActiveSessionMove,
|
|
89
|
+
} from '../lib/stale-active-session-files.js'
|
|
82
90
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
83
91
|
import { saveTrainingAudioSample } from '../lib/training-audio-save.js'
|
|
84
92
|
import {
|
|
@@ -92,6 +100,7 @@ import {
|
|
|
92
100
|
acquireMaintenanceWork,
|
|
93
101
|
maintenanceAdmissionsOpen,
|
|
94
102
|
maintenanceLifecycle,
|
|
103
|
+
onMaintenanceAdmissionsOpen,
|
|
95
104
|
type MaintenanceWorkLease,
|
|
96
105
|
} from '../lib/maintenance-lifecycle.js'
|
|
97
106
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
@@ -542,6 +551,8 @@ export function getTranscriptionSessionLiveness(now = Date.now()): Transcription
|
|
|
542
551
|
// Incremental chunk persistence — survive server restarts
|
|
543
552
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
544
553
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
554
|
+
/** 6.71.14: where an active-session file the server no longer owns is moved, never deleted in place. */
|
|
555
|
+
const STALE_ACTIVE_SESSION_DIR = dataPath(STALE_ACTIVE_SESSION_DIR_NAME)
|
|
545
556
|
|
|
546
557
|
function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
547
558
|
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
@@ -691,6 +702,71 @@ function persistSessionBestEffort(sessionId: string): void {
|
|
|
691
702
|
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
692
703
|
}
|
|
693
704
|
|
|
705
|
+
/**
|
|
706
|
+
* 6.71.14: one pass of the stale active-session sweep, against the real session
|
|
707
|
+
* map. Exported so a test drives the exact wiring the minute interval runs.
|
|
708
|
+
*/
|
|
709
|
+
export function sweepStaleActiveSessionFilesNow(now: number): StaleActiveSessionMove[] {
|
|
710
|
+
const moves = sweepStaleActiveSessionFiles({
|
|
711
|
+
dir: CHUNK_PERSIST_DIR,
|
|
712
|
+
targetDir: STALE_ACTIVE_SESSION_DIR,
|
|
713
|
+
isLive: id => sessions.has(id),
|
|
714
|
+
now,
|
|
715
|
+
idleRetentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
716
|
+
})
|
|
717
|
+
for (const move of moves) {
|
|
718
|
+
if (move.target) {
|
|
719
|
+
staleMoveFailuresLogged.delete(move.sessionId)
|
|
720
|
+
console.warn(
|
|
721
|
+
`[cleanup] Moved stale active-session file aside: ${move.sessionId} `
|
|
722
|
+
+ `(${move.reason}, idle ${Math.round((move.idleMs ?? 0) / 60_000)}m, not live) \u2192 ${move.target}`,
|
|
723
|
+
)
|
|
724
|
+
} else {
|
|
725
|
+
logStaleMoveFailure(move.sessionId, `[cleanup] Could not move stale active-session file aside for ${move.sessionId} (${move.reason}); it stays in active-sessions`)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const purged = purgeExpiredStaleActiveSessionFiles(STALE_ACTIVE_SESSION_DIR, now)
|
|
729
|
+
if (purged > 0) console.log(`[cleanup] Purged ${purged} stale active-session file(s) past retention`)
|
|
730
|
+
return moves
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** A failed move repeats every minute; say so once per session id, not every tick. */
|
|
734
|
+
const staleMoveFailuresLogged = new Set<string>()
|
|
735
|
+
function logStaleMoveFailure(sessionId: string, message: string): void {
|
|
736
|
+
if (staleMoveFailuresLogged.has(sessionId)) return
|
|
737
|
+
staleMoveFailuresLogged.add(sessionId)
|
|
738
|
+
if (staleMoveFailuresLogged.size > 500) staleMoveFailuresLogged.delete(staleMoveFailuresLogged.values().next().value as string)
|
|
739
|
+
console.error(message)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* 6.71.14: is this active-session file the leftover of a capture that is already
|
|
744
|
+
* decoded (recovered or receipted no_speech), rather than a session that RESUMED
|
|
745
|
+
* under the same id after that capture was quarantined?
|
|
746
|
+
*
|
|
747
|
+
* A late chunk is accepted (the phone deletes its copy on a 410), so a session can
|
|
748
|
+
* restart under an id whose older quarantine dir is already receipted. Its file is
|
|
749
|
+
* then newer than the quarantine and its own audio sits in session-audio/<id>.
|
|
750
|
+
* Only a file that is no newer than the quarantine AND has no chunk audio of its
|
|
751
|
+
* own is the leftover; anything else is adopted as 6.71.13 would, so a meeting is
|
|
752
|
+
* never split across a restart.
|
|
753
|
+
*/
|
|
754
|
+
function isTerminalCaptureFile(sessionId: string, recordedLastActivityAt: unknown, filePath: string): boolean {
|
|
755
|
+
const disposition = readCaptureDisposition(sessionId)
|
|
756
|
+
if (!disposition || (disposition.kind !== 'no_speech' && disposition.kind !== 'recovered')) return false
|
|
757
|
+
const terminalAtIso = disposition.quarantinedAt ?? disposition.receiptedAt
|
|
758
|
+
const terminalAt = terminalAtIso ? Date.parse(terminalAtIso) : NaN
|
|
759
|
+
if (!Number.isFinite(terminalAt)) return false
|
|
760
|
+
let fileActivity = typeof recordedLastActivityAt === 'number' && Number.isFinite(recordedLastActivityAt)
|
|
761
|
+
? recordedLastActivityAt
|
|
762
|
+
: NaN
|
|
763
|
+
if (!Number.isFinite(fileActivity)) {
|
|
764
|
+
try { fileActivity = statSync(filePath).mtimeMs } catch { return false }
|
|
765
|
+
}
|
|
766
|
+
if (terminalAt < fileActivity) return false
|
|
767
|
+
return countChunkWavs(resolve(SESSION_AUDIO_DIR, sessionId)) === 0
|
|
768
|
+
}
|
|
769
|
+
|
|
694
770
|
/** Recover sessions from disk on server restart */
|
|
695
771
|
function recoverSessions(): void {
|
|
696
772
|
try {
|
|
@@ -701,6 +777,27 @@ function recoverSessions(): void {
|
|
|
701
777
|
if (!file.endsWith('.json')) continue
|
|
702
778
|
try {
|
|
703
779
|
const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
|
|
780
|
+
// 6.71.14: recovery can now run after boot (deferred behind a closed
|
|
781
|
+
// maintenance gate). A session already in memory owns its file; the disk
|
|
782
|
+
// copy must never overwrite it.
|
|
783
|
+
if (typeof data.sessionId === 'string' && sessions.has(data.sessionId)) continue
|
|
784
|
+
// A capture whose quarantined audio was already decoded to a terminal
|
|
785
|
+
// outcome (a saved recovery or a no_speech receipt) is done. Adopting its
|
|
786
|
+
// file would bring back a live session for it; tombstoning would delete the
|
|
787
|
+
// file. Move it aside instead. Plain `quarantined` (never decoded) is NOT
|
|
788
|
+
// done: a crash between a chunk's first WAV write and its first
|
|
789
|
+
// active-session persist quarantines the audio, and the phone's retry then
|
|
790
|
+
// starts the session again. That file is adopted exactly as before.
|
|
791
|
+
if (typeof data.sessionId === 'string' && isTerminalCaptureFile(data.sessionId, data.lastActivityAt, resolve(CHUNK_PERSIST_DIR, file))) {
|
|
792
|
+
const terminal = readCaptureDisposition(data.sessionId)!
|
|
793
|
+
const target = moveActiveSessionFileAside(
|
|
794
|
+
resolve(CHUNK_PERSIST_DIR, file), data.sessionId, STALE_ACTIVE_SESSION_DIR, 'terminal_capture', Date.now(),
|
|
795
|
+
{ lastActivityAt: typeof data.lastActivityAt === 'number' ? data.lastActivityAt : null },
|
|
796
|
+
)
|
|
797
|
+
if (target) console.warn(`[session-recovery] ${data.sessionId} was already ${terminal.kind}; moved its active-session file aside: ${target}`)
|
|
798
|
+
else logStaleMoveFailure(data.sessionId, `[session-recovery] Could not move the active-session file aside for ${data.sessionId} (already ${terminal.kind}); left in place, not adopted`)
|
|
799
|
+
continue
|
|
800
|
+
}
|
|
704
801
|
// Reconstruct the (sparse) chunk array. New format keeps original
|
|
705
802
|
// indices via chunksIndexed; legacy format stored a dense `chunks`.
|
|
706
803
|
const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
|
|
@@ -859,7 +956,9 @@ function recoverSessions(): void {
|
|
|
859
956
|
try {
|
|
860
957
|
if (existsSync(SESSION_AUDIO_DIR)) {
|
|
861
958
|
const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
|
|
862
|
-
|
|
959
|
+
// `|| sessions.has`: deferred recovery (6.71.14) can run while sessions
|
|
960
|
+
// are live in memory; their audio is not orphaned.
|
|
961
|
+
isLive: id => recoveredIds.has(id) || sessions.has(id),
|
|
863
962
|
hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
|
|
864
963
|
reason: 'boot_sweep_unsaved',
|
|
865
964
|
})
|
|
@@ -896,9 +995,40 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
896
995
|
// A committed cross-boot maintenance operation owns durable state until the
|
|
897
996
|
// trusted controller adopts the candidate. Boot recovery must not mutate or
|
|
898
997
|
// promote sessions while that gate is closed.
|
|
899
|
-
|
|
998
|
+
//
|
|
999
|
+
// 6.71.14: and once that gate opens, recovery MUST still run. Until 6.71.13 this was
|
|
1000
|
+
// a module-scope `if` evaluated once at import: a boot behind a closed gate (every
|
|
1001
|
+
// managed update) skipped it for the life of the process. Persisted sessions were
|
|
1002
|
+
// never adopted or tombstoned, and the closed-session tombstones were never
|
|
1003
|
+
// restored. On 2026-10-12 that left `active-sessions/<id>.json` for a receipted
|
|
1004
|
+
// capture on disk for 17 hours, reported as live by COS Control and `missing` by
|
|
1005
|
+
// the status route.
|
|
1006
|
+
let durableSessionRecoveryRan = false
|
|
1007
|
+
function runDurableSessionRecovery(trigger: 'boot' | 'admissions_open'): void {
|
|
1008
|
+
if (durableSessionRecoveryRan) return
|
|
1009
|
+
durableSessionRecoveryRan = true
|
|
900
1010
|
recoverClosedSessions()
|
|
901
1011
|
recoverSessions()
|
|
1012
|
+
if (trigger === 'admissions_open') {
|
|
1013
|
+
console.log(`[session-recovery] Ran deferred durable recovery after the maintenance gate opened (${sessions.size} live session(s))`)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (maintenanceAdmissionsOpen()) {
|
|
1017
|
+
runDurableSessionRecovery('boot')
|
|
1018
|
+
} else {
|
|
1019
|
+
// Delivered on every later open; the flag above makes every delivery after the
|
|
1020
|
+
// first a no-op, so disk is never re-read over a live session map.
|
|
1021
|
+
onMaintenanceAdmissionsOpen(() => runDurableSessionRecovery('admissions_open'))
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* 6.71.14: has durable session recovery run in this process yet? Until it has, the
|
|
1026
|
+
* in-memory map does not yet hold the persisted sessions, so "not in memory" is not
|
|
1027
|
+
* evidence of anything. The status route answers 503 `recovery_pending` instead of
|
|
1028
|
+
* `missing` while this is false.
|
|
1029
|
+
*/
|
|
1030
|
+
export function durableSessionRecoveryHasRun(): boolean {
|
|
1031
|
+
return durableSessionRecoveryRan
|
|
902
1032
|
}
|
|
903
1033
|
|
|
904
1034
|
/**
|
|
@@ -1011,6 +1141,10 @@ function promoteStrandedCapture(sessionId: string, lastActivityAt: number): void
|
|
|
1011
1141
|
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
1012
1142
|
setInterval(() => {
|
|
1013
1143
|
if (!maintenanceAdmissionsOpen()) return
|
|
1144
|
+
// 6.71.14: the sweeps below judge a session dead by its absence from the
|
|
1145
|
+
// in-memory map. Before recovery has adopted the persisted sessions that map is
|
|
1146
|
+
// empty by construction, so sweeping then would quarantine a live capture.
|
|
1147
|
+
if (!durableSessionRecoveryRan) return
|
|
1014
1148
|
// A capture whose phone went away must still become a meeting. This loop used
|
|
1015
1149
|
// to close every idle session as 'expired', which quarantined its audio and
|
|
1016
1150
|
// produced NO meeting — recoverable for 72h, but only by whoever thought to
|
|
@@ -1053,6 +1187,15 @@ setInterval(() => {
|
|
|
1053
1187
|
// log, and nothing to reason about, because any throw in this block vanished.
|
|
1054
1188
|
console.error(`[cleanup] Orphan-audio sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1055
1189
|
}
|
|
1190
|
+
// 6.71.14: its own try, for the same reason as auto-recovery below. An
|
|
1191
|
+
// active-session file whose session is not live and has been idle past the
|
|
1192
|
+
// retention horizon is moved aside; COS Control reads that directory directly
|
|
1193
|
+
// and would otherwise show it as a live recording indefinitely.
|
|
1194
|
+
try {
|
|
1195
|
+
sweepStaleActiveSessionFilesNow(Date.now())
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
console.error(`[cleanup] Stale active-session sweep failed: ${error instanceof Error ? error.message : error}`)
|
|
1198
|
+
}
|
|
1056
1199
|
// DELIBERATELY ITS OWN TRY. This used to sit inside the block above, one line after
|
|
1057
1200
|
// purgeExpiredQuarantine, so a throw anywhere in that sweep meant auto-recovery
|
|
1058
1201
|
// silently never ran — which is exactly what happened in production on 6.23.1
|
package/shared/meeting-ask.ts
CHANGED
|
@@ -170,6 +170,19 @@ export interface MeetingAskExchange {
|
|
|
170
170
|
files: MeetingAskFile[];
|
|
171
171
|
error?: string;
|
|
172
172
|
retryOf?: string;
|
|
173
|
+
/** Server 6.71.13 (capabilities.factCheckScope): what a completed Fact check covered. Named-claim checks and follow-ups carry none. */
|
|
174
|
+
factCheckRange?: MeetingAskFactCheckRange;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* What one Fact check covered, in meeting-elapsed ms (the clock of throughMs and turn.elapsedMs). The first
|
|
178
|
+
* check covers the whole meeting (full); each later one covers only what is new since the last completed
|
|
179
|
+
* check's throughMs. A check that found nothing new still completes with claims 0, so the next one moves on.
|
|
180
|
+
*/
|
|
181
|
+
export interface MeetingAskFactCheckRange {
|
|
182
|
+
fromMs: number;
|
|
183
|
+
throughMs: number;
|
|
184
|
+
full: boolean;
|
|
185
|
+
claims: number;
|
|
173
186
|
}
|
|
174
187
|
export interface MeetingAskCapabilities {
|
|
175
188
|
protocolVersion: 2;
|
|
@@ -179,6 +192,8 @@ export interface MeetingAskCapabilities {
|
|
|
179
192
|
publicTopics?: true;
|
|
180
193
|
/** Explicit recent-claim Fact check CTA; transport remains action=ask. */
|
|
181
194
|
factCheck?: true;
|
|
195
|
+
/** 6.71.13: the server checks the whole meeting first, then only what is new, and says which in factCheckRange. */
|
|
196
|
+
factCheckScope?: 1;
|
|
182
197
|
/** 6.71.1: POST /api/meeting-ask/files/media turns uploaded media into meeting files. */
|
|
183
198
|
phoneFiles?: true;
|
|
184
199
|
/** 6.71.1: config carries cosSearch and PUT config {cosSearch} changes it. */
|