@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +5 -0
  4. package/server/lib/ask-session-id.ts +5 -0
  5. package/server/lib/decision-provider.ts +163 -0
  6. package/server/lib/decision-settings.ts +72 -0
  7. package/server/lib/diarizer-backend.ts +24 -2
  8. package/server/lib/dictation-benchmark.ts +197 -0
  9. package/server/lib/dictation-stt.ts +61 -2
  10. package/server/lib/glossary-learn.ts +221 -0
  11. package/server/lib/imported-library-rows.ts +62 -4
  12. package/server/lib/jev.ts +3 -3
  13. package/server/lib/lens-gist-engines.ts +7 -2
  14. package/server/lib/live-cos-ask.ts +2 -1
  15. package/server/lib/mac-recording-asr.ts +59 -0
  16. package/server/lib/mac-recording.ts +111 -0
  17. package/server/lib/meeting-actions.ts +1 -0
  18. package/server/lib/meeting-ask-follow-up.ts +2 -2
  19. package/server/lib/meeting-ask-runner.ts +26 -5
  20. package/server/lib/meeting-ask-store.ts +2 -1
  21. package/server/lib/meeting-batch-transcribe.ts +19 -4
  22. package/server/lib/meeting-context-keys.ts +7 -4
  23. package/server/lib/meeting-parse.ts +2 -0
  24. package/server/lib/meeting-store.ts +62 -9
  25. package/server/lib/parakeet-cli-trust.ts +262 -0
  26. package/server/lib/parakeet.ts +156 -29
  27. package/server/lib/session-recommendation.ts +12 -9
  28. package/server/lib/speaker-embeddings.ts +2 -2
  29. package/server/lib/stranded-session-actions.ts +15 -0
  30. package/server/lib/stt-runtime.ts +43 -11
  31. package/server/lib/stt-settings.ts +88 -3
  32. package/server/lib/subq-key.ts +38 -0
  33. package/server/lib/transcribe-audio.ts +87 -7
  34. package/server/lib/work-activity.ts +2 -2
  35. package/server/lib/work-chat.ts +113 -13
  36. package/server/lib/work-completion.ts +11 -8
  37. package/server/lib/work-intake-store.ts +2 -1
  38. package/server/lib/work-search.ts +32 -12
  39. package/server/routes/client-instance.ts +1 -1
  40. package/server/routes/decision-provider.ts +36 -0
  41. package/server/routes/glossary.ts +143 -11
  42. package/server/routes/jev.ts +3 -2
  43. package/server/routes/meeting.ts +44 -12
  44. package/server/routes/meetings.ts +42 -4
  45. package/server/routes/stt-settings.ts +55 -4
  46. package/server/routes/subq-key.ts +27 -0
  47. package/server/routes/transcribe-stream.ts +115 -9
  48. package/server/routes/transcribe.ts +74 -6
  49. package/server/routes/work-chat.ts +10 -4
  50. 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 can never
537
- * disagree about how many meetings a day holds.
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, operationsMergedSessions(directories, options.declaredSessions), options.recordingsRoot),
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
- export function buildClaudeGistArgs(model: string, system: string): string[] {
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', 'low',
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' || !/^meeting_[A-Za-z0-9_-]{1,100}$/.test(v.sessionId ?? '') || !ASK_ACTIONS.includes(v.action)) throw new AskError('invalid_request')
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(3)),
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)