@gotcos/glasses-server 6.73.5 → 6.74.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 +83 -0
- package/package.json +1 -1
- package/server/index.ts +5 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -0
- package/server/lib/diarizer-backend.ts +24 -2
- package/server/lib/dictation-benchmark.ts +197 -0
- package/server/lib/dictation-stt.ts +61 -2
- package/server/lib/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +87 -7
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +113 -13
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +74 -6
- package/server/routes/work-chat.ts +10 -4
- package/server/routes/work-search.ts +2 -2
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
import { transcribeParakeet, ParakeetError } from './parakeet.js'
|
|
16
16
|
import { asrFrontEnd } from './stt-live.js'
|
|
17
17
|
import {
|
|
18
|
-
currentSttCapabilities, ensurePreviewWarm, noteParakeetDictationHit, noteParakeetDictationMiss,
|
|
18
|
+
currentSttCapabilities, ensurePreviewWarm, installCliKnown, noteParakeetDictationHit, noteParakeetDictationMiss,
|
|
19
19
|
PARAKEET_PREVIEW_TIMEOUT_MS, PREVIEW_PARAKEET_MODEL,
|
|
20
20
|
} from './stt-runtime.js'
|
|
21
|
+
import { getWhisperHealth } from './whisper-local.js'
|
|
22
|
+
import { parakeetInstallRunning } from './parakeet.js'
|
|
21
23
|
import {
|
|
22
|
-
dictationPlan, readSttSettings, type DictationPlan, type DictationSlot, type FrontEndId,
|
|
24
|
+
desktopDictationPlan, dictationPlan, readSttSettings, type DesktopDictationPlan, type DictationPlan, type DictationSlot, type FrontEndId,
|
|
23
25
|
} from './stt-settings.js'
|
|
24
26
|
|
|
25
27
|
/** The plan for a dictation decode starting now. A Parakeet selection that is
|
|
@@ -37,6 +39,63 @@ export function currentDictationPlan(slot: DictationSlot): DictationPlan {
|
|
|
37
39
|
return plan
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** 6.74.0: the plan for a Mac dictation decode starting now (its own setting, not the
|
|
43
|
+
* Preview model). A Parakeet that is installed but cold starts warming here. */
|
|
44
|
+
export function currentDesktopDictationPlan(slot: DictationSlot): DesktopDictationPlan {
|
|
45
|
+
try {
|
|
46
|
+
const plan = desktopDictationPlan(slot, readSttSettings(), currentSttCapabilities())
|
|
47
|
+
if (plan.reason === 'parakeet_warming' || plan.reason === 'parakeet_warm_failed') ensurePreviewWarm()
|
|
48
|
+
return plan
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.warn(`[stt] Mac dictation settings unreadable, using Whisper: ${error instanceof Error ? error.message : error}`)
|
|
51
|
+
return { slot, engine: 'whisper', reason: null, frontEnd: 'highpass', key: slot === 'final' ? 'final:large-v3' : `${slot}:turbo`, setting: 'auto', whisperAllowed: true }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The one action a Mac dictation failure offers (see the contract). */
|
|
56
|
+
export type DesktopDictationAction = 'install_parakeet' | 'use_whisper' | 'retry_when_warm' | 'open_transcription_settings'
|
|
57
|
+
export interface DesktopFailureHint { action: DesktopDictationAction; retryable: boolean; detail: string }
|
|
58
|
+
export interface DesktopFailureFacts {
|
|
59
|
+
/** no_local_engine: nothing local could run. parakeet_unavailable: the setting is parakeet-ultra. */
|
|
60
|
+
kind: 'no_local_engine' | 'parakeet_unavailable'
|
|
61
|
+
/** The Parakeet reason code (plan or decode), when there is one. */
|
|
62
|
+
parakeetReason: string | null
|
|
63
|
+
parakeetInstallable: boolean
|
|
64
|
+
whisperStarting: boolean
|
|
65
|
+
whisperPresent: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pure. Retry is offered only when waiting can make it succeed. */
|
|
69
|
+
export function desktopFailureHint(f: DesktopFailureFacts): DesktopFailureHint {
|
|
70
|
+
const waiting = f.parakeetReason === 'parakeet_warming' || f.parakeetReason === 'parakeet_busy'
|
|
71
|
+
if (f.kind === 'parakeet_unavailable') {
|
|
72
|
+
const detail = f.parakeetReason ?? 'parakeet_failed'
|
|
73
|
+
if (detail === 'parakeet_not_installed') return { action: f.parakeetInstallable ? 'install_parakeet' : 'use_whisper', retryable: false, detail }
|
|
74
|
+
if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
|
|
75
|
+
return { action: 'use_whisper', retryable: false, detail }
|
|
76
|
+
}
|
|
77
|
+
if (f.whisperStarting) return { action: 'retry_when_warm', retryable: true, detail: 'whisper_starting' }
|
|
78
|
+
const detail = f.whisperPresent ? 'whisper_unavailable' : 'whisper_missing'
|
|
79
|
+
if (waiting) return { action: 'retry_when_warm', retryable: true, detail }
|
|
80
|
+
if (f.parakeetReason === 'parakeet_not_installed' && f.parakeetInstallable) return { action: 'install_parakeet', retryable: false, detail }
|
|
81
|
+
return { action: 'open_transcription_settings', retryable: false, detail }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Production facts for desktopFailureHint. Never throws. */
|
|
85
|
+
export function currentDesktopFailureHint(kind: DesktopFailureFacts['kind'], parakeetReason: string | null): DesktopFailureHint {
|
|
86
|
+
let whisperStarting = false, whisperPresent = true, parakeetInstallable = false
|
|
87
|
+
try {
|
|
88
|
+
const h = getWhisperHealth()
|
|
89
|
+
whisperStarting = h.restarting || h.startupState === 'preflight' || h.startupState === 'loading'
|
|
90
|
+
whisperPresent = h.serverConfigured
|
|
91
|
+
} catch { /* unknown: treat as present and not starting */ }
|
|
92
|
+
try {
|
|
93
|
+
const installing = parakeetInstallRunning()
|
|
94
|
+
parakeetInstallable = installing || (process.platform === 'darwin' && process.arch === 'arm64' && installCliKnown().known === true)
|
|
95
|
+
} catch { /* not installable */ }
|
|
96
|
+
return desktopFailureHint({ kind, parakeetReason, parakeetInstallable, whisperStarting, whisperPresent })
|
|
97
|
+
}
|
|
98
|
+
|
|
40
99
|
/** Parakeet reads 16 kHz mono PCM16 WAV. The browser recorder posts WebM, which stays on Whisper. */
|
|
41
100
|
export function isPcm16kMonoWav(audio: Buffer): boolean {
|
|
42
101
|
if (audio.length < 44 || audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE') return false
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// One-word dictionary learning (6.73.11). "Fix a word" in COS Control, and any
|
|
2
|
+
// other surface, teaches ONE correction at a time: heard → written joins the
|
|
3
|
+
// corrections map and `written` joins the vocabulary, in one write. Every learn
|
|
4
|
+
// is logged (bounded, newest first) so the person can see what COS learned, from
|
|
5
|
+
// where, and undo exactly that one entry.
|
|
6
|
+
//
|
|
7
|
+
// Pure: no IO. The route in routes/glossary.ts reads the profile and the log,
|
|
8
|
+
// calls these, and writes both back inside the glossary write chain.
|
|
9
|
+
|
|
10
|
+
export const MAX_TERMS = 500
|
|
11
|
+
export const MAX_TERM_LEN = 100
|
|
12
|
+
export const MAX_LOG = 200
|
|
13
|
+
export const CORRECTION_SOURCES = ['dictation', 'meeting', 'g2', 'manual'] as const
|
|
14
|
+
export type CorrectionSource = typeof CORRECTION_SOURCES[number]
|
|
15
|
+
|
|
16
|
+
export interface GlossaryWords {
|
|
17
|
+
vocabulary: string[]
|
|
18
|
+
corrections: Record<string, string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CorrectionLogEntry {
|
|
22
|
+
id: string
|
|
23
|
+
heard: string
|
|
24
|
+
written: string
|
|
25
|
+
source: CorrectionSource
|
|
26
|
+
at: string
|
|
27
|
+
/** The existing key this entry replaced (a case variant of `heard`, or `heard` itself), or null. */
|
|
28
|
+
replacedKey: string | null
|
|
29
|
+
/** What `replacedKey` mapped to before this entry, or null. */
|
|
30
|
+
previous: string | null
|
|
31
|
+
/** True when this entry put `written` into the vocabulary. */
|
|
32
|
+
addedVocabulary: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type LearnResult =
|
|
36
|
+
| { ok: true; unchanged: true }
|
|
37
|
+
| { ok: true; unchanged: false; words: GlossaryWords; entry: CorrectionLogEntry }
|
|
38
|
+
| { ok: false; status: number; error: string }
|
|
39
|
+
|
|
40
|
+
export type UndoResult =
|
|
41
|
+
| { ok: true; words: GlossaryWords; log: CorrectionLogEntry[]; entry: CorrectionLogEntry }
|
|
42
|
+
| { ok: false; status: number; error: string }
|
|
43
|
+
|
|
44
|
+
// Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
|
|
45
|
+
// there induce ".com"/handle hallucinations on quiet audio, so they are refused.
|
|
46
|
+
export function looksLikeUrlEmailPath(s: string): boolean {
|
|
47
|
+
return /(?:https?:\/\/|www\.)/i.test(s)
|
|
48
|
+
|| /@[\w.-]+\.\w/.test(s)
|
|
49
|
+
|| /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
|
|
50
|
+
|| /[/\\]/.test(s)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
54
|
+
const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
55
|
+
|
|
56
|
+
export function isCorrectionSource(value: unknown): value is CorrectionSource {
|
|
57
|
+
return typeof value === 'string' && (CORRECTION_SOURCES as readonly string[]).includes(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function learnCorrection(
|
|
61
|
+
words: GlossaryWords,
|
|
62
|
+
input: { heard: unknown; written: unknown; source: unknown },
|
|
63
|
+
stamp: { id: string; at: string },
|
|
64
|
+
): LearnResult {
|
|
65
|
+
if (typeof input.heard !== 'string' || typeof input.written !== 'string') {
|
|
66
|
+
return { ok: false, status: 400, error: 'heard and written must be strings' }
|
|
67
|
+
}
|
|
68
|
+
if (!isCorrectionSource(input.source)) {
|
|
69
|
+
return { ok: false, status: 400, error: `source must be one of ${CORRECTION_SOURCES.join(', ')}` }
|
|
70
|
+
}
|
|
71
|
+
const heard = input.heard.trim()
|
|
72
|
+
const written = input.written.trim()
|
|
73
|
+
if (!heard || !written) return { ok: false, status: 400, error: 'heard and written must not be empty' }
|
|
74
|
+
if (heard.length > MAX_TERM_LEN || written.length > MAX_TERM_LEN) {
|
|
75
|
+
return { ok: false, status: 400, error: `a correction is at most ${MAX_TERM_LEN} characters` }
|
|
76
|
+
}
|
|
77
|
+
if (CONTROL.test(heard) || CONTROL.test(written)) {
|
|
78
|
+
return { ok: false, status: 400, error: 'a correction cannot contain line breaks or control characters' }
|
|
79
|
+
}
|
|
80
|
+
if (heard === written) return { ok: false, status: 400, error: 'the written spelling is the same as what was heard' }
|
|
81
|
+
if (looksLikeUrlEmailPath(written)) {
|
|
82
|
+
return { ok: false, status: 400, error: 'the written spelling cannot be a URL, email or path; it would join the decoder prompt' }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const existingKey = Object.keys(words.corrections).find(key => same(key, heard)) ?? null
|
|
86
|
+
const correctionChanged = existingKey !== heard || words.corrections[heard] !== written
|
|
87
|
+
const inVocabulary = words.vocabulary.some(term => same(term, written))
|
|
88
|
+
if (!correctionChanged && inVocabulary) return { ok: true, unchanged: true }
|
|
89
|
+
|
|
90
|
+
if (existingKey === null && Object.keys(words.corrections).length >= MAX_TERMS) {
|
|
91
|
+
return { ok: false, status: 409, error: `the dictionary already holds ${MAX_TERMS} corrections; remove one first` }
|
|
92
|
+
}
|
|
93
|
+
if (!inVocabulary && words.vocabulary.length >= MAX_TERMS) {
|
|
94
|
+
return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const corrections = { ...words.corrections }
|
|
98
|
+
if (existingKey !== null && existingKey !== heard) delete corrections[existingKey]
|
|
99
|
+
corrections[heard] = written
|
|
100
|
+
const vocabulary = inVocabulary ? [...words.vocabulary] : [...words.vocabulary, written]
|
|
101
|
+
const entry: CorrectionLogEntry = {
|
|
102
|
+
id: stamp.id,
|
|
103
|
+
heard,
|
|
104
|
+
written,
|
|
105
|
+
source: input.source,
|
|
106
|
+
at: stamp.at,
|
|
107
|
+
replacedKey: existingKey,
|
|
108
|
+
previous: existingKey === null ? null : words.corrections[existingKey],
|
|
109
|
+
addedVocabulary: !inVocabulary,
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, unchanged: false, words: { vocabulary, corrections }, entry }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Newest first, bounded. */
|
|
115
|
+
export function appendLog(log: CorrectionLogEntry[], entry: CorrectionLogEntry): CorrectionLogEntry[] {
|
|
116
|
+
return [entry, ...log].slice(0, MAX_LOG)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Revert exactly one logged entry, and only while the dictionary still says
|
|
121
|
+
* what that entry wrote. A later entry for the same word, or a hand edit since,
|
|
122
|
+
* makes the undo refuse rather than clobber the newer choice.
|
|
123
|
+
*/
|
|
124
|
+
export function undoCorrection(words: GlossaryWords, log: CorrectionLogEntry[], id: unknown): UndoResult {
|
|
125
|
+
if (typeof id !== 'string' || !id) return { ok: false, status: 400, error: 'id is required' }
|
|
126
|
+
const index = log.findIndex(entry => entry.id === id)
|
|
127
|
+
if (index < 0) return { ok: false, status: 404, error: 'that correction is no longer in the recent list' }
|
|
128
|
+
const entry = log[index]
|
|
129
|
+
if (log.slice(0, index).some(newer => same(newer.heard, entry.heard))) {
|
|
130
|
+
return { ok: false, status: 409, error: `a newer correction changed "${entry.heard}"; undo that one first` }
|
|
131
|
+
}
|
|
132
|
+
if (words.corrections[entry.heard] !== entry.written) {
|
|
133
|
+
return { ok: false, status: 409, error: `"${entry.heard}" was changed since; edit it in the dictionary instead` }
|
|
134
|
+
}
|
|
135
|
+
const corrections = { ...words.corrections }
|
|
136
|
+
delete corrections[entry.heard]
|
|
137
|
+
if (entry.replacedKey !== null && entry.previous !== null) {
|
|
138
|
+
const collision = Object.keys(corrections).some(key => same(key, entry.replacedKey!))
|
|
139
|
+
if (collision) return { ok: false, status: 409, error: `"${entry.replacedKey}" was changed since; edit it in the dictionary instead` }
|
|
140
|
+
corrections[entry.replacedKey] = entry.previous
|
|
141
|
+
}
|
|
142
|
+
let vocabulary = [...words.vocabulary]
|
|
143
|
+
// The spelling stays while any remaining correction still writes it.
|
|
144
|
+
if (entry.addedVocabulary && !Object.values(corrections).some(value => same(value, entry.written))) {
|
|
145
|
+
vocabulary = vocabulary.filter(term => !same(term, entry.written))
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, words: { vocabulary, corrections }, log: log.filter((_, i) => i !== index), entry }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tolerant reader: a missing or corrupt log is an empty one; malformed rows are dropped. */
|
|
151
|
+
export function parseLog(raw: unknown): CorrectionLogEntry[] {
|
|
152
|
+
const entries = raw && typeof raw === 'object' && Array.isArray((raw as { entries?: unknown }).entries)
|
|
153
|
+
? (raw as { entries: unknown[] }).entries : []
|
|
154
|
+
const out: CorrectionLogEntry[] = []
|
|
155
|
+
for (const value of entries) {
|
|
156
|
+
if (!value || typeof value !== 'object') continue
|
|
157
|
+
const e = value as Record<string, unknown>
|
|
158
|
+
if (typeof e.id !== 'string' || typeof e.heard !== 'string' || typeof e.written !== 'string'
|
|
159
|
+
|| !isCorrectionSource(e.source) || typeof e.at !== 'string' || typeof e.addedVocabulary !== 'boolean') continue
|
|
160
|
+
const replacedKey = typeof e.replacedKey === 'string' ? e.replacedKey : null
|
|
161
|
+
const previous = typeof e.previous === 'string' ? e.previous : null
|
|
162
|
+
out.push({ id: e.id, heard: e.heard, written: e.written, source: e.source, at: e.at, replacedKey, previous, addedVocabulary: e.addedVocabulary })
|
|
163
|
+
if (out.length >= MAX_LOG) break
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Single-item edits (6.73.12) ──────────────────────────────────────────────
|
|
169
|
+
// COS Control's Dictionary pane adds or removes ONE word or correction. Each is a
|
|
170
|
+
// pure step over the stored words, run inside the glossary write chain, so a
|
|
171
|
+
// concurrent learn can never be lost the way a client read-then-PUT could lose it.
|
|
172
|
+
// These are hand edits; they are not logged for undo.
|
|
173
|
+
|
|
174
|
+
export type EditResult =
|
|
175
|
+
| { ok: true; unchanged: true }
|
|
176
|
+
| { ok: true; unchanged: false; words: GlossaryWords }
|
|
177
|
+
| { ok: false; status: number; error: string }
|
|
178
|
+
|
|
179
|
+
function validTerm(raw: unknown, what: string): { ok: true; value: string } | { ok: false; status: number; error: string } {
|
|
180
|
+
if (typeof raw !== 'string') return { ok: false, status: 400, error: `${what} must be a string` }
|
|
181
|
+
const value = raw.trim()
|
|
182
|
+
if (!value) return { ok: false, status: 400, error: `${what} must not be empty` }
|
|
183
|
+
if (value.length > MAX_TERM_LEN) return { ok: false, status: 400, error: `${what} is at most ${MAX_TERM_LEN} characters` }
|
|
184
|
+
if (CONTROL.test(value)) return { ok: false, status: 400, error: `${what} cannot contain line breaks or control characters` }
|
|
185
|
+
return { ok: true, value }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function addVocabulary(words: GlossaryWords, raw: unknown): EditResult {
|
|
189
|
+
const term = validTerm(raw, 'term')
|
|
190
|
+
if (!term.ok) return term
|
|
191
|
+
if (looksLikeUrlEmailPath(term.value)) {
|
|
192
|
+
return { ok: false, status: 400, error: 'a vocabulary word cannot be a URL, email or path; it would join the decoder prompt' }
|
|
193
|
+
}
|
|
194
|
+
if (words.vocabulary.some(existing => same(existing, term.value))) return { ok: true, unchanged: true }
|
|
195
|
+
if (words.vocabulary.length >= MAX_TERMS) {
|
|
196
|
+
return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, unchanged: false, words: { vocabulary: [...words.vocabulary, term.value], corrections: { ...words.corrections } } }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Removes the exact word; a word that is not there is not an error. */
|
|
202
|
+
export function removeVocabulary(words: GlossaryWords, raw: unknown): EditResult {
|
|
203
|
+
const term = validTerm(raw, 'term')
|
|
204
|
+
if (!term.ok) return term
|
|
205
|
+
const vocabulary = words.vocabulary.filter(existing => existing.trim() !== term.value)
|
|
206
|
+
if (vocabulary.length === words.vocabulary.length) return { ok: true, unchanged: true }
|
|
207
|
+
return { ok: true, unchanged: false, words: { vocabulary, corrections: { ...words.corrections } } }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Removes the correction for `heard` (its exact key, else its one case variant). */
|
|
211
|
+
export function removeCorrection(words: GlossaryWords, raw: unknown): EditResult {
|
|
212
|
+
const heard = validTerm(raw, 'heard')
|
|
213
|
+
if (!heard.ok) return heard
|
|
214
|
+
const key = heard.value in words.corrections
|
|
215
|
+
? heard.value
|
|
216
|
+
: Object.keys(words.corrections).find(existing => same(existing, heard.value))
|
|
217
|
+
if (key === undefined) return { ok: true, unchanged: true }
|
|
218
|
+
const corrections = { ...words.corrections }
|
|
219
|
+
delete corrections[key]
|
|
220
|
+
return { ok: true, unchanged: false, words: { vocabulary: [...words.vocabulary], corrections } }
|
|
221
|
+
}
|
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
importHash,
|
|
49
49
|
importRecordId,
|
|
50
50
|
} from './imported-meeting-library.js'
|
|
51
|
-
import { getMeetingStore, type MeetingMeta, type MeetingStore } from './meeting-store.js'
|
|
51
|
+
import { getMeetingStore, MEETING_LIST_CAP_SCOPED, type MeetingMeta, type MeetingStore } from './meeting-store.js'
|
|
52
|
+
import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
52
53
|
|
|
53
54
|
/** The merged-scribe markers live with the scribe reader; re-exported for callers. */
|
|
54
55
|
export {
|
|
@@ -531,10 +532,63 @@ function operationsMergedSessions(
|
|
|
531
532
|
return sessions
|
|
532
533
|
}
|
|
533
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Sessions the operations rows of these month folders hold: each scribe's sidecar session (beside it, else under the
|
|
537
|
+
* server's recordings root by the same name, as `listCosOperationsMeetings` reads it), plus every session a merged
|
|
538
|
+
* scribe declares. A store Mac row whose session is here is already a row, and a dot, on the operations side.
|
|
539
|
+
*/
|
|
540
|
+
function operationsHeldSessions(
|
|
541
|
+
directories: readonly string[],
|
|
542
|
+
merged: ReadonlySet<string>,
|
|
543
|
+
recordingsRoot: string = dataPath('recordings'),
|
|
544
|
+
): Set<string> {
|
|
545
|
+
const sessions = new Set(merged)
|
|
546
|
+
for (const directory of directories) {
|
|
547
|
+
let names: string[]
|
|
548
|
+
try {
|
|
549
|
+
names = readdirSync(directory).filter(name => name.endsWith('.md'))
|
|
550
|
+
} catch {
|
|
551
|
+
continue
|
|
552
|
+
}
|
|
553
|
+
for (const filename of names) {
|
|
554
|
+
const sessionId = droppedSessionId(directory, filename, recordingsRoot)
|
|
555
|
+
if (sessionId) sessions.add(sessionId)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return sessions
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 6.73.14: day counts for the store's Mac recordings. A Mac capture never reaches the operations tree, so on a pipeline
|
|
563
|
+
* Mac these rows are counted nowhere else. A session the operations side already holds (`held`, read only when the store
|
|
564
|
+
* has a Mac recording in this month) is left out, as `withMacStoreRows` leaves its row out, so an operations copy counts
|
|
565
|
+
* once. Read through `list()`, filtered before its cap (MEETING_LIST_CAP_SCOPED, 200 rows): a month with more than 200
|
|
566
|
+
* such recordings undercounts its dots; nothing is counted twice.
|
|
567
|
+
*/
|
|
568
|
+
export function macStoreDayCounts(
|
|
569
|
+
store: MeetingStore,
|
|
570
|
+
month: string,
|
|
571
|
+
domain: string,
|
|
572
|
+
held: () => ReadonlySet<string> = () => new Set(),
|
|
573
|
+
): Array<{ date: string; count: number }> {
|
|
574
|
+
const counts = new Map<string, number>()
|
|
575
|
+
let heldSessions: ReadonlySet<string> | undefined
|
|
576
|
+
const sessionMatch = (id: string | undefined) => typeof id === 'string' && MAC_CAPTURE_ID.test(id) && !(heldSessions ??= held()).has(id)
|
|
577
|
+
for (const row of store.list({ month, domain, limit: MEETING_LIST_CAP_SCOPED, sessionMatch })) {
|
|
578
|
+
// The day from the filename, as the store's own day dots read it (MeetingStore.listDayCounts).
|
|
579
|
+
const date = /^(\d{4}-\d{2}-\d{2})_/.exec(row.filename)?.[1]
|
|
580
|
+
if (!row.sessionId || !MAC_CAPTURE_ID.test(row.sessionId) || !date) continue
|
|
581
|
+
counts.set(date, (counts.get(date) ?? 0) + 1)
|
|
582
|
+
}
|
|
583
|
+
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, count]) => ({ date, count }))
|
|
584
|
+
}
|
|
585
|
+
|
|
534
586
|
/**
|
|
535
587
|
* Day counts for one month across every source the list shows, with supersession
|
|
536
|
-
* applied. The list and `probeMeetings` share it so a dot and a row
|
|
537
|
-
*
|
|
588
|
+
* applied. The list and `probeMeetings` share it so a dot and a row agree about
|
|
589
|
+
* how many meetings a day holds: each source is counted by the rule that lists
|
|
590
|
+
* it. The counts are not capped the way a row page is, except the store's Mac
|
|
591
|
+
* recordings, which stop at 200 a month (macStoreDayCounts).
|
|
538
592
|
*/
|
|
539
593
|
export function supersededDayCounts(
|
|
540
594
|
month: string,
|
|
@@ -568,9 +622,13 @@ export function supersededDayCounts(
|
|
|
568
622
|
// into this layout at all: on a pipeline Mac the operations tree is the one
|
|
569
623
|
// library, and a derived record here would be a second row for one meeting.
|
|
570
624
|
const directories = operationsMonthDirs(month, domain)
|
|
625
|
+
const merged = operationsMergedSessions(directories, options.declaredSessions)
|
|
571
626
|
return mergeDayCounts([
|
|
572
627
|
listCosOperationsMeetingDays(month, domain),
|
|
573
|
-
sessionDropCounts(directories,
|
|
628
|
+
sessionDropCounts(directories, merged, options.recordingsRoot),
|
|
629
|
+
// 6.73.14: the store's Mac recordings, which the list now shows (routes/meetings.ts withMacStoreRows), less any the
|
|
630
|
+
// operations side already counts.
|
|
631
|
+
macStoreDayCounts(store, month, domain, () => operationsHeldSessions(directories, merged, options.recordingsRoot)),
|
|
574
632
|
])
|
|
575
633
|
}
|
|
576
634
|
|
package/server/lib/jev.ts
CHANGED
|
@@ -81,7 +81,7 @@ function dailyCap(): number {
|
|
|
81
81
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : JEV_LIMITS.defaultDailyTokens
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export interface JevAnswers { answers: Record<string, { choice?: string; probabilities?: Record<string, number>; noul?: number }>; model?: string; inputTokens: number }
|
|
84
|
+
export interface JevAnswers { provider?: 'jev' | 'subq'; answers: Record<string, { type?: 'choice' | 'noul' | 'score'; choice?: string; probabilities?: Record<string, number>; noul?: number; confidence?: number; score?: number; legend?: Record<string, unknown> }>; model?: string; inputTokens: number }
|
|
85
85
|
|
|
86
86
|
/** One Jev client per process: it owns the breaker and the usage ledger. */
|
|
87
87
|
export class JevClient {
|
|
@@ -128,14 +128,14 @@ export class JevClient {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/** Ask one request. `estimate` is the caller's input-token estimate, checked against the cap BEFORE sending. */
|
|
131
|
-
async ask(state: unknown, questions: Record<string, unknown>, estimate: number): Promise<JevAnswers> {
|
|
131
|
+
async ask(state: unknown, questions: Record<string, unknown>, estimate: number, options?: { signal?: AbortSignal }): Promise<JevAnswers> {
|
|
132
132
|
const key = resolveJevKey()
|
|
133
133
|
if (!key) throw new JevError('jev_not_configured')
|
|
134
134
|
if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
|
|
135
135
|
if (this.usedToday() + estimate > this.cap()) this.fail('jev_cap_reached', false)
|
|
136
136
|
let res: Response
|
|
137
137
|
try {
|
|
138
|
-
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
|
|
138
|
+
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.any([AbortSignal.timeout(JEV_LIMITS.timeoutMs), ...(options?.signal ? [options.signal] : [])]),
|
|
139
139
|
headers: { Authorization: `Bearer ${key.key}`, 'Content-Type': 'application/json' },
|
|
140
140
|
body: JSON.stringify({ state, model: JEV_MODEL, questions }) })
|
|
141
141
|
} catch { return this.fail('jev_unavailable', true) }
|
|
@@ -200,9 +200,14 @@ function lastJsonObject(stdout: string): any {
|
|
|
200
200
|
|
|
201
201
|
// --- Claude ---------------------------------------------------------------------------
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
/** `claude -p --effort` levels COS passes (6.73.12). The CLI also takes xhigh and max; COS does not use them. */
|
|
204
|
+
export const CLAUDE_GIST_EFFORTS = ['low', 'medium', 'high'] as const
|
|
205
|
+
export type ClaudeGistEffort = typeof CLAUDE_GIST_EFFORTS[number]
|
|
206
|
+
|
|
207
|
+
/** `effort` defaults to low, so the lens gist and Meeting Ask argv stay exactly as before 6.73.12. */
|
|
208
|
+
export function buildClaudeGistArgs(model: string, system: string, effort: ClaudeGistEffort = 'low'): string[] {
|
|
204
209
|
return [
|
|
205
|
-
'-p', '--model', model, '--effort',
|
|
210
|
+
'-p', '--model', model, '--effort', effort,
|
|
206
211
|
// No tools: nothing to act on an instruction hidden in the reply, and ~1.2k tokens a
|
|
207
212
|
// call instead of a cache of tool definitions. claude-permissions.ts records an empty
|
|
208
213
|
// --tools causing a spurious compaction and a synthetic 400 on the 2026-08-26 CLI, in
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// On-demand meeting assistance. No poll, background summary, chat persistence or write tools.
|
|
2
|
+
import { MAC_ASK_SESSION } from './ask-session-id.js'
|
|
2
3
|
import { connectedEvidence } from './meeting-ask-evidence.js'
|
|
3
4
|
import { REVIEW_SYSTEM, normalizeReviewConfidence, reviewRepairDiagnostics, reviewProblem, unverifiedReview, isFactCheckCTA, pageWasRead } from './meeting-ask-review.js'
|
|
4
5
|
import { followupContext } from './meeting-ask-followup.js'
|
|
@@ -35,7 +36,7 @@ const TASKS: Record<AskAction, string> = {
|
|
|
35
36
|
}
|
|
36
37
|
export function normalizeAsk(value: unknown): AskInput {
|
|
37
38
|
const v = value as any
|
|
38
|
-
if (!v || typeof v !== 'object' || typeof v.sessionId !== 'string' ||
|
|
39
|
+
if (!v || typeof v !== 'object' || typeof v.sessionId !== 'string' || !(/^meeting_[A-Za-z0-9_-]{1,100}$/.test(v.sessionId ?? '') || MAC_ASK_SESSION.test(v.sessionId ?? '')) || !ASK_ACTIONS.includes(v.action)) throw new AskError('invalid_request')
|
|
39
40
|
if (typeof v.question !== 'string' || v.question.length > 2000 || (v.action === 'ask' && !v.question.trim())) throw new AskError('invalid_question')
|
|
40
41
|
if (!Number.isFinite(v.elapsedMs) || v.elapsedMs < 0 || v.elapsedMs > 7 * 86400_000) throw new AskError('invalid_time')
|
|
41
42
|
if (!Array.isArray(v.turns) || v.turns.length < 1 || v.turns.length > 6000) throw new AskError('invalid_transcript')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Manual Mac canonical chunks follow the saved-meeting model choice. The
|
|
2
|
+
// normal preview resolver may fall back to Turbo; canonical Mac audio must not.
|
|
3
|
+
import { ParakeetError, transcribeParakeet, type ParakeetFailure } from './parakeet.js'
|
|
4
|
+
import type { WhisperWord } from './whisper-local.js'
|
|
5
|
+
|
|
6
|
+
export const MAC_PARAKEET_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
export class MacParakeetUnavailableError extends Error {
|
|
9
|
+
readonly status = 503
|
|
10
|
+
readonly retryable = true
|
|
11
|
+
readonly model = 'parakeet-ultra'
|
|
12
|
+
readonly reason: `mac_parakeet_${ParakeetFailure}`
|
|
13
|
+
|
|
14
|
+
constructor(cause: unknown) {
|
|
15
|
+
const reason = cause instanceof ParakeetError ? cause.reason : 'cli_failed'
|
|
16
|
+
super(`Selected Parakeet Ultra could not transcribe this Mac chunk (${reason}); audio is preserved for retry`)
|
|
17
|
+
this.name = 'MacParakeetUnavailableError'
|
|
18
|
+
this.reason = `mac_parakeet_${reason}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Interactive admission is bounded and never queues behind saved HQ work.
|
|
23
|
+
* The shared worker verifies the pinned installation and offline sandbox.
|
|
24
|
+
* Cold compilation may use this deadline; no Whisper/cloud fallback is allowed. */
|
|
25
|
+
export async function transcribeMacParakeet(audio: Buffer, speechSeconds: number | null): Promise<{
|
|
26
|
+
text: string
|
|
27
|
+
words: WhisperWord[]
|
|
28
|
+
model: 'parakeet-ultra'
|
|
29
|
+
backend: 'fluidaudio-coreml'
|
|
30
|
+
asrProvider: 'server-parakeet'
|
|
31
|
+
latencyMs: number
|
|
32
|
+
}> {
|
|
33
|
+
try {
|
|
34
|
+
const result = await transcribeParakeet(audio, {
|
|
35
|
+
model: 'parakeet-ultra',
|
|
36
|
+
lane: 'interactive',
|
|
37
|
+
timeoutMs: MAC_PARAKEET_TIMEOUT_MS,
|
|
38
|
+
})
|
|
39
|
+
if (result.model !== 'parakeet-ultra') {
|
|
40
|
+
throw new ParakeetError('bad_output', 'Parakeet did not return the selected model')
|
|
41
|
+
}
|
|
42
|
+
// 6.73.8: no words over detected speech (laughter, music, crosstalk) is an answer, not a failure. As a retryable
|
|
43
|
+
// 503 it replayed the same audio forever and froze every later chunk of a live meeting (2026-10-15, chunk 51).
|
|
44
|
+
// The saved meeting re-transcribes the full audio with strict Parakeet, so these words are not lost.
|
|
45
|
+
if (!result.text.trim() && speechSeconds !== null && speechSeconds >= 0.5) {
|
|
46
|
+
console.warn(`[mac-asr] Parakeet returned no words over ${speechSeconds.toFixed(2)}s of detected speech; recorded empty`)
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
text: result.text,
|
|
50
|
+
words: result.words.map(word => ({ ...word, probability: word.probability ?? 0 })),
|
|
51
|
+
model: result.model,
|
|
52
|
+
backend: 'fluidaudio-coreml',
|
|
53
|
+
asrProvider: 'server-parakeet',
|
|
54
|
+
latencyMs: result.wallMs,
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new MacParakeetUnavailableError(error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Manual, remote-playback-only Mac capture. This registry is independent of
|
|
2
|
+
* live transcription state so provenance and the strict policy survive save,
|
|
3
|
+
* quarantine, finalization and recovery. No capture or model is started here. */
|
|
4
|
+
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { dataPath } from './data-dir.js'
|
|
7
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
8
|
+
import { readSpeakerPolicy, writeSpeakerPolicy } from './meeting-speaker-policy.js'
|
|
9
|
+
|
|
10
|
+
export const MAC_SOURCE_TYPE = 'mac_system_audio' as const
|
|
11
|
+
export const MAC_CAPTURE_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
12
|
+
export const MAC_AUDIO = { sourceType: MAC_SOURCE_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16, maxChunkMs: 30000, maxChunkBytes: 960044, speakerPolicy: 'high-confidence' as const }
|
|
13
|
+
export interface MacRecording { version: 1; captureId: string; sessionId: string; sourceType: typeof MAC_SOURCE_TYPE; startTime: number; speakerPolicy: 'high-confidence'; closedAt?: number }
|
|
14
|
+
export function macError(status: number, reason: string, message: string): Error & { status: number; reason: string } {
|
|
15
|
+
return Object.assign(new Error(message), { status, reason })
|
|
16
|
+
}
|
|
17
|
+
function registryPath(id: string): string {
|
|
18
|
+
if (!MAC_CAPTURE_ID.test(id)) throw macError(400, 'invalid_capture_id', 'Invalid Mac capture ID')
|
|
19
|
+
return join(dataPath('mac-recordings'), id + '.json')
|
|
20
|
+
}
|
|
21
|
+
export function readMacRecording(id: string): MacRecording | null {
|
|
22
|
+
if (!MAC_CAPTURE_ID.test(id)) return null
|
|
23
|
+
try {
|
|
24
|
+
const path = registryPath(id)
|
|
25
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error('symbolic link')
|
|
26
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd)
|
|
29
|
+
if (!stat.isFile() || stat.size > 4096) throw new Error('invalid registry file')
|
|
30
|
+
const value = JSON.parse(readFileSync(fd, 'utf8'))
|
|
31
|
+
if (value.version !== 1 || value.captureId !== id || value.sessionId !== id || value.sourceType !== MAC_SOURCE_TYPE || value.speakerPolicy !== 'high-confidence' || !Number.isSafeInteger(value.startTime) || value.startTime <= 0) throw new Error('invalid registry metadata')
|
|
32
|
+
return value
|
|
33
|
+
} finally { closeSync(fd) }
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
36
|
+
throw macError(409, 'mac_capture_metadata_unreadable', 'Mac capture metadata is unreadable; recording was not admitted')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function registerMacRecording(input: unknown): { recording: MacRecording; replayed: boolean } {
|
|
40
|
+
const body = input as Partial<MacRecording> | null
|
|
41
|
+
if (!body || typeof body.captureId !== 'string') throw macError(400, 'invalid_capture_id', 'Mac capture ID required')
|
|
42
|
+
const path = registryPath(body.captureId)
|
|
43
|
+
if (body.sourceType !== MAC_SOURCE_TYPE || !Number.isSafeInteger(body.startTime) || Number(body.startTime) <= 0 || Number(body.startTime) > Date.now() + 300000) throw macError(400, 'invalid_mac_capture', 'Source type and valid capture start time required')
|
|
44
|
+
const prior = readMacRecording(body.captureId)
|
|
45
|
+
if (prior) {
|
|
46
|
+
if (prior.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
|
|
47
|
+
if (prior.startTime !== body.startTime) throw macError(409, 'mac_capture_conflict', 'Capture ID already belongs to another start time')
|
|
48
|
+
if (readSpeakerPolicy(body.captureId).mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_changed', 'Mac capture requires high-confidence identity policy')
|
|
49
|
+
return { recording: prior, replayed: true }
|
|
50
|
+
}
|
|
51
|
+
const policy = readSpeakerPolicy(body.captureId)
|
|
52
|
+
// A policy written before a crash can be reused, but never overwrite another policy.
|
|
53
|
+
if (policy.revision !== 'unset' && policy.mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_conflict', 'Capture ID has an incompatible identity policy')
|
|
54
|
+
if (policy.revision === 'unset') writeSpeakerPolicy(body.captureId, 'high-confidence', policy.revision)
|
|
55
|
+
const recording: MacRecording = { version: 1, captureId: body.captureId, sessionId: body.captureId, sourceType: MAC_SOURCE_TYPE, startTime: Number(body.startTime), speakerPolicy: 'high-confidence' }
|
|
56
|
+
mkdirSync(dataPath('mac-recordings'), { recursive: true, mode: 0o700 })
|
|
57
|
+
// Registry is the admission commit marker; strict policy is durable first.
|
|
58
|
+
durableAtomicWriteFileSync(path, JSON.stringify(recording), { mode: 0o600 })
|
|
59
|
+
return { recording, replayed: false }
|
|
60
|
+
}
|
|
61
|
+
export function requireMacRecording(id: string): MacRecording {
|
|
62
|
+
registryPath(id)
|
|
63
|
+
const value = readMacRecording(id)
|
|
64
|
+
if (value?.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
|
|
65
|
+
if (!value) throw macError(409, 'mac_capture_not_started', 'Start this Mac capture before sending audio')
|
|
66
|
+
if (readSpeakerPolicy(id).mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_changed', 'Mac capture requires high-confidence identity policy')
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
/** Native bridge intentionally sends one canonical WAV format, never raw HAL buffers. */
|
|
70
|
+
export function validateMacWav(audio: Buffer): number {
|
|
71
|
+
if (audio.length < 46 || audio.length > MAC_AUDIO.maxChunkBytes || audio.length % 2 !== 0 ||
|
|
72
|
+
audio.toString('ascii', 0, 4) !== 'RIFF' || audio.readUInt32LE(4) !== audio.length - 8 ||
|
|
73
|
+
audio.toString('ascii', 8, 12) !== 'WAVE' || audio.toString('ascii', 12, 16) !== 'fmt ' || audio.readUInt32LE(16) !== 16 ||
|
|
74
|
+
audio.readUInt16LE(20) !== 1 || audio.readUInt16LE(22) !== 1 || audio.readUInt32LE(24) !== 16000 || audio.readUInt32LE(28) !== 32000 || audio.readUInt16LE(32) !== 2 || audio.readUInt16LE(34) !== 16 ||
|
|
75
|
+
audio.toString('ascii', 36, 40) !== 'data' || audio.readUInt32LE(40) !== audio.length - 44) {
|
|
76
|
+
throw macError(400, 'invalid_mac_audio', 'Expected a bounded canonical 16-kHz mono PCM16 WAV')
|
|
77
|
+
}
|
|
78
|
+
return (audio.length - 44) / 32
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function closeMacRecording(id: string): void {
|
|
82
|
+
const value = readMacRecording(id)
|
|
83
|
+
if (value && !value.closedAt) durableAtomicWriteFileSync(registryPath(id), JSON.stringify({ ...value, closedAt: Date.now() }), { mode: 0o600 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MacChunkReceipt { chunkIndex: number; endElapsed: number; durationMs: number; audioSha256: string }
|
|
87
|
+
export function macChunkReceipt(id: string, index: number): MacChunkReceipt | null {
|
|
88
|
+
registryPath(id)
|
|
89
|
+
if (!Number.isInteger(index) || index < 0 || index > 9999) throw macError(400, 'invalid_chunk_index', 'Invalid chunk index')
|
|
90
|
+
const path = join(dataPath('mac-recording-chunks'), id, index + '.json')
|
|
91
|
+
try {
|
|
92
|
+
const stat = lstatSync(path)
|
|
93
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw new Error('invalid receipt')
|
|
94
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
95
|
+
if (value.chunkIndex !== index || !Number.isFinite(value.endElapsed) || !Number.isFinite(value.durationMs) || value.durationMs <= 0 || value.durationMs > MAC_AUDIO.maxChunkMs || value.endElapsed < value.durationMs || !/^[0-9a-f]{64}$/.test(value.audioSha256)) throw new Error('invalid receipt')
|
|
96
|
+
return value
|
|
97
|
+
} catch(error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
99
|
+
throw macError(409, 'mac_chunk_metadata_unreadable', 'Mac chunk receipt is unreadable')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function persistMacChunkReceipt(id: string, value: MacChunkReceipt): void {
|
|
103
|
+
const prior = macChunkReceipt(id, value.chunkIndex)
|
|
104
|
+
if (prior) {
|
|
105
|
+
if (prior.audioSha256 !== value.audioSha256 || prior.endElapsed !== value.endElapsed || prior.durationMs !== value.durationMs) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio or timing')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const dir = join(dataPath('mac-recording-chunks'), id)
|
|
109
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
110
|
+
durableAtomicWriteFileSync(join(dir, value.chunkIndex + '.json'), JSON.stringify(value), { mode: 0o600 })
|
|
111
|
+
}
|
|
@@ -282,6 +282,7 @@ export function toEngineSentences(rows: unknown): FirefliesSentenceInput[] {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
function g2FromSidecar(doc: Record<string, unknown>, sessionId: string): G2RecordingInput | null {
|
|
285
|
+
if (doc.sourceType === 'mac_system_audio') return null // Mac pilot never enters the G2-only blend engine.
|
|
285
286
|
const startMs = numberOr(doc.startTime, Number.NaN)
|
|
286
287
|
const durationMs = numberOr(doc.durationMs, Number.NaN)
|
|
287
288
|
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) return null
|
|
@@ -35,8 +35,8 @@ export async function saveAskFollowUp(raw: unknown, deps: FollowUpDependencies):
|
|
|
35
35
|
try { meeting = await deps.resolveMeeting(descriptor) } catch { throw new WorkIntakeError('meeting_unavailable', 409) }
|
|
36
36
|
if (meeting.recordId !== descriptor.recordId) throw new WorkIntakeError('meeting_identity_changed', 409)
|
|
37
37
|
const captures = new Set([meeting.sessionId, meeting.sourceSessionId, ...(meeting.g2SessionIds ?? []),
|
|
38
|
-
...(meeting.contextKeys ?? []).filter(k => k.startsWith('g2:')).map(k => k.slice(
|
|
39
|
-
...(meeting.sources ?? []).filter(s => s.kind === 'g2').map(s => s.id)])
|
|
38
|
+
...(meeting.contextKeys ?? []).filter(k => k.startsWith('g2:') || k.startsWith('mac:')).map(k => k.slice(k.indexOf(':') + 1)),
|
|
39
|
+
...(meeting.sources ?? []).filter(s => s.kind === 'g2' || s.kind === 'mac').map(s => s.id)])
|
|
40
40
|
if (!captures.has(exchange.sessionId) || meeting.contextAmbiguous) throw new WorkIntakeError('meeting_capture_mismatch', 409)
|
|
41
41
|
const ids = body.sourceIds === undefined ? exchange.sources.slice(0, 8).map(s => s.id) : body.sourceIds
|
|
42
42
|
if (!Array.isArray(ids) || ids.length > 8 || ids.some(id => typeof id !== 'string' || !exchange.sources.some(s => s.id === id))) throw new WorkIntakeError('invalid_follow_up_source', 400)
|