@gotcos/glasses-server 6.83.0 → 6.85.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 +314 -0
- package/README.md +23 -0
- package/docs/active-query-jobs.md +123 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +9 -1
- package/server/index.ts +3 -0
- package/server/lib/dictation-clean.ts +79 -11
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-instructions.ts +118 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/dictation-prewarm.ts +2 -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/query-job-active.ts +140 -0
- package/server/lib/query-job-coordinator.ts +8 -0
- package/server/lib/query-job-store.ts +25 -0
- package/server/lib/token-audit.ts +7 -0
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +33 -6
- package/server/routes/dictation-polish-settings.ts +91 -0
- package/server/routes/glossary.ts +14 -2
- package/server/routes/health.ts +9 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +46 -10
- package/server/routes/query-jobs.ts +22 -0
- package/server/routes/transcribe-stream.ts +130 -11
- package/server/routes/welcome-context.ts +29 -6
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +0 -151
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,10 @@ 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'
|
|
55
|
+
import { customInstructionsHash, readCustomInstructions } from '../lib/dictation-polish-instructions.js'
|
|
53
56
|
import { createBreaker } from '../lib/claude-circuit.js'
|
|
54
57
|
import { logTokenAudit } from '../lib/token-audit.js'
|
|
55
58
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -90,7 +93,7 @@ const autoCleanBreakers = {
|
|
|
90
93
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
91
94
|
|
|
92
95
|
/** 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'
|
|
96
|
+
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
|
|
94
97
|
/** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
|
|
95
98
|
export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
|
|
96
99
|
|
|
@@ -107,7 +110,7 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
|
107
110
|
async function cleanOutboundDictation(
|
|
108
111
|
text: string,
|
|
109
112
|
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
110
|
-
): Promise<
|
|
113
|
+
): Promise<PolishResult> {
|
|
111
114
|
const result = await polishOutboundDictation(text, opts)
|
|
112
115
|
// A warm process the model call did not take must not sit until its TTL.
|
|
113
116
|
if (opts.prewarmToken && !result.prewarm) {
|
|
@@ -116,10 +119,23 @@ async function cleanOutboundDictation(
|
|
|
116
119
|
return result
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
/** 6.85.0, additive and present only when the model was given the user's polish instructions. */
|
|
123
|
+
interface PolishInstructionsUse { customInstructions?: true; instructionsHash?: string }
|
|
124
|
+
type PolishResult = { text: string; polishSkipped?: PolishSkipped; prewarm?: PrewarmOutcome } & PolishInstructionsUse
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 6.85.0: the user's polish instructions apply to dictation (Mac dictation and G2 messages, which
|
|
128
|
+
* are surface "message", and prompt drafts). Meeting lines are a faithful record, so they stay on
|
|
129
|
+
* the built-in rules alone, the same way they skip the filler pass and spoken paths.
|
|
130
|
+
*/
|
|
131
|
+
export function polishInstructionsFor(bucket: PolishBucket): string {
|
|
132
|
+
return bucket === 'meeting' ? '' : readCustomInstructions().customInstructions
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
async function polishOutboundDictation(
|
|
120
136
|
text: string,
|
|
121
137
|
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal; prewarmToken?: string },
|
|
122
|
-
): Promise<
|
|
138
|
+
): Promise<PolishResult> {
|
|
123
139
|
let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
|
|
124
140
|
// applyCorrections above is an EXACT string map, so it only fixes misspellings someone
|
|
125
141
|
// already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
|
|
@@ -144,6 +160,9 @@ async function polishOutboundDictation(
|
|
|
144
160
|
}
|
|
145
161
|
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
146
162
|
if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
|
|
163
|
+
// 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
|
|
164
|
+
// at a phrase end only; slugs of several words are the model's (dictation-format.ts).
|
|
165
|
+
if (opts.bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
|
|
147
166
|
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
148
167
|
const model = opts.model ?? 'haiku'
|
|
149
168
|
const autoCleanBreaker = autoCleanBreakers[model]
|
|
@@ -154,6 +173,10 @@ async function polishOutboundDictation(
|
|
|
154
173
|
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
155
174
|
const startedAt = Date.now()
|
|
156
175
|
recordPolishCall(opts.bucket)
|
|
176
|
+
// Read once per polish: the warm and cold paths see the same instructions.
|
|
177
|
+
const customInstructions = polishInstructionsFor(opts.bucket)
|
|
178
|
+
const instructionsHash = customInstructionsHash(customInstructions)
|
|
179
|
+
const used: PolishInstructionsUse = customInstructions ? { customInstructions: true, instructionsHash } : {}
|
|
157
180
|
// 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
|
|
158
181
|
const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
|
|
159
182
|
let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
|
|
@@ -161,24 +184,33 @@ async function polishOutboundDictation(
|
|
|
161
184
|
let polished: string | null = null
|
|
162
185
|
if (warmEntry) {
|
|
163
186
|
try {
|
|
164
|
-
polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal })).trim()
|
|
187
|
+
polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal, customInstructions })).trim()
|
|
165
188
|
prewarm = 'used'
|
|
166
189
|
} catch (err) {
|
|
167
190
|
// Nothing was sent: the cold path below is the same call, not a second one.
|
|
168
191
|
if (!(err instanceof PrewarmUnavailableError)) throw err
|
|
169
192
|
}
|
|
170
193
|
}
|
|
171
|
-
if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
|
|
194
|
+
if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal, customInstructions })).trim()
|
|
172
195
|
autoCleanBreaker.recordSuccess()
|
|
173
196
|
logTokenAudit({
|
|
174
197
|
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
175
198
|
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
199
|
+
polishInstructions: { on: Boolean(customInstructions), hash: instructionsHash },
|
|
176
200
|
})
|
|
177
|
-
|
|
201
|
+
// 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
|
|
202
|
+
// answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
|
|
203
|
+
// pasting a URL nobody said. The call still counted: it ran.
|
|
204
|
+
const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
|
|
205
|
+
if (linkProblem) {
|
|
206
|
+
console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
|
|
207
|
+
return { text: cleaned, ...(prewarm ? { prewarm } : {}), polishSkipped: 'link_guard', ...used }
|
|
208
|
+
}
|
|
209
|
+
return { text: polished || cleaned, ...(prewarm ? { prewarm } : {}), ...used }
|
|
178
210
|
} catch (err: any) {
|
|
179
211
|
autoCleanBreaker.recordFailure()
|
|
180
212
|
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
181
|
-
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}) }
|
|
213
|
+
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
|
|
182
214
|
}
|
|
183
215
|
}
|
|
184
216
|
|
|
@@ -206,7 +238,7 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
206
238
|
|
|
207
239
|
const abort = new AbortController()
|
|
208
240
|
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
209
|
-
const { text: finalText, polishSkipped, prewarm } = await cleanOutboundDictation(text, {
|
|
241
|
+
const { text: finalText, polishSkipped, prewarm, customInstructions, instructionsHash } = await cleanOutboundDictation(text, {
|
|
210
242
|
enabled: true,
|
|
211
243
|
model,
|
|
212
244
|
bucket: surface,
|
|
@@ -218,6 +250,8 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
218
250
|
res.json({
|
|
219
251
|
text: finalText, surface, polished: finalText !== text,
|
|
220
252
|
...(polishSkipped ? { polishSkipped } : {}), ...(prewarm ? { prewarm } : {}),
|
|
253
|
+
// 6.85.0: only when the model was given the user's polish instructions (a hash, never the text).
|
|
254
|
+
...(customInstructions ? { customInstructions, instructionsHash } : {}),
|
|
221
255
|
})
|
|
222
256
|
})
|
|
223
257
|
|
|
@@ -505,7 +539,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
505
539
|
await markPromptDraftError(draftId, 'No speech detected')
|
|
506
540
|
throw new NoSpeechDetectedError()
|
|
507
541
|
}
|
|
508
|
-
const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
|
|
542
|
+
const { text: finalText, polishSkipped, customInstructions, instructionsHash } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
|
|
509
543
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
510
544
|
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
511
545
|
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
@@ -535,6 +569,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
535
569
|
...(engineReason ? { engineReason } : {}),
|
|
536
570
|
// 6.79.1, additive.
|
|
537
571
|
...(polishSkipped ? { polishSkipped } : {}),
|
|
572
|
+
// 6.85.0, additive.
|
|
573
|
+
...(customInstructions ? { customInstructions, instructionsHash } : {}),
|
|
538
574
|
}
|
|
539
575
|
}
|
|
540
576
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Router, type Request, type Response } from 'express'
|
|
2
2
|
import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
|
|
3
|
+
import { ACTIVE_QUERY_JOBS_LIST_MAX, ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
|
|
3
4
|
import {
|
|
4
5
|
QueryJobCoordinator,
|
|
5
6
|
QueryJobCoordinatorError,
|
|
@@ -197,6 +198,27 @@ export function createQueryJobsRouter(
|
|
|
197
198
|
}
|
|
198
199
|
})
|
|
199
200
|
|
|
201
|
+
// 6.84.1: the in-flight jobs for COS Control's Running panel (docs/active-query-jobs.md).
|
|
202
|
+
// Read-only and in memory; registered ahead of `/query-jobs/:jobId`, which would answer
|
|
203
|
+
// 400 invalid_job_id for "active". A title is the prompt's first line, cut to 80
|
|
204
|
+
// characters; no prompt body, answer, activity or trail is ever in this answer.
|
|
205
|
+
router.get('/query-jobs/active', (_req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const now = Date.now()
|
|
208
|
+
const { items, count } = coordinator.activeJobs(now)
|
|
209
|
+
return res.json({
|
|
210
|
+
protocolVersion: ACTIVE_QUERY_JOBS_PROTOCOL,
|
|
211
|
+
enabled: enabled(),
|
|
212
|
+
now: new Date(now).toISOString(),
|
|
213
|
+
count,
|
|
214
|
+
items: items.slice(0, ACTIVE_QUERY_JOBS_LIST_MAX),
|
|
215
|
+
})
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const wire = wireError(error)
|
|
218
|
+
return res.status(wire.status).json(wire.body)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
200
222
|
// Lost-202 recovery is keyed by the immutable client identity. Keep this
|
|
201
223
|
// route explicit and ahead of the dynamic job lookup for future router
|
|
202
224
|
// changes that may broaden the latter's matcher.
|
|
@@ -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 {
|
|
@@ -2493,7 +2608,9 @@ async function processStreamChunk(opts: {
|
|
|
2493
2608
|
// The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
|
|
2494
2609
|
if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
|
|
2495
2610
|
|
|
2496
|
-
|
|
2611
|
+
// 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
|
|
2612
|
+
// text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
|
|
2613
|
+
console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} gate=${quiet.gate}${quiet.speechSec === null ? '' : ` speech=${quiet.speechSec.toFixed(2)}s`} fe=${stt.frontEnd} | ${asrProvider} | chars=${trimmedText.length}`)
|
|
2497
2614
|
return canonicalChunkResponse(chunk, sessionId, chunkIndex)
|
|
2498
2615
|
}
|
|
2499
2616
|
|
|
@@ -2610,7 +2727,9 @@ export function assertMacCaptureComplete(sessionId: string, expectedChunkCount:
|
|
|
2610
2727
|
|
|
2611
2728
|
// Manual Mac pilot: independent from iPhone candidate and cue rollout gates.
|
|
2612
2729
|
transcribeStreamRouter.get('/mac-recording/capabilities', (_req, res) => {
|
|
2613
|
-
|
|
2730
|
+
// 6.83.2: `discard` advertises POST /api/meeting/sessions/:id/discard for a stranded no-sound capture. Older
|
|
2731
|
+
// servers omit it, and COS Control then discards only its own local copy and says so.
|
|
2732
|
+
res.set('Cache-Control', 'private, no-store').json({ available: true, ...MAC_AUDIO, serverInstanceId: getServerInstanceId(), discard: true })
|
|
2614
2733
|
})
|
|
2615
2734
|
function requireMacPin(req: { get(name: string): string | undefined }): string {
|
|
2616
2735
|
const pin = req.get('X-COS-Server-Instance')?.trim()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// only proxies Open-Meteo / reverse-geocode — it is not "server location".
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
|
-
import { callPython } from '../lib/python-bridge.js'
|
|
6
|
+
import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
7
|
|
|
8
8
|
export const welcomeContextRouter = Router()
|
|
9
9
|
|
|
@@ -24,6 +24,8 @@ let lastKnownCity = ''
|
|
|
24
24
|
|
|
25
25
|
let cachedNextEvent: NextEventPayload | null = null
|
|
26
26
|
let calendarFetchedAt = 0
|
|
27
|
+
/** 6.84.1: whether the read that filled `cachedNextEvent` really reached the calendar. */
|
|
28
|
+
let cachedCalendarRead = false
|
|
27
29
|
|
|
28
30
|
// 6.63.1: after a server restart the first forecast lookup can fail and, with nothing cached, the
|
|
29
31
|
// glasses Home got no weather until the app was relaunched. The last good weather and
|
|
@@ -282,9 +284,21 @@ function isGenericNoiseEvent(title: string): boolean {
|
|
|
282
284
|
return /\booo\b|out of office|vacation|\bpto\b/i.test(t)
|
|
283
285
|
}
|
|
284
286
|
|
|
285
|
-
|
|
287
|
+
/**
|
|
288
|
+
* 6.84.1: did the bridge answer with a calendar? False for no bridge (COS_SCRIPTS_DIR unset, or
|
|
289
|
+
* set without the venv and bridge script: callPython then answers an empty no-op), for a
|
|
290
|
+
* missing answer, and for the bridge's own error answer (`calendar_unavailable` on its
|
|
291
|
+
* deadline, or not configured). An empty calendar is a real read.
|
|
292
|
+
*/
|
|
293
|
+
export function calendarAnswerWasRead(bridgeReady: boolean, answer: unknown): boolean {
|
|
294
|
+
if (!bridgeReady) return false
|
|
295
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false
|
|
296
|
+
return !('error' in answer)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function fetchNextEvent(): Promise<{ event: NextEventPayload | null; read: boolean }> {
|
|
286
300
|
if (cachedNextEvent && Date.now() - calendarFetchedAt < CALENDAR_TTL_MS) {
|
|
287
|
-
return cachedNextEvent
|
|
301
|
+
return { event: cachedNextEvent, read: cachedCalendarRead }
|
|
288
302
|
}
|
|
289
303
|
|
|
290
304
|
try {
|
|
@@ -299,6 +313,7 @@ async function fetchNextEvent(): Promise<NextEventPayload | null> {
|
|
|
299
313
|
minutes_until_next?: number
|
|
300
314
|
} | null
|
|
301
315
|
|
|
316
|
+
const read = calendarAnswerWasRead(pythonBridgeAvailable(), cal)
|
|
302
317
|
const remaining = cal?.today_remaining ?? []
|
|
303
318
|
const meaningful = remaining.find((evt) => {
|
|
304
319
|
if (evt.is_all_day) return false
|
|
@@ -327,9 +342,11 @@ async function fetchNextEvent(): Promise<NextEventPayload | null> {
|
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
calendarFetchedAt = Date.now()
|
|
330
|
-
|
|
345
|
+
cachedCalendarRead = read
|
|
346
|
+
return { event: cachedNextEvent, read }
|
|
331
347
|
} catch {
|
|
332
|
-
|
|
348
|
+
// Threw or timed out: whatever is cached was not read just now.
|
|
349
|
+
return { event: cachedNextEvent, read: false }
|
|
333
350
|
}
|
|
334
351
|
}
|
|
335
352
|
|
|
@@ -342,6 +359,7 @@ export function _resetWelcomeContextCachesForTests(): void {
|
|
|
342
359
|
lastKnownCity = ''
|
|
343
360
|
cachedNextEvent = null
|
|
344
361
|
calendarFetchedAt = 0
|
|
362
|
+
cachedCalendarRead = false
|
|
345
363
|
weatherPersistence = null
|
|
346
364
|
weatherRestored = false
|
|
347
365
|
lastFailureLogAt.clear()
|
|
@@ -354,13 +372,18 @@ welcomeContextRouter.get('/welcome-context', async (req, res) => {
|
|
|
354
372
|
const pairLat = lat != null && lon != null ? lat : undefined
|
|
355
373
|
const pairLon = lat != null && lon != null ? lon : undefined
|
|
356
374
|
|
|
357
|
-
const [weather,
|
|
375
|
+
const [weather, calendar] = await Promise.all([
|
|
358
376
|
fetchWeather(pairLat, pairLon),
|
|
359
377
|
fetchNextEvent(),
|
|
360
378
|
])
|
|
379
|
+
const nextEvent = calendar.event
|
|
361
380
|
|
|
362
381
|
res.json({
|
|
363
382
|
...(weather ? { weather } : {}),
|
|
364
383
|
...(nextEvent ? { nextEvent } : {}),
|
|
384
|
+
// 6.84.1: true only when this answer's calendar came from a successful read (an empty
|
|
385
|
+
// calendar included); false when the read threw, timed out or there is no COS pipeline.
|
|
386
|
+
// Lets a client tell "no more events today" from "could not read the calendar".
|
|
387
|
+
calendarRead: calendar.read,
|
|
365
388
|
})
|
|
366
389
|
})
|