@gotcos/glasses-server 6.21.7 → 6.21.9

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.
@@ -0,0 +1,235 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
2
+ import { basename, dirname, join, resolve, sep } from 'node:path'
3
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
4
+ import { dataPath } from './data-dir.js'
5
+
6
+ const JOB_NAME = /^[A-Za-z0-9:_-]{3,96}\.json$/
7
+
8
+ export interface MeetingFinalizationJob {
9
+ schemaVersion: 1
10
+ sessionId: string
11
+ meetingPath: string
12
+ sidecarPath: string
13
+ audioDir: string | null
14
+ streamingWordCount: number
15
+ phase: 'capture_pending' | 'batch_pending' | 'ops_pending'
16
+ claimPending: boolean
17
+ createdAt: string
18
+ updatedAt: string
19
+ lastError?: string
20
+ }
21
+
22
+ function contained(parent: string, child: string): boolean {
23
+ return child === parent || child.startsWith(`${parent}${sep}`)
24
+ }
25
+
26
+ function validJob(raw: unknown): raw is MeetingFinalizationJob {
27
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
28
+ const job = raw as Partial<MeetingFinalizationJob>
29
+ return job.schemaVersion === 1
30
+ && typeof job.sessionId === 'string'
31
+ && /^[A-Za-z0-9:_-]{3,96}$/.test(job.sessionId)
32
+ && typeof job.meetingPath === 'string'
33
+ && typeof job.sidecarPath === 'string'
34
+ && (job.audioDir === null || typeof job.audioDir === 'string')
35
+ && typeof job.streamingWordCount === 'number'
36
+ && Number.isFinite(job.streamingWordCount)
37
+ && (job.phase === 'capture_pending' || job.phase === 'batch_pending' || job.phase === 'ops_pending')
38
+ && (job.claimPending === undefined || typeof job.claimPending === 'boolean')
39
+ && typeof job.createdAt === 'string'
40
+ && typeof job.updatedAt === 'string'
41
+ }
42
+
43
+ /** Durable replay ledger for the post-response HQ + operations handoff.
44
+ * The meeting and sidecar remain the canonical data; this store contains only
45
+ * bounded pointers and phase state so a server restart can resume safely. */
46
+ export class MeetingFinalizationJobStore {
47
+ readonly root: string
48
+
49
+ constructor(root = dataPath('meeting-finalization-jobs')) {
50
+ this.root = resolve(root)
51
+ }
52
+
53
+ private ensureRoot(): void {
54
+ mkdirSync(this.root, { recursive: true, mode: 0o700 })
55
+ }
56
+
57
+ private pathFor(sessionId: string): string {
58
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) throw new Error('Invalid finalization sessionId')
59
+ const path = resolve(this.root, `${sessionId}.json`)
60
+ if (!contained(this.root, path)) throw new Error('Unsafe finalization job path')
61
+ return path
62
+ }
63
+
64
+ private assertSafePointers(input: Pick<MeetingFinalizationJob, 'meetingPath' | 'sidecarPath' | 'audioDir'>): void {
65
+ const dataRoot = dirname(this.root)
66
+ const recordingsRoot = resolve(dataRoot, 'recordings')
67
+ const pendingRoot = resolve(dataRoot, 'pending-batch')
68
+ if (!contained(recordingsRoot, resolve(input.meetingPath))) throw new Error('Unsafe finalization meeting path')
69
+ if (!contained(recordingsRoot, resolve(input.sidecarPath))) throw new Error('Unsafe finalization sidecar path')
70
+ if (input.audioDir && !contained(pendingRoot, resolve(input.audioDir))) {
71
+ throw new Error('Unsafe finalization audio path')
72
+ }
73
+ }
74
+
75
+ save(input: Omit<MeetingFinalizationJob, 'schemaVersion' | 'createdAt' | 'updatedAt'>): MeetingFinalizationJob {
76
+ this.assertSafePointers(input)
77
+ this.ensureRoot()
78
+ const prior = this.get(input.sessionId)
79
+ const now = new Date().toISOString()
80
+ const job: MeetingFinalizationJob = {
81
+ schemaVersion: 1,
82
+ ...input,
83
+ createdAt: prior?.createdAt ?? now,
84
+ updatedAt: now,
85
+ }
86
+ durableAtomicWriteFileSync(this.pathFor(input.sessionId), `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 })
87
+ return job
88
+ }
89
+
90
+ get(sessionId: string): MeetingFinalizationJob | null {
91
+ const path = this.pathFor(sessionId)
92
+ if (!existsSync(path)) return null
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
95
+ if (!validJob(parsed)) return null
96
+ const normalized = { ...parsed, claimPending: parsed.claimPending === true }
97
+ this.assertSafePointers(normalized)
98
+ return normalized
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ list(): MeetingFinalizationJob[] {
105
+ if (!existsSync(this.root)) return []
106
+ let names: string[] = []
107
+ try { names = readdirSync(this.root).filter(name => JOB_NAME.test(name)) } catch { return [] }
108
+ return names.flatMap(name => {
109
+ const sessionId = basename(name, '.json')
110
+ const job = this.get(sessionId)
111
+ return job ? [job] : []
112
+ })
113
+ }
114
+
115
+ malformedCount(): number {
116
+ if (!existsSync(this.root)) return 0
117
+ try {
118
+ return readdirSync(this.root)
119
+ .filter(name => JOB_NAME.test(name))
120
+ .filter(name => this.get(basename(name, '.json')) === null)
121
+ .length
122
+ } catch {
123
+ return 0
124
+ }
125
+ }
126
+
127
+ remove(sessionId: string): void {
128
+ try { unlinkSync(this.pathFor(sessionId)) } catch { /* already absent */ }
129
+ }
130
+
131
+ /** Find a previously moved audio directory without trusting a stored path. */
132
+ findPendingAudioDir(sessionId: string): string | null {
133
+ const pendingRoot = resolve(dirname(this.root), 'pending-batch')
134
+ if (!existsSync(pendingRoot)) return null
135
+ try {
136
+ const candidates = readdirSync(pendingRoot)
137
+ .filter(name => name === sessionId || name.startsWith(`${sessionId}_`))
138
+ .map(name => resolve(pendingRoot, name))
139
+ .filter(path => contained(pendingRoot, path) && statSync(path).isDirectory())
140
+ .sort()
141
+ return candidates[0] ?? null
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ /** Rebuild missing replay jobs from the canonical sidecar intent marker. */
148
+ reconcileCanonicalSidecars(): MeetingFinalizationJob[] {
149
+ const recordingsRoot = resolve(dirname(this.root), 'recordings')
150
+ if (!existsSync(recordingsRoot)) return []
151
+ const rebuilt: MeetingFinalizationJob[] = []
152
+ let months: string[] = []
153
+ try { months = readdirSync(recordingsRoot).filter(name => /^\d{4}-\d{2}$/.test(name)) } catch { return [] }
154
+ for (const month of months) {
155
+ const monthDir = resolve(recordingsRoot, month)
156
+ let names: string[] = []
157
+ try { names = readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')) } catch { continue }
158
+ for (const name of names) {
159
+ const sidecarPath = resolve(monthDir, name)
160
+ try {
161
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
162
+ const sessionId = typeof sidecar.sessionId === 'string' ? sidecar.sessionId : ''
163
+ if (sidecar.finalizationState === 'complete' || !sidecar.finalizationState || this.get(sessionId)) continue
164
+ const meetingPath = sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
165
+ if (!existsSync(meetingPath)) continue
166
+ const audioDir = this.findPendingAudioDir(sessionId)
167
+ rebuilt.push(this.save({
168
+ sessionId,
169
+ meetingPath,
170
+ sidecarPath,
171
+ audioDir,
172
+ streamingWordCount: Number(sidecar.streamingWordCount ?? 0),
173
+ phase: audioDir ? 'batch_pending' : 'capture_pending',
174
+ claimPending: sidecar.claimPending === true,
175
+ }))
176
+ } catch { /* malformed canonical sidecars are not executable */ }
177
+ }
178
+ }
179
+ return rebuilt
180
+ }
181
+ }
182
+
183
+ export function readFinalizationChunkEntries(job: MeetingFinalizationJob): unknown[] | null {
184
+ if (!existsSync(job.meetingPath) || !existsSync(job.sidecarPath)) return null
185
+ try {
186
+ const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
187
+ if (sidecar.sessionId !== job.sessionId || !Array.isArray(sidecar.chunkEntries)) return null
188
+ return sidecar.chunkEntries
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ export function canonicalFinalizationIsComplete(job: MeetingFinalizationJob): boolean {
195
+ try {
196
+ const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
197
+ return sidecar.sessionId === job.sessionId && sidecar.finalizationState === 'complete'
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+
203
+ export function markCanonicalFinalizationState(
204
+ sidecarPath: string,
205
+ state: MeetingFinalizationJob['phase'] | 'complete',
206
+ claimPending: boolean,
207
+ ): void {
208
+ if (!existsSync(sidecarPath)) return
209
+ const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
210
+ parsed.finalizationState = state
211
+ parsed.claimPending = claimPending
212
+ parsed.finalizationUpdatedAt = new Date().toISOString()
213
+ if (state === 'complete') delete parsed.finalizationError
214
+ durableAtomicWriteFileSync(sidecarPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 })
215
+ }
216
+
217
+ export function getMeetingFinalizationSnapshot(): {
218
+ pending: number
219
+ failed: number
220
+ oldestUpdatedAt: string | null
221
+ lastError: string | null
222
+ malformed: number
223
+ } {
224
+ const jobs = new MeetingFinalizationJobStore().list()
225
+ const failed = jobs.filter(job => Boolean(job.lastError))
226
+ const oldest = jobs.map(job => job.updatedAt).sort()[0] ?? null
227
+ const mostRecentFailure = failed.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
228
+ return {
229
+ pending: jobs.length,
230
+ failed: failed.length,
231
+ oldestUpdatedAt: oldest,
232
+ lastError: mostRecentFailure?.lastError?.slice(0, 200) ?? null,
233
+ malformed: new MeetingFinalizationJobStore().malformedCount(),
234
+ }
235
+ }
@@ -89,6 +89,10 @@ export interface SaveMeetingInput {
89
89
  chunkEntries?: IndexedTranscriptChunk[]
90
90
  providerCandidates?: Record<string, ProviderCandidateRecord>
91
91
  transferIntegrity?: TranscriptGapReport | null
92
+ /** Durable intent used to reconstruct post-save work if the process exits
93
+ * between the canonical commit and creation of the replay job. */
94
+ finalizationRequired?: boolean
95
+ claimPending?: boolean
92
96
  }
93
97
 
94
98
  export interface SavedMeeting {
@@ -444,6 +448,11 @@ export class MeetingStore {
444
448
  transcriptionQuality: 'streaming',
445
449
  batchApplied: false,
446
450
  streamingWordCount: wordCount(transcript),
451
+ ...(input.finalizationRequired ? {
452
+ finalizationState: 'capture_pending',
453
+ claimPending: input.claimPending === true,
454
+ finalizationUpdatedAt: new Date().toISOString(),
455
+ } : {}),
447
456
  }
448
457
 
449
458
  // Sidecar first, markdown second: the markdown is the visible commit marker.
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { spawn, execFile } from 'node:child_process'
10
10
  import type { ChildProcess } from 'node:child_process'
11
- import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs'
11
+ import { writeFileSync, unlinkSync, existsSync, readFileSync, statSync } from 'node:fs'
12
12
  import { basename, join } from 'node:path'
13
13
  import { homedir } from 'node:os'
14
14
  import crypto from 'node:crypto'
@@ -74,6 +74,37 @@ export interface HighQualityTranscriptionResult {
74
74
  degradationReason?: HighQualityUnavailableReason
75
75
  }
76
76
 
77
+ let highQualityCheckpointFingerprint: string | null = null
78
+
79
+ /** Stable, path-free cache identity for progressive meeting HQ checkpoints.
80
+ * Model/configuration is immutable for the lifetime of one server process, so
81
+ * cache this instead of stat'ing multi-GB model files on every health poll. */
82
+ export function getHighQualityCheckpointFingerprint(): string {
83
+ if (highQualityCheckpointFingerprint) return highQualityCheckpointFingerprint
84
+ const identity = (path: string): Record<string, number | boolean> => {
85
+ try {
86
+ const stat = statSync(path)
87
+ return { present: true, size: stat.size, mtimeMs: Math.floor(stat.mtimeMs) }
88
+ } catch {
89
+ return { present: false, size: 0, mtimeMs: 0 }
90
+ }
91
+ }
92
+ highQualityCheckpointFingerprint = crypto.createHash('sha256').update(JSON.stringify({
93
+ schema: 2,
94
+ serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
95
+ whisperCli: identity(WHISPER_CLI),
96
+ model: identity(resolveBatchModel()),
97
+ vad: identity(VAD_MODEL_PATH),
98
+ beam: 5,
99
+ bestOf: 5,
100
+ vadEnabled: hqCliVadEnabled('batch'),
101
+ enhancement: 'audio-enhance-v1',
102
+ prompt: buildWhisperPrompt(),
103
+ corrections: getWhisperCorrections(),
104
+ })).digest('hex')
105
+ return highQualityCheckpointFingerprint
106
+ }
107
+
77
108
  export function classifyHighQualityTranscriptionCapability(input: {
78
109
  enabled: boolean
79
110
  cliPresent: boolean
@@ -806,7 +837,14 @@ export async function transcribeHighQuality(
806
837
  /** forceCpu: the batch pipeline's one CPU retry after a Metal preempt. It
807
838
  * bypasses the gate entirely so the retry cannot itself be preempted into
808
839
  * an infinite loop. */
809
- opts: { priority?: 'interactive' | 'batch'; forceCpu?: boolean } = {},
840
+ opts: {
841
+ priority?: 'interactive' | 'batch'
842
+ forceCpu?: boolean
843
+ forceCpuReason?: string
844
+ threads?: number
845
+ backgroundCpu?: boolean
846
+ signal?: AbortSignal
847
+ } = {},
810
848
  ): Promise<HighQualityTranscriptionResult> {
811
849
  if (!cliAvailable) {
812
850
  // Fall back to server (no beam search available via HTTP API)
@@ -844,7 +882,7 @@ export async function transcribeHighQuality(
844
882
  const useVad = hqCliVadEnabled(opts.priority)
845
883
  const decision: { device: 'metal' | 'cpu'; reason: string; metalEnabled: boolean } = isBatch
846
884
  ? (opts.forceCpu
847
- ? { device: 'cpu', reason: 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
885
+ ? { device: 'cpu', reason: opts.forceCpuReason || 'preempt_retry', metalEnabled: batchHqMetalEnabled() }
848
886
  : chooseBatchDevice())
849
887
  : { device: 'metal', reason: 'interactive', metalEnabled: batchHqMetalEnabled() }
850
888
  const useMetal = decision.device === 'metal'
@@ -858,11 +896,14 @@ export async function transcribeHighQuality(
858
896
  : 2
859
897
  const beam = isBatch ? 5 : interactiveBeam
860
898
  const bestOf = beam
899
+ const requestedThreads = Number.isFinite(opts.threads)
900
+ ? Math.max(1, Math.min(16, Math.floor(opts.threads!)))
901
+ : 8
861
902
  const args = [
862
903
  '-m', modelPath,
863
904
  '-f', tmpWav,
864
905
  // CPU batch stays at 8 threads so it cannot starve live work of cores.
865
- '-t', (isBatch && !useMetal) ? '8' : '16',
906
+ '-t', (isBatch && !useMetal) ? String(requestedThreads) : '16',
866
907
  '-l', 'en',
867
908
  ...(useMetal ? ['-fa'] : ['-ng']),
868
909
  '-bs', String(beam),
@@ -879,9 +920,18 @@ export async function transcribeHighQuality(
879
920
  // omits this — VAD was measured dropping real leading speech on compose.
880
921
  args.push('--vad', '--vad-model', VAD_MODEL_PATH)
881
922
  }
882
- const proc = spawn(WHISPER_CLI, args, {
923
+ const useBackgroundTaskPolicy = Boolean(
924
+ isBatch && !useMetal && opts.backgroundCpu
925
+ && process.platform === 'darwin'
926
+ && existsSync('/usr/sbin/taskpolicy'),
927
+ )
928
+ const proc = spawn(
929
+ useBackgroundTaskPolicy ? '/usr/sbin/taskpolicy' : WHISPER_CLI,
930
+ useBackgroundTaskPolicy ? ['-b', WHISPER_CLI, ...args] : args,
931
+ {
883
932
  stdio: ['ignore', 'pipe', 'pipe'],
884
- })
933
+ },
934
+ )
885
935
  ownedHqChildren.add(proc)
886
936
 
887
937
  // BLOCKER contract: a preempted Metal child is a HARD FAIL. Its stdout
@@ -896,6 +946,17 @@ export async function transcribeHighQuality(
896
946
 
897
947
  let stdout = ''
898
948
  let stderr = ''
949
+ let aborted = false
950
+ let abortForceKill: ReturnType<typeof setTimeout> | null = null
951
+ const onAbort = (): void => {
952
+ aborted = true
953
+ try { proc.kill('SIGTERM') } catch { /* already exited */ }
954
+ abortForceKill = setTimeout(() => {
955
+ try { proc.kill('SIGKILL') } catch { /* already exited */ }
956
+ }, 2_000)
957
+ }
958
+ if (opts.signal?.aborted) onAbort()
959
+ else opts.signal?.addEventListener('abort', onAbort, { once: true })
899
960
  proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
900
961
  proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
901
962
 
@@ -915,9 +976,11 @@ export async function transcribeHighQuality(
915
976
  }, timeoutMs)
916
977
 
917
978
  proc.on('close', (code) => {
979
+ opts.signal?.removeEventListener('abort', onAbort)
918
980
  ownedHqChildren.delete(proc)
919
981
  unregisterMetalBatchChild(proc)
920
982
  clearTimeout(timeout)
983
+ if (abortForceKill) clearTimeout(abortForceKill)
921
984
  if (forceKill) clearTimeout(forceKill)
922
985
  // Preempt is checked FIRST and ignores the exit code: SIGTERM often
923
986
  // yields a non-zero code, but a race could also let the child exit 0
@@ -926,6 +989,12 @@ export async function transcribeHighQuality(
926
989
  reject(new MetalBatchPreemptedError(preemptedReason))
927
990
  return
928
991
  }
992
+ if (aborted) {
993
+ const error = new Error('Progressive HQ checkpoint aborted')
994
+ error.name = 'AbortError'
995
+ reject(error)
996
+ return
997
+ }
929
998
  if (timedOut) {
930
999
  reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
931
1000
  return
@@ -938,9 +1007,11 @@ export async function transcribeHighQuality(
938
1007
  })
939
1008
 
940
1009
  proc.on('error', (err) => {
1010
+ opts.signal?.removeEventListener('abort', onAbort)
941
1011
  ownedHqChildren.delete(proc)
942
1012
  unregisterMetalBatchChild(proc)
943
1013
  clearTimeout(timeout)
1014
+ if (abortForceKill) clearTimeout(abortForceKill)
944
1015
  if (forceKill) clearTimeout(forceKill)
945
1016
  reject(new Error(`whisper-cli HQ spawn error: ${err.message}`))
946
1017
  })
@@ -40,6 +40,9 @@ import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
40
40
  import { getWhisperPreviewCapability } from '../lib/whisper-preview.js'
41
41
  import { getTranscriptionProfileStatus } from '../lib/profile.js'
42
42
  import { getHealthStaticProbes } from '../lib/health-static-probes.js'
43
+ import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
44
+ import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
45
+ import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
43
46
 
44
47
  export const healthRouter = Router()
45
48
 
@@ -190,6 +193,7 @@ healthRouter.get('/health', async (_req, res) => {
190
193
  const cursorSnapshot = getCursorModelCatalogSnapshot()
191
194
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
192
195
  const meeting_sync = getMeetingSyncSnapshot()
196
+ const progressiveHq = getProgressiveHqSnapshot()
193
197
  // Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
194
198
  // surface — same exposure level as meeting_sync's meetingIds. Full detail
195
199
  // plus the recover action live on the authenticated /api/meeting/orphans.
@@ -235,6 +239,11 @@ healthRouter.get('/health', async (_req, res) => {
235
239
  },
236
240
  cliDebug: CLI_DEBUG_CAPABILITY,
237
241
  liveCues,
242
+ meetingLifecycle: {
243
+ earlySyncClaim: getEarlyMeetingSyncSnapshot(),
244
+ progressiveHq,
245
+ finalization: getMeetingFinalizationSnapshot(),
246
+ },
238
247
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
239
248
  },
240
249
  // /api/health is intentionally unauthenticated for setup diagnostics.
@@ -261,6 +270,7 @@ healthRouter.get('/models', async (req, res) => {
261
270
  const transcriptionHq = getHighQualityTranscriptionCapability()
262
271
  const transcriptionLive = getWhisperPreviewCapability()
263
272
  const transcriptionProfile = getTranscriptionProfileStatus()
273
+ const progressiveHq = getProgressiveHqSnapshot()
264
274
  const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
265
275
  res.json({
266
276
  ...catalog,
@@ -282,6 +292,11 @@ healthRouter.get('/models', async (req, res) => {
282
292
  hq: transcriptionHq,
283
293
  profile: transcriptionProfile,
284
294
  },
295
+ meetingLifecycle: {
296
+ earlySyncClaim: getEarlyMeetingSyncSnapshot(),
297
+ progressiveHq,
298
+ finalization: getMeetingFinalizationSnapshot(),
299
+ },
285
300
  cliDebug: CLI_DEBUG_CAPABILITY,
286
301
  recovery: managedRuntimeCapability(),
287
302
  // Same helper as /api/health — the companion's 15s liveness poll reads