@gotcos/glasses-server 6.73.5 → 6.73.14

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 (42) hide show
  1. package/CHANGELOG.md +62 -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/glossary-learn.ts +221 -0
  8. package/server/lib/imported-library-rows.ts +62 -4
  9. package/server/lib/jev.ts +3 -3
  10. package/server/lib/lens-gist-engines.ts +7 -2
  11. package/server/lib/live-cos-ask.ts +2 -1
  12. package/server/lib/mac-recording-asr.ts +59 -0
  13. package/server/lib/mac-recording.ts +111 -0
  14. package/server/lib/meeting-actions.ts +1 -0
  15. package/server/lib/meeting-ask-follow-up.ts +2 -2
  16. package/server/lib/meeting-ask-runner.ts +26 -5
  17. package/server/lib/meeting-ask-store.ts +2 -1
  18. package/server/lib/meeting-batch-transcribe.ts +19 -4
  19. package/server/lib/meeting-context-keys.ts +7 -4
  20. package/server/lib/meeting-parse.ts +2 -0
  21. package/server/lib/meeting-store.ts +62 -9
  22. package/server/lib/session-recommendation.ts +12 -9
  23. package/server/lib/speaker-embeddings.ts +2 -2
  24. package/server/lib/stranded-session-actions.ts +15 -0
  25. package/server/lib/subq-key.ts +38 -0
  26. package/server/lib/transcribe-audio.ts +9 -1
  27. package/server/lib/work-activity.ts +2 -2
  28. package/server/lib/work-chat.ts +87 -11
  29. package/server/lib/work-completion.ts +11 -8
  30. package/server/lib/work-intake-store.ts +2 -1
  31. package/server/lib/work-search.ts +32 -12
  32. package/server/routes/client-instance.ts +1 -1
  33. package/server/routes/decision-provider.ts +36 -0
  34. package/server/routes/glossary.ts +143 -11
  35. package/server/routes/jev.ts +3 -2
  36. package/server/routes/meeting.ts +44 -12
  37. package/server/routes/meetings.ts +42 -4
  38. package/server/routes/subq-key.ts +27 -0
  39. package/server/routes/transcribe-stream.ts +115 -9
  40. package/server/routes/transcribe.ts +20 -2
  41. package/server/routes/work-chat.ts +8 -4
  42. package/server/routes/work-search.ts +2 -2
@@ -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)
@@ -4,9 +4,28 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { resolveProviderBinary } from './provider-binary.js';
7
- import { buildClaudeGistArgs, buildCodexGistArgs, parseClaudeGistOutput, parseCodexGistOutput, lensGistChildEnv } from './lens-gist-engines.js';
7
+ import { buildClaudeGistArgs, buildCodexGistArgs, parseClaudeGistOutput, parseCodexGistOutput, lensGistChildEnv, type ClaudeGistEffort } from './lens-gist-engines.js';
8
8
  import { logTokenAudit } from './token-audit.js';
9
9
  import type { MeetingAskModel } from '../../shared/meeting-ask.js';
10
+ /** 6.73.12: how one caller's run is bounded. Omitted fields keep Meeting Ask's budget, unchanged since before 6.73.12. */
11
+ export interface MeetingAskRunBudget {
12
+ /** Claude `--effort`. Omitted keeps the builder's low. */
13
+ effort?: ClaudeGistEffort;
14
+ /** Answer tokens, excluding thinking. The child's CLAUDE_CODE_MAX_OUTPUT_TOKENS is this plus maxThinkingTokens,
15
+ * because the model's max_tokens counts thinking too. */
16
+ maxOutputTokens?: number;
17
+ /** MAX_THINKING_TOKENS for the child. 0 turns thinking off. */
18
+ maxThinkingTokens?: number;
19
+ /** Wall-clock limit before the child is stopped with provider_timeout. */
20
+ deadlineMs?: number;
21
+ }
22
+ export const MEETING_ASK_DEFAULT_BUDGET = { maxOutputTokens: 1400, maxThinkingTokens: 0, deadlineMs: 45000 } as const;
23
+ /** The child environment's token limits for a budget; the default is exactly '1400' and '0'. */
24
+ export function meetingAskTokenEnv(budget?: MeetingAskRunBudget): { CLAUDE_CODE_MAX_OUTPUT_TOKENS: string; MAX_THINKING_TOKENS: string } {
25
+ const output = budget?.maxOutputTokens ?? MEETING_ASK_DEFAULT_BUDGET.maxOutputTokens;
26
+ const thinking = budget?.maxThinkingTokens ?? MEETING_ASK_DEFAULT_BUDGET.maxThinkingTokens;
27
+ return { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(output + thinking), MAX_THINKING_TOKENS: String(thinking) };
28
+ }
10
29
  export class AskProcessError extends Error {
11
30
  constructor(message: string, readonly terminationUnconfirmed = false) { super(message); }
12
31
  }
@@ -17,7 +36,9 @@ export function runMeetingAskModel(model: MeetingAskModel, prompt: string, syste
17
36
  }, audit?: {
18
37
  /** Token-audit caller label (6.71.13), e.g. meeting_fact_check_planner_hedge. A failed call appends ":<reason>". */
19
38
  caller: string;
20
- }): Promise<{
39
+ },
40
+ /** 6.73.12: effort, token budget and deadline. Omitted keeps Meeting Ask's, so every older caller is unchanged. */
41
+ budget?: MeetingAskRunBudget): Promise<{
21
42
  text: string;
22
43
  model?: string;
23
44
  }> {
@@ -30,11 +51,11 @@ export function runMeetingAskModel(model: MeetingAskModel, prompt: string, syste
30
51
  if (!binary.ok)
31
52
  return Promise.reject(new AskProcessError('provider_unavailable'));
32
53
  const workspace = adapter?.workspace ?? mkdtempSync(join(tmpdir(), 'cos-meeting-ask-'));
33
- const args = adapter?.args ?? (provider === 'claude' ? buildClaudeGistArgs(model.model, system) : buildCodexGistArgs(model.model, workspace));
54
+ const args = adapter?.args ?? (provider === 'claude' ? buildClaudeGistArgs(model.model, system, budget?.effort) : buildCodexGistArgs(model.model, workspace));
34
55
  // Codex has no system-prompt flag here: trusted instruction is separate, then JSON evidence.
35
56
  const stdin = adapter?.stdin ?? (provider === 'claude' ? prompt : `${system}\n\n${prompt}`);
36
57
  return new Promise((resolve, reject) => {
37
- const child = spawn(binary.path, args, { cwd: workspace, detached: true, stdio: ['pipe', 'pipe', 'pipe'], env: { ...lensGistChildEnv(binary.path), CLAUDE_CODE_MAX_OUTPUT_TOKENS: '1400', MAX_THINKING_TOKENS: '0' } });
58
+ const child = spawn(binary.path, args, { cwd: workspace, detached: true, stdio: ['pipe', 'pipe', 'pipe'], env: { ...lensGistChildEnv(binary.path), ...meetingAskTokenEnv(budget) } });
38
59
  let stdout = '', failure: string | null = null, done = false, closeSeen = false, code: number | null = null;
39
60
  let killTimer: NodeJS.Timeout | undefined, proofTimer: NodeJS.Timeout | undefined;
40
61
  const kill = (sig: NodeJS.Signals) => { if (child.pid)
@@ -115,7 +136,7 @@ export function runMeetingAskModel(model: MeetingAskModel, prompt: string, syste
115
136
  finish(true); }, 50);
116
137
  };
117
138
  const abort = () => stop('cancelled');
118
- const deadline = setTimeout(() => stop('provider_timeout'), 45000);
139
+ const deadline = setTimeout(() => stop('provider_timeout'), budget?.deadlineMs ?? MEETING_ASK_DEFAULT_BUDGET.deadlineMs);
119
140
  signal?.addEventListener('abort', abort, { once: true });
120
141
  if (signal?.aborted)
121
142
  abort();
@@ -8,7 +8,8 @@ import { emptyOrientation } from './meeting-ask-orientation.js';
8
8
  import type { MeetingAskOrientation } from '../../shared/meeting-ask.js';
9
9
  import { AskError } from './live-cos-ask.js';
10
10
  export const ASK_ID = /^[A-Za-z0-9_-]{8,100}$/;
11
- export const ASK_SESSION = /^meeting_[A-Za-z0-9_-]{1,89}$/;
11
+ export { ASK_SESSION } from './ask-session-id.js';
12
+ import { ASK_SESSION } from './ask-session-id.js';
12
13
  export interface AskRecord {
13
14
  version: 2;
14
15
  fingerprint: string;
@@ -20,6 +20,8 @@ import { measureSpeechSeconds } from './vad-silero.js'
20
20
  import { batchTimestampsEnabled } from './whisper-batch-timing.js'
21
21
  import { readSttSettings, recordLastSttJob, type FrontEndId, type HqId, type SecondOpinionId } from './stt-settings.js'
22
22
  import { currentEffectiveStt } from './stt-runtime.js'
23
+ import { MAC_CAPTURE_ID } from './mac-recording.js'
24
+ import { MacParakeetUnavailableError } from './mac-recording-asr.js'
23
25
  import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
24
26
  import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
25
27
  import { getVocabulary } from './profile.js'
@@ -450,9 +452,15 @@ export interface HqJobPlan {
450
452
  secondOpinion: SecondOpinionId
451
453
  resolver: boolean
452
454
  frontEnd: FrontEndId
455
+ /** Mac canonical Parakeet must never silently substitute Whisper. */
456
+ strictPrimary?: true
453
457
  }
454
458
 
455
- export function currentHqJobPlan(): HqJobPlan {
459
+ export function currentHqJobPlan(sessionId?: string): HqJobPlan {
460
+ const selected = readSttSettings()
461
+ if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
462
+ return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
463
+ }
456
464
  const eff = currentEffectiveStt()
457
465
  return { engine: eff.hq.engine, secondOpinion: eff.secondOpinion.engine, resolver: eff.resolver, frontEnd: eff.frontEnd }
458
466
  }
@@ -668,6 +676,7 @@ async function decodeHqWindow(
668
676
  try {
669
677
  primary = await parakeetGuarded(job, () => decodeParakeetWindow(deps, input, parakeetTimeout(job), 'parakeet-ultra', true))
670
678
  } catch (error) {
679
+ if (plan.strictPrimary) throw new MacParakeetUnavailableError(error)
671
680
  fallbackReason = `parakeet_failed: ${errorReason(error)}`
672
681
  console.warn(`[meeting-batch] Parakeet HQ failed for ${segment.startChunkIdx}-${segment.endChunkIdx}; using Large-v3: ${errorReason(error)}`)
673
682
  const w = await decodeWhisperWindow(deps, input, previousText, 'large-v3', segment, audioDir)
@@ -766,6 +775,7 @@ export async function transcribeSegments(
766
775
  plan: HqJobPlan,
767
776
  deps: HqDecodeDeps = defaultHqDeps,
768
777
  ): Promise<BatchResult[]> {
778
+ if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
769
779
  const results: BatchResult[] = []
770
780
  const meetingId = basename(audioDir)
771
781
  const job: JobState = { parakeetRan: false, parakeetFailures: 0, parakeetStopped: false }
@@ -809,6 +819,9 @@ export async function transcribeSegments(
809
819
  meetingId,
810
820
  })
811
821
  } catch (error) {
822
+ // One failed strict Mac window invalidates the HQ candidate; preserve the
823
+ // already canonical Parakeet chunks, rather than accepting partial text.
824
+ if (plan.strictPrimary) throw error
812
825
  console.error(
813
826
  `[meeting-batch] Segment ${segment.startChunkIdx}-${segment.endChunkIdx} failed: `
814
827
  + `${error instanceof Error ? error.message : String(error)}`,
@@ -831,9 +844,10 @@ export function recoveryHqJob(
831
844
  segments: BatchSegment[],
832
845
  entries: IndexedTranscriptChunk[],
833
846
  deps: HqDecodeDeps = defaultHqDeps,
847
+ sessionId?: string,
834
848
  ): { plan: HqJobPlan; run: () => Promise<BatchResult[]> } {
835
- const plan = currentHqJobPlan()
836
- return { plan, run: () => transcribeSegments(quarantineDir, segments, entries, undefined, plan, deps) }
849
+ const plan = currentHqJobPlan(sessionId)
850
+ return { plan, run: () => transcribeSegments(quarantineDir, segments, entries, sessionId, plan, deps) }
837
851
  }
838
852
 
839
853
  export function summarizeStt(plan: HqJobPlan, results: readonly BatchResult[], segmentsPlanned = results.length): BatchSttSummary {
@@ -1229,7 +1243,7 @@ export function runMeetingBatchPipeline(
1229
1243
  lease.unref()
1230
1244
  // Frozen at SAVE time: a settings change applies to the next saved meeting,
1231
1245
  // never to one already queued behind another HQ job.
1232
- const plan = currentHqJobPlan()
1246
+ const plan = currentHqJobPlan(sessionId)
1233
1247
  const job = batchQueueTail.then(() => runMeetingBatchPipelineNow(
1234
1248
  audioDir,
1235
1249
  entries,
@@ -1304,6 +1318,7 @@ async function runMeetingBatchPipelineNow(
1304
1318
  outcome: 'failed',
1305
1319
  reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
1306
1320
  })
1321
+ if (plan.strictPrimary) throw error
1307
1322
  return { transcriptionQuality: 'streaming' }
1308
1323
  }
1309
1324
  }
@@ -1,3 +1,4 @@
1
+ import { readMacRecording } from './mac-recording.js'
1
2
  /**
2
3
  * Meeting context keys (6.64.0): the ids COS Control files a meeting's dropped files under.
3
4
  *
@@ -10,6 +11,7 @@
10
11
  * decisions, Work review and speaker review depend on. So the key is read from the meeting's own source ids:
11
12
  * - `g2:<sessionId>` from its `.g2-chunks.json` sidecar (the speaker system's key; moves with every G2 rename);
12
13
  * - `ff:<id>` from its `.fireflies.json` sidecar;
14
+ * - `mac:<captureId>` from an admitted Mac source and its saved sourceType;
13
15
  * - `g2:<id>` for every `<!-- g2-session -->` the file declares, which is how a merged meeting also shows the files of
14
16
  * the capture it absorbed.
15
17
  * Measured 2026-10-06: 649 of 656 meetings from August to October carry at least one.
@@ -23,7 +25,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpat
23
25
  import { dirname, join } from 'node:path'
24
26
 
25
27
  /** A key as Control stores it. The id part is the server's own marker grammar (`G2_SESSION_MARKER`). */
26
- export const MEETING_CONTEXT_KEY = /^(?:g2|ff):[A-Za-z0-9:_-]{3,96}$/
28
+ export const MEETING_CONTEXT_KEY = /^(?:g2|ff|mac):[A-Za-z0-9:_-]{3,96}$/
27
29
  const SOURCE_ID = /^[A-Za-z0-9:_-]{3,96}$/
28
30
 
29
31
  const SIDECAR_HEAD_BYTES = 4096
@@ -96,11 +98,12 @@ export function firefliesSidecarId(monthDir: string, meetingFilename: string): s
96
98
 
97
99
  /** The ordered, de-duplicated keys for one meeting. The first is primary: the capture's own sidecar, then Fireflies,
98
100
  * then what the file declares. */
99
- export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[] }): string[] {
101
+ export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[]; sourceType?: string }): string[] {
100
102
  const keys: string[] = []
101
- const add = (prefix: 'g2' | 'ff', id: string | undefined) => {
103
+ const add = (prefix: 'g2' | 'ff' | 'mac', id: string | undefined) => {
102
104
  if (!id || !SOURCE_ID.test(id)) return
103
- const key = `${prefix}:${id}`
105
+ const actualPrefix = prefix === 'g2' && (sources.sourceType === 'mac_system_audio' || readMacRecording(id)) ? 'mac' : prefix
106
+ const key = `${actualPrefix}:${id}`
104
107
  if (!keys.includes(key)) keys.push(key)
105
108
  }
106
109
  add('g2', sources.g2SessionId)
@@ -134,6 +134,7 @@ export function parseMeeting(content: string, filename: string, month: string):
134
134
  // codebase, disagreeing with each other.
135
135
  domainAbbr: domainAbbreviation(domain),
136
136
  source: parseField(content, 'Source'),
137
+ ...(parseField(content, 'Capture source') === 'mac_system_audio' ? { sourceType: 'mac_system_audio' as const } : {}),
137
138
  duration,
138
139
  ...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
139
140
  month,
@@ -171,6 +172,7 @@ export function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
171
172
  domain: detail.domain,
172
173
  domainAbbr: detail.domainAbbr,
173
174
  source: detail.source,
175
+ ...(detail.sourceType ? { sourceType: detail.sourceType } : {}),
174
176
  duration: detail.duration,
175
177
  ...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
176
178
  month: detail.month,