@gotcos/glasses-server 6.16.9 → 6.18.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/.env.example +21 -0
- package/CHANGELOG.md +74 -0
- package/README.md +49 -0
- package/managed-runtime-contract.json +6 -1
- package/package.json +1 -1
- package/server/index.ts +12 -0
- package/server/lib/g2-enrichment-runner.ts +179 -0
- package/server/lib/g2-ops-handoff.ts +136 -0
- package/server/lib/live-cues-capability.ts +83 -0
- package/server/lib/live-cues-cursor.ts +167 -0
- package/server/lib/live-cues-engine.ts +461 -0
- package/server/lib/live-cues-memory.ts +202 -0
- package/server/lib/live-cues-prompt.ts +98 -0
- package/server/lib/maintenance-lifecycle.ts +6 -0
- package/server/lib/meeting-store.ts +10 -1
- package/server/lib/profile.ts +24 -2
- package/server/lib/speaker-embeddings.ts +169 -6
- package/server/routes/health.ts +17 -0
- package/server/routes/live-cues.ts +58 -0
- package/server/routes/meeting.ts +43 -12
- package/server/routes/transcribe-stream.ts +17 -2
- package/server/routes/voice.ts +9 -3
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Live Cues routes. Authenticated by the global /api X-Cos-Token middleware
|
|
2
|
+
// (index.ts:148) like every other /api route — no route-local auth needed.
|
|
3
|
+
//
|
|
4
|
+
// POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
|
|
5
|
+
// POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
|
|
6
|
+
// GET /live-cues/status -> engine snapshot + capability
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { errMsg } from '../lib/utils.js'
|
|
10
|
+
import {
|
|
11
|
+
armLiveCues,
|
|
12
|
+
disarmLiveCues,
|
|
13
|
+
getLiveCuesStatus,
|
|
14
|
+
LiveCuesArmError,
|
|
15
|
+
} from '../lib/live-cues-engine.js'
|
|
16
|
+
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
17
|
+
|
|
18
|
+
export const liveCuesRouter = Router()
|
|
19
|
+
|
|
20
|
+
liveCuesRouter.post('/live-cues/start', async (req, res) => {
|
|
21
|
+
const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
|
|
22
|
+
? String((req.body as { sessionId: string }).sessionId).trim()
|
|
23
|
+
: ''
|
|
24
|
+
// A start with no sessionId is refused, never silently armed on a global
|
|
25
|
+
// counter — the per-meeting cap is only real when it is session-scoped.
|
|
26
|
+
if (!sessionId || !/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
27
|
+
return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const result = await armLiveCues(sessionId)
|
|
31
|
+
return res.json(result)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof LiveCuesArmError) {
|
|
34
|
+
return res.status(error.status).json({ error: error.message, reason: error.code })
|
|
35
|
+
}
|
|
36
|
+
return res.status(500).json({ error: errMsg(error) })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
liveCuesRouter.post('/live-cues/stop', (req, res) => {
|
|
41
|
+
const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
|
|
42
|
+
? String((req.body as { sessionId: string }).sessionId).trim()
|
|
43
|
+
: ''
|
|
44
|
+
if (!sessionId) {
|
|
45
|
+
return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
|
|
46
|
+
}
|
|
47
|
+
// Shape matches the existing client stop handler: it reads nudgesGenerated
|
|
48
|
+
// for its log line and hydrates meetingNudgeHistory from nudges when SSE
|
|
49
|
+
// missed events (the 200-event replay buffer is flooded by transcript_chunk).
|
|
50
|
+
return res.json(disarmLiveCues(sessionId))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
liveCuesRouter.get('/live-cues/status', (_req, res) => {
|
|
54
|
+
res.json({
|
|
55
|
+
capability: liveCuesCapability(),
|
|
56
|
+
sessions: getLiveCuesStatus(),
|
|
57
|
+
})
|
|
58
|
+
})
|
package/server/routes/meeting.ts
CHANGED
|
@@ -41,6 +41,13 @@ import {
|
|
|
41
41
|
} from './transcribe-stream.js'
|
|
42
42
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
43
43
|
import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
|
|
44
|
+
import { handoffMeetingToOperations } from '../lib/g2-ops-handoff.js'
|
|
45
|
+
|
|
46
|
+
function cosOpsPipelineConfigured(): boolean {
|
|
47
|
+
// Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
|
|
48
|
+
// clear ops env stay standalone, and Control-updated env is visible.
|
|
49
|
+
return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
|
|
50
|
+
}
|
|
44
51
|
|
|
45
52
|
interface MeetingSessionSource {
|
|
46
53
|
getTranscript(sessionId: string): string | null
|
|
@@ -313,24 +320,48 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
313
320
|
allowDuringDrain: true,
|
|
314
321
|
phase: 'queued',
|
|
315
322
|
})
|
|
316
|
-
const task = Promise.resolve().then(() => {
|
|
323
|
+
const task = Promise.resolve().then(async () => {
|
|
317
324
|
batchLease.setPhase('active')
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
try {
|
|
326
|
+
await finalizeBatch({
|
|
327
|
+
audioDir: pendingAudioDir,
|
|
328
|
+
entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
|
|
329
|
+
streamingWordCount: countWords(transcript),
|
|
330
|
+
meetingPath: saved.filepath,
|
|
331
|
+
sidecarPath: saved.sidecarPath,
|
|
332
|
+
runBatch,
|
|
333
|
+
})
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// Raw audio deliberately remains for bounded cleanup / retry.
|
|
336
|
+
console.error(
|
|
337
|
+
`[meeting/save] Batch finalization failed for ${sessionId}: `
|
|
338
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
// Always hand off the durable local scribe (streaming or batch) into
|
|
342
|
+
// operations/ when COS pipeline is configured — this is what was
|
|
343
|
+
// missing on managed public server and left today's G2 files unsynced.
|
|
344
|
+
if (cosOpsPipelineConfigured()) {
|
|
345
|
+
await handoffMeetingToOperations(saved.filepath)
|
|
346
|
+
}
|
|
326
347
|
}).catch(error => {
|
|
327
|
-
// Raw audio deliberately remains for the existing two-hour cleanup.
|
|
328
348
|
console.error(
|
|
329
|
-
`[meeting/save]
|
|
349
|
+
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
330
350
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
331
351
|
)
|
|
332
352
|
}).finally(() => batchLease.release())
|
|
333
353
|
scheduleBackground(task)
|
|
354
|
+
} else if (cosOpsPipelineConfigured()) {
|
|
355
|
+
// No HQ batch (no audio / incomplete writes) — still hand off the
|
|
356
|
+
// streaming scribe into operations when COS pipeline is configured.
|
|
357
|
+
scheduleBackground(
|
|
358
|
+
handoffMeetingToOperations(saved.filepath).catch(error => {
|
|
359
|
+
console.error(
|
|
360
|
+
`[meeting/save] G2 ops handoff failed for ${sessionId}: `
|
|
361
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
+
)
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
334
365
|
}
|
|
335
366
|
} catch (error) {
|
|
336
367
|
if (error instanceof MeetingStoreError) {
|
|
@@ -399,7 +430,7 @@ async function finalizeBatch(options: {
|
|
|
399
430
|
if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
|
|
400
431
|
rmSync(options.audioDir, { recursive: true, force: true })
|
|
401
432
|
} else {
|
|
402
|
-
console.warn('[meeting/save] Pending raw audio retained for bounded
|
|
433
|
+
console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
|
|
403
434
|
}
|
|
404
435
|
}
|
|
405
436
|
|
|
@@ -46,8 +46,10 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
|
46
46
|
import {
|
|
47
47
|
acquireMaintenanceWork,
|
|
48
48
|
maintenanceAdmissionsOpen,
|
|
49
|
+
maintenanceLifecycle,
|
|
49
50
|
type MaintenanceWorkLease,
|
|
50
51
|
} from '../lib/maintenance-lifecycle.js'
|
|
52
|
+
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
51
53
|
|
|
52
54
|
function ensurePrivateDirectory(path: string): void {
|
|
53
55
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -636,11 +638,15 @@ setInterval(() => {
|
|
|
636
638
|
}
|
|
637
639
|
}
|
|
638
640
|
} catch {}
|
|
639
|
-
// Purge stale pending-batch dirs
|
|
641
|
+
// Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
|
|
642
|
+
// restart can exceed 2h (2026-07-27: two sessions purged before batch).
|
|
643
|
+
// Use 12h, and never purge while a meeting_batch_finalization lease is held.
|
|
640
644
|
// Age measured by _batch_pending.marker mtime (set by moveSessionAudioToPending), NOT the first
|
|
641
645
|
// chunk's mtime — chunk files keep their original write-time across the atomic rename, so for
|
|
642
646
|
// meetings > 1 hour, chunk mtimes would always look stale. Fallback: dir mtime for older marker-less dirs.
|
|
643
647
|
try {
|
|
648
|
+
const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
|
|
649
|
+
.meeting_batch_finalization ?? 0) > 0
|
|
644
650
|
for (const dir of readdirSync(PENDING_BATCH_DIR)) {
|
|
645
651
|
const dirPath = resolve(PENDING_BATCH_DIR, dir)
|
|
646
652
|
try {
|
|
@@ -655,7 +661,11 @@ setInterval(() => {
|
|
|
655
661
|
// Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
|
|
656
662
|
ageSource = statSync(dirPath).ctimeMs
|
|
657
663
|
}
|
|
658
|
-
if (Date.now() - ageSource >
|
|
664
|
+
if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
|
|
665
|
+
if (batchBusy) {
|
|
666
|
+
console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
659
669
|
rmSync(dirPath, { recursive: true, force: true })
|
|
660
670
|
console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
|
|
661
671
|
}
|
|
@@ -1538,6 +1548,11 @@ async function processStreamChunk(opts: {
|
|
|
1538
1548
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1539
1549
|
|
|
1540
1550
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
1551
|
+
// Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
|
|
1552
|
+
// block on a cue, and no LLM runs on this path. .catch() is required — a
|
|
1553
|
+
// bare `void` on a rejecting promise is an unhandled rejection, which Node
|
|
1554
|
+
// throws on by default. All gates live inside the feed.
|
|
1555
|
+
void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
1541
1556
|
|
|
1542
1557
|
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
|
|
1543
1558
|
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|
package/server/routes/voice.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
|
|
9
9
|
import { statSync } from 'node:fs'
|
|
10
10
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
11
|
+
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
11
12
|
|
|
12
13
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
13
14
|
const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
|
|
@@ -18,7 +19,10 @@ export const voiceRouter = Router()
|
|
|
18
19
|
// POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
|
|
19
20
|
voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
20
21
|
try {
|
|
21
|
-
|
|
22
|
+
// Default to the configured wearer label ('Me' unless owner_speaker_label
|
|
23
|
+
// is set). Hardcoding one user's initials here enrolled every other install
|
|
24
|
+
// under a stranger's name.
|
|
25
|
+
const name = (req.query.name as string) || getOwnerSpeakerLabel()
|
|
22
26
|
|
|
23
27
|
// Collect raw audio body
|
|
24
28
|
const buffers: Buffer[] = []
|
|
@@ -38,10 +42,12 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
|
|
|
38
42
|
}
|
|
39
43
|
})
|
|
40
44
|
|
|
41
|
-
// GET /api/voice/status — is
|
|
45
|
+
// GET /api/voice/status — is the wearer enrolled?
|
|
42
46
|
voiceRouter.get('/voice/status', (_req, res) => {
|
|
47
|
+
const owner = getOwnerSpeakerLabel()
|
|
43
48
|
res.json({
|
|
44
|
-
|
|
49
|
+
owner,
|
|
50
|
+
enrolled: isEnrolled(owner),
|
|
45
51
|
speakers: getAllSpeakerNames(),
|
|
46
52
|
})
|
|
47
53
|
})
|