@gotcos/glasses-server 6.83.0 → 6.84.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 +143 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +5 -1
- package/server/lib/dictation-clean.ts +26 -3
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/glossary-learn.ts +88 -10
- package/server/lib/hallucination-filter.ts +5 -0
- package/server/lib/meeting-batch-transcribe.ts +12 -4
- package/server/lib/profile.ts +3 -1
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +31 -5
- package/server/routes/glossary.ts +14 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +15 -2
- package/server/routes/transcribe-stream.ts +127 -10
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +0 -151
|
@@ -43,8 +43,16 @@ export interface QuarantineManifest {
|
|
|
43
43
|
reason: string
|
|
44
44
|
chunkFiles: number
|
|
45
45
|
bytes: number
|
|
46
|
+
/** 6.83.2: a shorter clock for this one capture (a Mac capture discarded with no sound). Never longer than the global one. */
|
|
47
|
+
retentionMs?: number
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** 6.83.2: how long a discarded no-sound Mac capture stays quarantined: long enough to undo a wrong discard by hand. */
|
|
51
|
+
export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
52
|
+
|
|
53
|
+
/** The receipt a discard writes, so the capture is terminal (`no_speech`) and never recovered on its own. */
|
|
54
|
+
export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
|
|
55
|
+
|
|
48
56
|
export interface UnsavedCapture {
|
|
49
57
|
sessionId: string
|
|
50
58
|
dirName: string
|
|
@@ -57,6 +65,12 @@ export interface UnsavedCapture {
|
|
|
57
65
|
recovered: boolean
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** The clock that applies to one capture: its own when it set a shorter one, else the global retention. */
|
|
69
|
+
export function effectiveRetentionMs(manifest: { retentionMs?: unknown } | null, retentionMs: number): number {
|
|
70
|
+
const own = manifest?.retentionMs
|
|
71
|
+
return typeof own === 'number' && Number.isFinite(own) && own > 0 ? Math.min(retentionMs, own) : retentionMs
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export function unsavedAudioRoot(): string {
|
|
61
75
|
return dataPath('unsaved-audio')
|
|
62
76
|
}
|
|
@@ -99,6 +113,7 @@ export function quarantineSessionAudio(
|
|
|
99
113
|
sourceDir: string,
|
|
100
114
|
reason: string,
|
|
101
115
|
root: string = unsavedAudioRoot(),
|
|
116
|
+
options: { retentionMs?: number; receipt?: QuarantineReceipt } = {},
|
|
102
117
|
): string | null {
|
|
103
118
|
const sessionId = basename(sourceDir)
|
|
104
119
|
try {
|
|
@@ -114,6 +129,7 @@ export function quarantineSessionAudio(
|
|
|
114
129
|
reason,
|
|
115
130
|
chunkFiles: countChunkWavs(target),
|
|
116
131
|
bytes: dirBytes(target),
|
|
132
|
+
...(options.retentionMs ? { retentionMs: options.retentionMs } : {}),
|
|
117
133
|
}
|
|
118
134
|
try {
|
|
119
135
|
writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
|
|
@@ -121,6 +137,19 @@ export function quarantineSessionAudio(
|
|
|
121
137
|
mode: 0o600,
|
|
122
138
|
})
|
|
123
139
|
} catch { /* manifest is observability; the audio move already succeeded */ }
|
|
140
|
+
if (options.receipt) {
|
|
141
|
+
// 6.83.2: a discarded capture is terminal. The receipt is what keeps the auto-recover sweep and the
|
|
142
|
+
// unsaved-captures count off it (listUnsavedCaptures reads it as recovered, readCaptureDisposition as no_speech).
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(resolve(target, RECOVERED_RECEIPT), `${JSON.stringify({
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
recoveredAt: new Date().toISOString(),
|
|
147
|
+
chunkFiles: manifest.chunkFiles,
|
|
148
|
+
words: 0,
|
|
149
|
+
...options.receipt,
|
|
150
|
+
})}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
151
|
+
} catch { /* the audio is quarantined either way; without the receipt it is merely listed as recoverable */ }
|
|
152
|
+
}
|
|
124
153
|
return target
|
|
125
154
|
} catch {
|
|
126
155
|
// Rename failed (cross-device, permissions, race). Leave the source alone —
|
|
@@ -220,6 +249,7 @@ export function listUnsavedCaptures(
|
|
|
220
249
|
} catch { continue }
|
|
221
250
|
const manifest = readManifest(dirPath)
|
|
222
251
|
const atMs = quarantinedAtMs(dirPath, manifest)
|
|
252
|
+
const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
|
|
223
253
|
captures.push({
|
|
224
254
|
sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
|
|
225
255
|
dirName: dir,
|
|
@@ -228,7 +258,7 @@ export function listUnsavedCaptures(
|
|
|
228
258
|
chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
|
|
229
259
|
bytes: manifest?.bytes ?? dirBytes(dirPath),
|
|
230
260
|
reason: manifest?.reason ?? null,
|
|
231
|
-
expiresAt: atMs != null ? new Date(atMs +
|
|
261
|
+
expiresAt: atMs != null ? new Date(atMs + ownRetentionMs).toISOString() : null,
|
|
232
262
|
recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
|
|
233
263
|
})
|
|
234
264
|
}
|
|
@@ -248,9 +278,10 @@ export function purgeExpiredQuarantine(
|
|
|
248
278
|
// sitting exactly at the retention boundary would otherwise lose its
|
|
249
279
|
// audio mid-run, in the module whose thesis is "never delete evidence".
|
|
250
280
|
if (isRecoveryActive(capture.dirName)) continue
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
281
|
+
// The capture's own expiry: a discarded no-sound capture sets a shorter clock (6.83.2).
|
|
282
|
+
const expiresMs = capture.expiresAt ? Date.parse(capture.expiresAt) : NaN
|
|
283
|
+
if (!Number.isFinite(expiresMs)) continue
|
|
284
|
+
if (Date.now() > expiresMs) {
|
|
254
285
|
try {
|
|
255
286
|
rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
|
|
256
287
|
purged.push(capture.dirName)
|
|
@@ -433,7 +464,7 @@ function dispositionOf(sessionId: string, dirPath: string, retentionMs: number):
|
|
|
433
464
|
quarantineReason: manifest?.reason ?? null,
|
|
434
465
|
savedFilename,
|
|
435
466
|
recovering: isRecoveryActive(basename(dirPath)),
|
|
436
|
-
retainedUntil: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
|
|
467
|
+
retainedUntil: atMs != null ? new Date(atMs + effectiveRetentionMs(manifest, retentionMs)).toISOString() : null,
|
|
437
468
|
}
|
|
438
469
|
}
|
|
439
470
|
|
|
@@ -16,6 +16,7 @@ import crypto from 'node:crypto'
|
|
|
16
16
|
import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
|
|
17
17
|
import { readSttSettings, type FrontEndId } from './stt-settings.js'
|
|
18
18
|
import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
|
|
19
|
+
import { decoderSafeContext } from './glossary-learn.js'
|
|
19
20
|
import { stripBrandUrls } from './hallucination-filter.js'
|
|
20
21
|
import {
|
|
21
22
|
batchHqMetalEnabled,
|
|
@@ -1243,8 +1244,9 @@ export async function transcribeHighQuality(
|
|
|
1243
1244
|
}
|
|
1244
1245
|
}
|
|
1245
1246
|
|
|
1246
|
-
/** Build prompt with optional previous transcript context for continuity
|
|
1247
|
-
|
|
1247
|
+
/** Build prompt with optional previous transcript context for continuity.
|
|
1248
|
+
* Exported (6.83.1) only so tests can assert on the exact decoder prompt. */
|
|
1249
|
+
export function buildPrompt(context?: string, isQuiet?: boolean): string {
|
|
1248
1250
|
// During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
|
|
1249
1251
|
if (isQuiet) return ''
|
|
1250
1252
|
if (!context || context.length < 10) return getWhisperPrompt()
|
|
@@ -1259,6 +1261,9 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
|
|
|
1259
1261
|
// prior chunk would otherwise feed back through this context and re-seed the
|
|
1260
1262
|
// next decode. Strip brand URLs from the priming context (output unaffected).
|
|
1261
1263
|
if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
|
|
1264
|
+
// 6.83.1: a correction-only form ("/pricing") came from the corrections pass, not the
|
|
1265
|
+
// speaker's audio; the decoder is primed with the spoken form instead (glossary-learn.ts).
|
|
1266
|
+
sanitized = decoderSafeContext(sanitized, getWhisperCorrections())
|
|
1262
1267
|
// Previous transcript helps Whisper maintain proper noun consistency,
|
|
1263
1268
|
// continue sentences across chunk boundaries, and reduce hallucinations
|
|
1264
1269
|
return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
|
|
@@ -1379,14 +1384,34 @@ function buildCorrections(): Array<[RegExp, string]> {
|
|
|
1379
1384
|
|
|
1380
1385
|
// Escape correction keys before interpolation. Names such as "A.C.M.E."
|
|
1381
1386
|
// are literal vocabulary, never regular-expression programs.
|
|
1382
|
-
|
|
1383
|
-
|
|
1387
|
+
// 6.83.1: the words of a multi-word key may be split by a run of spaces or tabs, or by one
|
|
1388
|
+
// hyphen ("forward-slash pricing"); never by a line break or a spaced dash (" - ").
|
|
1389
|
+
for (const [pattern, replacement] of correctionOrder(getWhisperCorrections())) {
|
|
1390
|
+
const escaped = pattern.trim().split(/\s+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:[ \\t]+|-)')
|
|
1384
1391
|
corrections.push([new RegExp(`\\b${escaped}\\b`, 'gi'), replacement])
|
|
1385
1392
|
}
|
|
1386
1393
|
|
|
1387
1394
|
return corrections
|
|
1388
1395
|
}
|
|
1389
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* The profile's order, except that a key goes ahead of any earlier key whose words it
|
|
1399
|
+
* contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
|
|
1400
|
+
* the shorter key would cut the longer phrase short. Chains the person relies on
|
|
1401
|
+
* ("Myles" then "Miles Ukoma") keep their order, since neither key contains the other.
|
|
1402
|
+
*/
|
|
1403
|
+
export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
|
|
1404
|
+
const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
|
|
1405
|
+
const out: Array<[string, string]> = []
|
|
1406
|
+
for (const entry of Object.entries(corrections)) {
|
|
1407
|
+
const mine = norm(entry[0])
|
|
1408
|
+
const at = out.findIndex(([key]) => mine !== norm(key) && mine.includes(norm(key)))
|
|
1409
|
+
if (at < 0) out.push(entry)
|
|
1410
|
+
else out.splice(at, 0, entry)
|
|
1411
|
+
}
|
|
1412
|
+
return out
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1390
1415
|
let _corrections: Array<[RegExp, string]> | null = null
|
|
1391
1416
|
function getCorrections(): Array<[RegExp, string]> {
|
|
1392
1417
|
if (!_corrections) _corrections = buildCorrections()
|
|
@@ -1399,7 +1424,8 @@ function getCorrections(): Array<[RegExp, string]> {
|
|
|
1399
1424
|
export function applyCorrections(text: string): string {
|
|
1400
1425
|
let result = text
|
|
1401
1426
|
for (const [pattern, replacement] of getCorrections()) {
|
|
1402
|
-
|
|
1427
|
+
// A function, so a written form holding "$&" or "$1" is inserted literally (6.83.1).
|
|
1428
|
+
result = result.replace(pattern, () => replacement)
|
|
1403
1429
|
}
|
|
1404
1430
|
return result
|
|
1405
1431
|
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// left unchanged). whisper_corrections is stored as a JSON STRING in the
|
|
9
9
|
// profile (legacy decoder contract), so the route encodes/decodes it here.
|
|
10
10
|
// POST /api/transcription-glossary/correction {heard, written, source} → learn
|
|
11
|
-
// ONE correction and its spelling (6.73.11, Fix a word), logged for undo.
|
|
11
|
+
// ONE correction and its spelling (6.73.11, Fix a word), logged for undo. A
|
|
12
|
+
// written link, email or path is a correction only (6.83.1): the answer adds
|
|
13
|
+
// correctionOnly: true and a note, and the vocabulary is untouched.
|
|
12
14
|
// GET /api/transcription-glossary/recent → { entries } newest first.
|
|
13
15
|
// POST /api/transcription-glossary/correction/undo {id} → revert that entry.
|
|
14
16
|
// POST /api/transcription-glossary/vocabulary/add {term}, /vocabulary/remove {term},
|
|
@@ -32,6 +34,8 @@ import {
|
|
|
32
34
|
MAX_TERM_LEN,
|
|
33
35
|
addVocabulary,
|
|
34
36
|
appendLog,
|
|
37
|
+
CORRECTION_ONLY_NOTE,
|
|
38
|
+
isCorrectionOnly,
|
|
35
39
|
learnCorrection,
|
|
36
40
|
looksLikeUrlEmailPath,
|
|
37
41
|
parseLog,
|
|
@@ -219,7 +223,15 @@ export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
|
|
|
219
223
|
if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
|
|
220
224
|
if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
|
|
221
225
|
commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
|
|
222
|
-
|
|
226
|
+
// 6.83.1: a link, email or path is kept as a correction only; say so in words a client can show.
|
|
227
|
+
const correctionOnly = isCorrectionOnly(learned.entry.written)
|
|
228
|
+
return {
|
|
229
|
+
status: 200,
|
|
230
|
+
body: {
|
|
231
|
+
unchanged: false, entry: learned.entry, glossary: currentGlossary(),
|
|
232
|
+
...(correctionOnly ? { correctionOnly: true, note: CORRECTION_ONLY_NOTE } : {}),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
223
235
|
})
|
|
224
236
|
}
|
|
225
237
|
|
package/server/routes/meeting.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/mee
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
8
8
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
9
|
-
import { resolve } from 'node:path'
|
|
9
|
+
import { basename, resolve } from 'node:path'
|
|
10
10
|
import { createHash } from 'node:crypto'
|
|
11
11
|
import { Router } from 'express'
|
|
12
12
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -129,6 +129,7 @@ import {
|
|
|
129
129
|
analyzeTranscriptGaps,
|
|
130
130
|
deleteSession,
|
|
131
131
|
assertMacCaptureComplete,
|
|
132
|
+
discardSilentMacCapture,
|
|
132
133
|
drainSessionAudioWrites,
|
|
133
134
|
durableSessionRecoveryHasRun,
|
|
134
135
|
getSessionChunkEntries,
|
|
@@ -805,6 +806,80 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
805
806
|
})
|
|
806
807
|
})
|
|
807
808
|
|
|
809
|
+
// ── 6.83.2: discard a stranded Mac capture that held no sound ──────────────────────
|
|
810
|
+
// POST /meeting/sessions/:id/discard {confirm: true, serverInstanceId}. COS Control offers it only for a capture the
|
|
811
|
+
// server called no_speech or whose audio was all zeros; the server re-checks everything itself
|
|
812
|
+
// (transcribe-stream.ts discardSilentMacCapture) and never touches a capture with speech. Without `confirm: true`
|
|
813
|
+
// it answers 400 confirmation_required with what it WOULD do, and changes nothing.
|
|
814
|
+
router.post('/meeting/sessions/:sessionId/discard', async (req, res) => {
|
|
815
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
816
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
817
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
res.set('Cache-Control', 'private, no-store')
|
|
821
|
+
if (!MAC_CAPTURE_ID.test(sessionId)) {
|
|
822
|
+
res.status(400).json({ error: 'Only a Mac recording can be discarded here', reason: 'not_mac_capture' })
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
const body = (req.body ?? {}) as Record<string, unknown>
|
|
826
|
+
const serverInstanceId = getServerInstanceId()
|
|
827
|
+
if (!serverInstanceId) {
|
|
828
|
+
res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
const pin = suppliedServerPin(req, body.serverInstanceId)
|
|
832
|
+
if (!pin) {
|
|
833
|
+
res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
if (pin !== serverInstanceId) {
|
|
837
|
+
res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
if (store.findBySessionId(sessionId)) {
|
|
841
|
+
res.status(409).json({ error: 'This recording is saved as a meeting', reason: 'already_saved' })
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
if (savingSessions.has(sessionId)) {
|
|
845
|
+
res.status(409).json({ error: 'This recording is being saved', reason: 'saving' })
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
const confirmed = body.confirm === true
|
|
849
|
+
const result = await discardSilentMacCapture(sessionId, { dryRun: !confirmed })
|
|
850
|
+
if (!result.ok && result.reason === 'session_not_found') {
|
|
851
|
+
// Already discarded (a retry after a lost answer): the quarantine says so, and the answer is the same.
|
|
852
|
+
const disposition = readCaptureDisposition(sessionId)
|
|
853
|
+
if (disposition?.quarantineReason === 'discarded_no_sound') {
|
|
854
|
+
res.json({ sessionId, discarded: true, replayed: true, quarantined: disposition.dirName, retainedUntil: disposition.retainedUntil })
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
res.status(404).json({ error: 'The server holds no live session for this recording', reason: 'session_not_found' })
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
if (!result.ok) {
|
|
861
|
+
const message: Record<string, string> = {
|
|
862
|
+
has_speech: 'This recording has speech; save it instead',
|
|
863
|
+
asr_in_progress: 'Audio is still being transcribed; try again in a moment',
|
|
864
|
+
chunk_in_progress: 'An audio chunk is still arriving; try again in a moment',
|
|
865
|
+
not_idle: 'This recording is still receiving audio',
|
|
866
|
+
not_mac_capture: 'Only a Mac recording can be discarded here',
|
|
867
|
+
}
|
|
868
|
+
res.status(409).json({ error: message[result.reason ?? ''] ?? 'Not discarded', reason: result.reason, receivedCount: result.receivedCount })
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
if (!confirmed) {
|
|
872
|
+
res.status(400).json({
|
|
873
|
+
error: 'Confirmation required', reason: 'confirmation_required',
|
|
874
|
+
preview: { sessionId, chunkFiles: result.chunkFiles, receivedCount: result.receivedCount, retentionHours: 24 },
|
|
875
|
+
})
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
console.warn(`[meeting/discard] Discarded no-sound Mac capture ${sessionId}: ${result.receivedCount} chunk(s) received, audio quarantined for 24 h`)
|
|
879
|
+
res.json({ sessionId, discarded: true, replayed: false, quarantined: result.quarantined ? basename(result.quarantined) : null,
|
|
880
|
+
chunkFiles: result.chunkFiles, receivedCount: result.receivedCount, retainedUntil: result.retainedUntil })
|
|
881
|
+
})
|
|
882
|
+
|
|
808
883
|
// ── Stop a live G2 meeting from the Mac (COS Control) ────────────────────────────
|
|
809
884
|
// The Mac never saves a live G2 session itself (lib/meeting-stop-requests.ts says why).
|
|
810
885
|
// It asks the glasses: POST records the request and puts `meeting_stop_requested` on the
|
|
@@ -49,7 +49,9 @@ import {
|
|
|
49
49
|
discardPolishPrewarm, isPrewarmModel, prewarmEnabled, PrewarmUnavailableError, runWarmPolish,
|
|
50
50
|
startPolishPrewarm, takePolishPrewarm,
|
|
51
51
|
} from '../lib/dictation-prewarm.js'
|
|
52
|
-
import { getVocabulary } from '../lib/profile.js'
|
|
52
|
+
import { getVocabulary, getWhisperCorrections } from '../lib/profile.js'
|
|
53
|
+
import { correctionOnlyForms } from '../lib/glossary-learn.js'
|
|
54
|
+
import { polishLinkProblem, renderSpokenPaths, spokenPathsEnabled } from '../lib/dictation-format.js'
|
|
53
55
|
import { createBreaker } from '../lib/claude-circuit.js'
|
|
54
56
|
import { logTokenAudit } from '../lib/token-audit.js'
|
|
55
57
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -90,7 +92,7 @@ const autoCleanBreakers = {
|
|
|
90
92
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
91
93
|
|
|
92
94
|
/** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
|
|
93
|
-
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean'
|
|
95
|
+
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
|
|
94
96
|
/** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
|
|
95
97
|
export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
|
|
96
98
|
|
|
@@ -144,6 +146,9 @@ async function polishOutboundDictation(
|
|
|
144
146
|
}
|
|
145
147
|
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
146
148
|
if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
|
|
149
|
+
// 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
|
|
150
|
+
// at a phrase end only; slugs of several words are the model's (dictation-format.ts).
|
|
151
|
+
if (opts.bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
|
|
147
152
|
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
148
153
|
const model = opts.model ?? 'haiku'
|
|
149
154
|
const autoCleanBreaker = autoCleanBreakers[model]
|
|
@@ -174,6 +179,14 @@ async function polishOutboundDictation(
|
|
|
174
179
|
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
175
180
|
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
176
181
|
})
|
|
182
|
+
// 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
|
|
183
|
+
// answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
|
|
184
|
+
// pasting a URL nobody said. The call still counted: it ran.
|
|
185
|
+
const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
|
|
186
|
+
if (linkProblem) {
|
|
187
|
+
console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
|
|
188
|
+
return { text: cleaned, ...(prewarm ? { prewarm } : {}), polishSkipped: 'link_guard' }
|
|
189
|
+
}
|
|
177
190
|
return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}) }
|
|
178
191
|
} catch (err: any) {
|
|
179
192
|
autoCleanBreaker.recordFailure()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MacParakeetUnavailableError, transcribeMacParakeet } from '../lib/mac-recording-asr.js'
|
|
2
2
|
import { getMeetingStore } from '../lib/meeting-store.js'
|
|
3
3
|
import { appendAutonomousSaveLog, getStopIntentStore, savedChunkReplay, type StopIntent } from '../lib/meeting-stop-intents.js'
|
|
4
|
-
import { MAC_AUDIO, MAC_CAPTURE_ID, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
4
|
+
import { MAC_AUDIO, MAC_CAPTURE_ID, closeMacRecording, persistMacChunkReceipt, macError, registerMacRecording, requireMacRecording, readMacRecording, validateMacWav } from '../lib/mac-recording.js'
|
|
5
5
|
import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
|
|
6
6
|
import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
7
7
|
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
@@ -15,7 +15,8 @@ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSyn
|
|
|
15
15
|
import { writeFile } from 'node:fs/promises'
|
|
16
16
|
import { resolve } from 'node:path'
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import { getVocabulary, getOwnerName, getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
18
|
+
import { getVocabulary, getOwnerName, getOwnerSpeakerLabel, getWhisperCorrections } from '../lib/profile.js'
|
|
19
|
+
import { decoderContextWindow } from '../lib/glossary-learn.js'
|
|
19
20
|
import { getOpenAIKey } from '../lib/openai-key.js'
|
|
20
21
|
import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
|
|
21
22
|
import { STRANDED_STALE_MS } from '../lib/stranded-sessions.js'
|
|
@@ -81,12 +82,14 @@ import {
|
|
|
81
82
|
} from '../lib/even-hub-speaker-role.js'
|
|
82
83
|
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
83
84
|
import {
|
|
85
|
+
DISCARDED_RETENTION_MS,
|
|
84
86
|
countChunkWavs,
|
|
85
87
|
listUnsavedCaptures,
|
|
86
88
|
purgeExpiredQuarantine,
|
|
87
89
|
quarantineSessionAudio,
|
|
88
90
|
readCaptureDisposition,
|
|
89
91
|
sweepOrphanedSessionAudio,
|
|
92
|
+
type QuarantineReceipt,
|
|
90
93
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
91
94
|
import {
|
|
92
95
|
STALE_ACTIVE_SESSION_DIR_NAME,
|
|
@@ -1728,11 +1731,14 @@ export function getMeetingSessionStatus(sessionId: string, now = Date.now()): Me
|
|
|
1728
1731
|
}
|
|
1729
1732
|
}
|
|
1730
1733
|
|
|
1734
|
+
/** 6.83.2: a close that quarantines under its own reason, clock and terminal receipt (the Mac discard). */
|
|
1735
|
+
type CloseQuarantine = { reason: string; retentionMs?: number; receipt?: QuarantineReceipt }
|
|
1736
|
+
|
|
1731
1737
|
function closeTranscriptSession(
|
|
1732
1738
|
sessionId: string,
|
|
1733
1739
|
reason: ClosedTranscriptSession['reason'],
|
|
1734
|
-
options: { preserveAudio?: boolean } = {},
|
|
1735
|
-
):
|
|
1740
|
+
options: { preserveAudio?: boolean; quarantine?: CloseQuarantine } = {},
|
|
1741
|
+
): string | null {
|
|
1736
1742
|
// Progressive HQ is disposable cache state. Every terminal session path —
|
|
1737
1743
|
// save, expiry, quarantine, or abandonment — must release the single owner.
|
|
1738
1744
|
void stopProgressiveHqSession(sessionId)
|
|
@@ -1751,7 +1757,7 @@ function closeTranscriptSession(
|
|
|
1751
1757
|
maxChunkIndex,
|
|
1752
1758
|
reason,
|
|
1753
1759
|
})
|
|
1754
|
-
finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
|
|
1760
|
+
return finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
|
|
1755
1761
|
}
|
|
1756
1762
|
|
|
1757
1763
|
/** Delete session after save */
|
|
@@ -1759,10 +1765,11 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
|
|
|
1759
1765
|
closeTranscriptSession(sessionId, 'saved', options)
|
|
1760
1766
|
}
|
|
1761
1767
|
|
|
1768
|
+
/** Where a close put the session's chunk audio (the quarantine dir), or null when it moved none. */
|
|
1762
1769
|
function finishClosingTranscriptSession(
|
|
1763
1770
|
sessionId: string,
|
|
1764
|
-
options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
|
|
1765
|
-
):
|
|
1771
|
+
options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason']; quarantine?: CloseQuarantine },
|
|
1772
|
+
): string | null {
|
|
1766
1773
|
sessions.delete(sessionId)
|
|
1767
1774
|
sessionAudioBytes.delete(sessionId)
|
|
1768
1775
|
sessionAudioWrites.delete(sessionId)
|
|
@@ -1794,6 +1801,7 @@ function finishClosingTranscriptSession(
|
|
|
1794
1801
|
if (countChunkWavs(audioDir) > 0) {
|
|
1795
1802
|
const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
|
|
1796
1803
|
if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
|
|
1804
|
+
return target
|
|
1797
1805
|
} else {
|
|
1798
1806
|
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1799
1807
|
}
|
|
@@ -1801,13 +1809,114 @@ function finishClosingTranscriptSession(
|
|
|
1801
1809
|
// Any non-saved close (expired, error, …) with chunk audio still on disk
|
|
1802
1810
|
// is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
|
|
1803
1811
|
if (countChunkWavs(audioDir) > 0) {
|
|
1804
|
-
const
|
|
1812
|
+
const quarantine = options.quarantine
|
|
1813
|
+
const target = quarantineSessionAudio(audioDir, quarantine?.reason ?? `close_${options.closeReason ?? 'unknown'}`, undefined,
|
|
1814
|
+
quarantine ? { retentionMs: quarantine.retentionMs, receipt: quarantine.receipt } : {})
|
|
1805
1815
|
if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
|
|
1806
1816
|
else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
|
|
1817
|
+
return target
|
|
1807
1818
|
} else {
|
|
1808
1819
|
try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
|
|
1809
1820
|
}
|
|
1810
1821
|
}
|
|
1822
|
+
return null
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ── 6.83.2: discard a stranded Mac capture that holds no sound ─────────────────────
|
|
1826
|
+
// Miles, 2026-10-21: Record on Mac captured a 21-minute Zoom call as 211 chunks of exact zeros (macOS returns
|
|
1827
|
+
// zeros, not an error, when system audio recording is not allowed). The save refused it (no_speech), and the
|
|
1828
|
+
// capture then sat in the stranded list with a Save that can never work. COS Control asks the server to let it go.
|
|
1829
|
+
//
|
|
1830
|
+
// What it may touch: a Mac capture (Control is its only uploader, so nothing else will send it a chunk) that is in
|
|
1831
|
+
// memory, holds NO canonical chunk (no transcribed text, so no speech), has finished ASR on every chunk it received
|
|
1832
|
+
// (an in-flight chunk could still become speech), is idle past EMPTY_SESSION_STALE_MS, and has no chunk upload in
|
|
1833
|
+
// progress. Anything else is refused and left exactly as it was. The audio is never deleted here: it moves into the
|
|
1834
|
+
// existing quarantine with a terminal no_speech receipt and a 24-hour clock (DISCARDED_RETENTION_MS).
|
|
1835
|
+
export type MacDiscardRefusal =
|
|
1836
|
+
| 'session_not_found' | 'not_mac_capture' | 'has_speech' | 'asr_in_progress' | 'not_idle' | 'chunk_in_progress'
|
|
1837
|
+
|
|
1838
|
+
export interface MacDiscardResult {
|
|
1839
|
+
ok: boolean
|
|
1840
|
+
reason?: MacDiscardRefusal
|
|
1841
|
+
sessionId: string
|
|
1842
|
+
/** The quarantine dir the audio moved into, or null when the capture held no chunk audio. */
|
|
1843
|
+
quarantined: string | null
|
|
1844
|
+
chunkFiles: number
|
|
1845
|
+
receivedCount: number
|
|
1846
|
+
retainedUntil: string | null
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
export const MAC_DISCARD_IDLE_MS = EMPTY_SESSION_STALE_MS
|
|
1850
|
+
|
|
1851
|
+
/** Why a session may not be discarded, or null when it may. Pure over the session's ledger. */
|
|
1852
|
+
export function macDiscardRefusal(input: {
|
|
1853
|
+
sessionId: string
|
|
1854
|
+
present: boolean
|
|
1855
|
+
canonicalCount: number
|
|
1856
|
+
receivedIndices: number[]
|
|
1857
|
+
asrCompletedIndices: number[]
|
|
1858
|
+
silentForMs: number
|
|
1859
|
+
chunkInProgress: boolean
|
|
1860
|
+
}): MacDiscardRefusal | null {
|
|
1861
|
+
if (!MAC_CAPTURE_ID.test(input.sessionId)) return 'not_mac_capture'
|
|
1862
|
+
if (!input.present) return 'session_not_found'
|
|
1863
|
+
if (input.canonicalCount > 0) return 'has_speech'
|
|
1864
|
+
const completed = new Set(input.asrCompletedIndices)
|
|
1865
|
+
if (input.receivedIndices.some(index => !completed.has(index))) return 'asr_in_progress'
|
|
1866
|
+
if (input.chunkInProgress) return 'chunk_in_progress'
|
|
1867
|
+
if (input.silentForMs < MAC_DISCARD_IDLE_MS) return 'not_idle'
|
|
1868
|
+
return null
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
export async function discardSilentMacCapture(sessionId: string, options: { now?: number; dryRun?: boolean } = {}): Promise<MacDiscardResult> {
|
|
1872
|
+
const now = options.now ?? Date.now()
|
|
1873
|
+
const refusalFor = () => {
|
|
1874
|
+
const session = sessions.get(sessionId)
|
|
1875
|
+
return {
|
|
1876
|
+
session,
|
|
1877
|
+
refusal: macDiscardRefusal({
|
|
1878
|
+
sessionId,
|
|
1879
|
+
present: !!session,
|
|
1880
|
+
canonicalCount: session ? canonicalIndices(session).length : 0,
|
|
1881
|
+
receivedIndices: session?.receivedIndices ?? [],
|
|
1882
|
+
asrCompletedIndices: session?.asrCompletedIndices ?? [],
|
|
1883
|
+
silentForMs: session ? now - session.lastActivityAt : 0,
|
|
1884
|
+
chunkInProgress: macChunkLocks.has(sessionId),
|
|
1885
|
+
}),
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const empty = (reason: MacDiscardRefusal, receivedCount = 0): MacDiscardResult =>
|
|
1889
|
+
({ ok: false, reason, sessionId, quarantined: null, chunkFiles: 0, receivedCount, retainedUntil: null })
|
|
1890
|
+
const first = refusalFor()
|
|
1891
|
+
if (first.refusal) return empty(first.refusal, first.session?.receivedIndices?.length ?? 0)
|
|
1892
|
+
if (options.dryRun) {
|
|
1893
|
+
const audioDir = getSessionAudioDirectory(sessionId)
|
|
1894
|
+
return { ok: true, sessionId, quarantined: null, chunkFiles: audioDir ? countChunkWavs(audioDir) : 0,
|
|
1895
|
+
receivedCount: first.session?.receivedIndices?.length ?? 0, retainedUntil: null }
|
|
1896
|
+
}
|
|
1897
|
+
// Every chunk write this session started reaches disk before its directory moves.
|
|
1898
|
+
await drainSessionAudioWrites(sessionId)
|
|
1899
|
+
// Ask again after the await: a chunk may have arrived meanwhile.
|
|
1900
|
+
const second = refusalFor()
|
|
1901
|
+
if (second.refusal) return empty(second.refusal, second.session?.receivedIndices?.length ?? 0)
|
|
1902
|
+
const receivedCount = second.session?.receivedIndices?.length ?? 0
|
|
1903
|
+
const quarantined = closeTranscriptSession(sessionId, 'closed', {
|
|
1904
|
+
quarantine: {
|
|
1905
|
+
reason: 'discarded_no_sound',
|
|
1906
|
+
retentionMs: DISCARDED_RETENTION_MS,
|
|
1907
|
+
receipt: { outcome: 'no_speech', discarded: true, note: 'Discarded from COS Control: no sound was captured. Audio retained until its 24-hour clock clears it.' },
|
|
1908
|
+
},
|
|
1909
|
+
})
|
|
1910
|
+
// A late chunk or a replayed start for this capture answers 410 from here on.
|
|
1911
|
+
closeMacRecording(sessionId)
|
|
1912
|
+
return {
|
|
1913
|
+
ok: true,
|
|
1914
|
+
sessionId,
|
|
1915
|
+
quarantined,
|
|
1916
|
+
chunkFiles: quarantined ? countChunkWavs(quarantined) : 0,
|
|
1917
|
+
receivedCount,
|
|
1918
|
+
retainedUntil: quarantined ? new Date(Date.now() + DISCARDED_RETENTION_MS).toISOString() : null,
|
|
1919
|
+
}
|
|
1811
1920
|
}
|
|
1812
1921
|
|
|
1813
1922
|
function isIosAsrCandidateEnabled(): boolean {
|
|
@@ -1946,7 +2055,13 @@ function recentWhisperContext(sessionId: string, session: TranscriptSession): st
|
|
|
1946
2055
|
.map(c => c.text)
|
|
1947
2056
|
.join(' ')
|
|
1948
2057
|
const cleanedContext = stripInlineHallucinations(recentText, sessionId)
|
|
1949
|
-
|
|
2058
|
+
// 6.83.1: correction-only forms ("/pricing") go back to speech BEFORE the 250-character cut.
|
|
2059
|
+
return decoderContextWindow(cleanedContext, decoderCorrections()) ?? ''
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/** The corrections map for the decoder window; a test double without it reads as none. */
|
|
2063
|
+
function decoderCorrections(): Record<string, string> {
|
|
2064
|
+
try { return getWhisperCorrections() } catch { return {} }
|
|
1950
2065
|
}
|
|
1951
2066
|
|
|
1952
2067
|
function isCrossChunkRepeat(session: TranscriptSession, text: string): boolean {
|
|
@@ -2610,7 +2725,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
|
|
|
2610
2725
|
|
|
2611
2726
|
// Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
|
|
2612
2727
|
transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
|
|
2613
|
-
|
|
2728
|
+
// 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
|
|
2729
|
+
// servers omit it, and COS Control then discards only its own local copy and says so.
|
|
2730
|
+
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
|
|
2614
2731
|
})
|
|
2615
2732
|
function requireMacPin(req: { get(name: string): string | undefined }): string {
|
|
2616
2733
|
const pin = req.get('X-COS-Server-Instance')?.trim()
|