@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
@@ -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,
@@ -1,3 +1,4 @@
1
+ import { MAC_SOURCE_TYPE, readMacRecording, closeMacRecording } from './mac-recording.js'
1
2
  import {
2
3
  chmodSync,
3
4
  existsSync,
@@ -33,6 +34,8 @@ const DAY_FILE_PREFIX = /^(\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))_/
33
34
  const DOMAIN_HEAD_BYTES = 4096
34
35
  const LIST_CAP = 50
35
36
  const LIST_CAP_SCOPED = 200
37
+ /** The most rows a month- or day-scoped list returns. Exported so a caller that asks for "all of them" names the cap it gets. */
38
+ export const MEETING_LIST_CAP_SCOPED = LIST_CAP_SCOPED
36
39
 
37
40
  export function meetingListLimit(limit: number | undefined, scoped: boolean): number {
38
41
  const raw = typeof limit === 'number' && Number.isFinite(limit) ? Math.trunc(limit) : 20
@@ -92,6 +95,7 @@ export interface MeetingMeta {
92
95
  domain: string
93
96
  domainAbbr: string
94
97
  source: string
98
+ sourceType?: 'mac_system_audio'
95
99
  duration: string
96
100
  durationMinutes?: number
97
101
  month: string
@@ -133,7 +137,7 @@ export interface MeetingMeta {
133
137
  /** 0-based position of a split piece inside its source recording. */
134
138
  pieceIndex?: number
135
139
  /** Inputs of a derived record, for the detail view's source links. */
136
- sources?: Array<{ kind: 'g2' | 'fireflies'; id: string; recordId?: string }>
140
+ sources?: Array<{ kind: 'g2' | 'fireflies' | 'mac'; id: string; recordId?: string }>
137
141
  /** Present only when the server can state a truthful local record. */
138
142
  canonicalRecord?: string
139
143
  /** 6.64.0, additive and read-only. The keys COS Control files a meeting's dropped files under, from source ids the
@@ -192,6 +196,8 @@ export interface SaveMeetingInput {
192
196
  }
193
197
 
194
198
  export interface SavedMeeting {
199
+ captureId?: string
200
+ sourceType?: typeof MAC_SOURCE_TYPE
195
201
  filepath: string
196
202
  sidecarPath: string
197
203
  filename: string
@@ -255,6 +261,36 @@ function filenameStem(title: string, fallback: string): string {
255
261
  return safe || fallback
256
262
  }
257
263
 
264
+ function clockTime(hourText: string, minuteText: string, meridiemText = ''): string | undefined {
265
+ let hour = Number(hourText)
266
+ const minute = Number(minuteText)
267
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute > 59) return undefined
268
+ const meridiem = meridiemText.toUpperCase()
269
+ if (meridiem) {
270
+ if (hour < 1 || hour > 12) return undefined
271
+ if (meridiem === 'AM' && hour === 12) hour = 0
272
+ if (meridiem === 'PM' && hour !== 12) hour += 12
273
+ } else if (hour > 23) {
274
+ return undefined
275
+ }
276
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
277
+ }
278
+
279
+ /**
280
+ * 6.73.14: a stored recording's start as "HH:MM" (24 h), the form operations rows carry (cos-operations-meetings
281
+ * extractMeetingDateTime), so a merged list orders a store row by its time and not at 00:00 of its day. Save writes
282
+ * `**Time** | 10:39 PM`; an older or hand-made record may put the time on the Date line instead; a fallback title
283
+ * leaves `_YYYY-MM-DD_HHMM` in the filename.
284
+ */
285
+ export function storedMeetingTime(content: string, filename: string): string | undefined {
286
+ const field = parseField(content, 'Time').match(/^(\d{1,2}):(\d{2})(?:\s*([AP]M))?$/i)
287
+ if (field) return clockTime(field[1], field[2], field[3])
288
+ const dateLine = parseField(content, 'Date').match(/^\d{4}-\d{2}-\d{2}[ T]+(\d{1,2}):(\d{2})(?:\s*([AP]M))?$/i)
289
+ if (dateLine) return clockTime(dateLine[1], dateLine[2], dateLine[3])
290
+ const named = filename.match(/_\d{4}-\d{2}-\d{2}_(\d{2})(\d{2})(?:\D|$)/)
291
+ return named ? clockTime(named[1], named[2]) : undefined
292
+ }
293
+
258
294
  function localParts(timestamp: number): {
259
295
  date: string
260
296
  month: string
@@ -312,10 +348,11 @@ export class MeetingStore {
312
348
  .trim()
313
349
  if (!transcript) throw new MeetingStoreError('Transcript is empty', 400, 'empty_transcript')
314
350
 
351
+ const mac = readMacRecording(sessionId)
315
352
  const parts = localParts(input.startTime)
316
- const fallbackTitle = `G2 Recording ${parts.date} ${parts.hourMinute}`
353
+ const fallbackTitle = `${mac ? 'Mac' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
317
354
  const title = normalizeTitle(input.title, fallbackTitle)
318
- const stem = filenameStem(title, `G2_Recording_${parts.hourMinute}`)
355
+ const stem = filenameStem(title, `${mac ? 'Mac' : 'G2'}_Recording_${parts.hourMinute}`)
319
356
  const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
320
357
  const durationMin = Math.round(durationMs / 60_000)
321
358
 
@@ -354,7 +391,8 @@ export class MeetingStore {
354
391
  `| **Date** | ${parts.date} |`,
355
392
  `| **Time** | ${parts.time} |`,
356
393
  `| **Duration** | ${durationMin} minutes |`,
357
- '| **Source** | G2 Glasses |',
394
+ mac ? '| **Source** | Mac System Audio |' : '| **Source** | G2 Glasses |',
395
+ ...(mac ? ['| **Capture source** | mac_system_audio |', '| **Captured voices** | Other participants only |'] : []),
358
396
  `| **Domain** | ${escapeTableValue(domain)} |`,
359
397
  `| **Transfer integrity** | ${integrityValue} |`,
360
398
  '| **Transcription quality** | streaming |',
@@ -364,7 +402,7 @@ export class MeetingStore {
364
402
  // When COS ops is configured, use the private-app pipeline markers so
365
403
  // sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
366
404
  // "Standalone recording" summaries are treated as already-final and skipped.
367
- ...(process.env.COS_SCRIPTS_DIR
405
+ ...(process.env.COS_SCRIPTS_DIR && !mac
368
406
  ? [
369
407
  '<!-- g2-needs-domain-review -->',
370
408
  '',
@@ -381,6 +419,7 @@ export class MeetingStore {
381
419
  const providers = [...new Set(input.chunks.map(chunk => chunk.asrProvider || 'server-whisper'))]
382
420
  const sidecar = {
383
421
  schemaVersion: 2,
422
+ ...(mac ? { sourceType: MAC_SOURCE_TYPE, captureSource: mac } : {}),
384
423
  sessionId,
385
424
  startTime: input.startTime,
386
425
  durationMs,
@@ -416,7 +455,9 @@ export class MeetingStore {
416
455
  throw error
417
456
  }
418
457
 
458
+ if (mac) closeMacRecording(sessionId)
419
459
  return {
460
+ ...(mac ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
420
461
  filepath,
421
462
  sidecarPath,
422
463
  filename,
@@ -455,6 +496,7 @@ export class MeetingStore {
455
496
  ? Math.max(0, sidecar.durationMs)
456
497
  : 0
457
498
  return {
499
+ ...(sidecar.sourceType === MAC_SOURCE_TYPE ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
458
500
  filepath: join(monthDir, filename),
459
501
  sidecarPath: join(monthDir, sidecarName),
460
502
  filename,
@@ -472,7 +514,12 @@ export class MeetingStore {
472
514
  return null
473
515
  }
474
516
 
475
- list(options: { limit?: number; domain?: string; month?: string; day?: string } = {}): MeetingMeta[] {
517
+ /**
518
+ * `sessionMatch` filters BEFORE the cap (6.73.14: Mac rows were cut by the 200-row cap of every store row), and before
519
+ * the markdown is read: the session comes from the sidecar's 4 KB head, so a list of Mac rows opens no G2 recording.
520
+ * A `day` with no `month` reads only that day's month folder; save files every recording under its own date's month.
521
+ */
522
+ list(options: { limit?: number; domain?: string; month?: string; day?: string; sessionMatch?: (sessionId: string | undefined) => boolean } = {}): MeetingMeta[] {
476
523
  const scoped = Boolean(options.month || options.day)
477
524
  const limit = meetingListLimit(options.limit, scoped)
478
525
  const domain = options.domain ?? 'all'
@@ -488,27 +535,33 @@ export class MeetingStore {
488
535
  const rootReal = this.rootRealpath()
489
536
  if (!rootReal) return []
490
537
  const meetings: MeetingMeta[] = []
538
+ const onlyMonth = options.month ?? options.day?.slice(0, 7)
491
539
 
492
540
  for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
493
- if (options.month && month !== options.month) continue
541
+ if (onlyMonth && month !== onlyMonth) continue
494
542
  const monthDir = join(this.root, month)
495
543
  const monthReal = safeDirectoryRealpath(monthDir, rootReal)
496
544
  if (!monthReal) continue
497
545
  for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name)).sort().reverse()) {
498
546
  try {
547
+ // Matched first when asked: the sidecar head is the cheap read, the markdown the expensive one.
548
+ const matched = options.sessionMatch ? this.sidecarSessionId(monthDir, monthReal, filename) : undefined
549
+ if (options.sessionMatch && !options.sessionMatch(matched)) continue
499
550
  const content = safeReadFile(monthDir, monthReal, filename)
500
551
  if (content === null) continue
501
552
  const detail = parseMeeting(content, filename, month)
502
553
  if (domain !== 'all' && detail.domain !== domain) continue
503
554
  if (options.day && detail.date !== options.day) continue
504
- meetings.push(toMeta(detail, this.sidecarSessionId(monthDir, monthReal, filename)))
555
+ const sessionId = options.sessionMatch ? matched : this.sidecarSessionId(monthDir, monthReal, filename)
556
+ const time = storedMeetingTime(content, filename)
557
+ meetings.push({ ...toMeta(detail, sessionId), ...(time ? { time } : {}) })
505
558
  } catch {
506
559
  // One unreadable/corrupt entry must not hide the rest of the store.
507
560
  }
508
561
  }
509
562
  }
510
563
  meetings.sort((left, right) => (
511
- right.date.localeCompare(left.date) || right.filename.localeCompare(left.filename)
564
+ right.date.localeCompare(left.date) || (right.time ?? '').localeCompare(left.time ?? '') || right.filename.localeCompare(left.filename)
512
565
  ))
513
566
  return meetings.slice(0, limit)
514
567
  }
@@ -0,0 +1,262 @@
1
+ // 6.74.0: trust for a fluidaudiocli that COS Control ships signed (its Contents/Helpers).
2
+ //
3
+ // FluidAudio publishes no binary for v0.17.4, so COS Control builds it from the pinned
4
+ // source tag and signs it with the COS Developer ID. That build cannot match the SHA-256
5
+ // pin (parakeet-manifest.ts) because the signature is part of the bytes, so it is trusted
6
+ // on three proofs instead, all required:
7
+ //
8
+ // 1. `codesign --verify --strict` passes, and passes again against the requirement
9
+ // that the leaf certificate chains to Apple and names the COS team (a self-signed
10
+ // certificate can claim any team name; `anchor apple generic` rules that out).
11
+ // 2. The TeamIdentifier codesign reports is COS_TEAM_ID.
12
+ // 3. The codesign Identifier, and the CFBundleIdentifier of the Info.plist embedded in
13
+ // the binary, are both CLI_IDENTIFIER.
14
+ // 4. That embedded Info.plist is bound by the signature (`Info.plist entries=`) and its
15
+ // CFBundleShortVersionString is 0.17.4. The upstream 0.17.4 CLI has no version
16
+ // command, so the binary is never run to ask.
17
+ //
18
+ // The hand-placed SHA-256 pinned copy (parakeet.ts isPinnedCli) is unchanged.
19
+
20
+ import { execFile } from 'node:child_process'
21
+ import { createHash } from 'node:crypto'
22
+ import { constants as fsConstants, createReadStream, fstatSync, openSync, readSync, closeSync, statSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { basename, join } from 'node:path'
25
+ import { pipeline } from 'node:stream/promises'
26
+ import { Transform } from 'node:stream'
27
+ import { PARAKEET_CLI } from './parakeet-manifest.js'
28
+
29
+ /** The COS Developer ID team (read from /Applications/COS Control.app with codesign -dv, 2026-10-16). */
30
+ export const COS_TEAM_ID = 'NV3X46LLCR'
31
+ export const CODESIGN = '/usr/bin/codesign'
32
+ export const TRUST_REQUIREMENT = `=anchor apple generic and certificate leaf[subject.OU] = "${COS_TEAM_ID}"`
33
+ /** The codesign identifier and embedded CFBundleIdentifier of COS Control's build (agreed with Control). */
34
+ export const CLI_IDENTIFIER = 'com.gotcos.control.fluidaudiocli'
35
+
36
+ export type CliTrustReason = 'missing' | 'unsigned' | 'signature_invalid' | 'wrong_team' | 'wrong_identifier' | 'wrong_version'
37
+
38
+ export type CliTrust =
39
+ | { trusted: true; trust: 'developer-id'; path: string; teamId: string; version: string; versionSource: 'info-plist'; sha256: string; size: number }
40
+ | { trusted: false; path: string; reason: CliTrustReason; detail: string }
41
+
42
+ export interface CliTrustDeps {
43
+ /** codesign -dv: what it prints (stderr), and its exit code. */
44
+ display: (path: string) => Promise<{ code: number; output: string }>
45
+ /** codesign --verify --strict [-R requirement]. */
46
+ verify: (path: string, requirement?: string) => Promise<{ code: number; output: string }>
47
+ /** The Info.plist embedded in the Mach-O, or null. */
48
+ embeddedPlist: (path: string) => string | null
49
+ sha256: (path: string) => Promise<string>
50
+ }
51
+
52
+ function run(command: string, args: string[], timeoutMs: number): Promise<{ code: number; output: string }> {
53
+ return new Promise(resolve => {
54
+ execFile(command, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { PATH: '/usr/bin:/bin' } }, (error, stdout, stderr) => {
55
+ const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 1) : 0
56
+ resolve({ code, output: `${stdout ?? ''}${stderr ?? ''}` })
57
+ })
58
+ })
59
+ }
60
+
61
+ async function sha256File(path: string): Promise<string> {
62
+ const hash = createHash('sha256')
63
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
64
+ return hash.digest('hex')
65
+ }
66
+
67
+ export const defaultCliTrustDeps: CliTrustDeps = {
68
+ display: path => run(CODESIGN, ['-dv', path], 10_000),
69
+ verify: (path, requirement) => run(CODESIGN, ['--verify', '--strict', ...(requirement ? ['-R', requirement] : []), path], 15_000),
70
+ embeddedPlist: path => readEmbeddedInfoPlist(path),
71
+ sha256: sha256File,
72
+ }
73
+
74
+ // ── Mach-O __TEXT,__info_plist ────────────────────────────────────────────────
75
+
76
+ const MH_MAGIC_64 = 0xfeedfacf
77
+ const FAT_MAGIC = 0xcafebabe
78
+ const LC_SEGMENT_64 = 0x19
79
+ const CPU_TYPE_ARM64 = 0x0100000c
80
+
81
+ function readAt(fd: number, offset: number, length: number): Buffer {
82
+ const buf = Buffer.alloc(length)
83
+ const n = readSync(fd, buf, 0, length, offset)
84
+ return buf.subarray(0, n)
85
+ }
86
+
87
+ function cstr(buf: Buffer, start: number): string {
88
+ const raw = buf.subarray(start, start + 16)
89
+ const end = raw.indexOf(0)
90
+ return raw.subarray(0, end < 0 ? 16 : end).toString('ascii')
91
+ }
92
+
93
+ /** The __TEXT,__info_plist section of a thin 64-bit or fat Mach-O (arm64 slice), or null. */
94
+ export function readEmbeddedInfoPlist(path: string): string | null {
95
+ let fd: number
96
+ // Non-blocking and no-follow: the path can come from a client, and a FIFO would
97
+ // block this synchronous open, and with it the whole server (hazard-invariants.test.ts).
98
+ try { fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW) } catch { return null }
99
+ try { if (!fstatSync(fd).isFile()) { closeSync(fd); return null } } catch { closeSync(fd); return null }
100
+ try {
101
+ let base = 0
102
+ const head = readAt(fd, 0, 8)
103
+ if (head.length < 8) return null
104
+ if (head.readUInt32BE(0) === FAT_MAGIC) {
105
+ const count = head.readUInt32BE(4)
106
+ if (count > 16) return null
107
+ const arches = readAt(fd, 8, count * 20)
108
+ base = -1
109
+ for (let i = 0; i < count; i++) {
110
+ if (arches.readUInt32BE(i * 20) === CPU_TYPE_ARM64) { base = arches.readUInt32BE(i * 20 + 8); break }
111
+ }
112
+ if (base < 0) return null
113
+ }
114
+ const header = readAt(fd, base, 32)
115
+ if (header.length < 32 || header.readUInt32LE(0) !== MH_MAGIC_64) return null
116
+ const ncmds = header.readUInt32LE(16)
117
+ const sizeofcmds = header.readUInt32LE(20)
118
+ if (sizeofcmds > 4 * 1024 * 1024) return null
119
+ const cmds = readAt(fd, base + 32, sizeofcmds)
120
+ let off = 0
121
+ for (let i = 0; i < ncmds && off + 8 <= cmds.length; i++) {
122
+ const cmd = cmds.readUInt32LE(off)
123
+ const cmdsize = cmds.readUInt32LE(off + 4)
124
+ if (cmdsize < 8) return null
125
+ if (cmd === LC_SEGMENT_64 && cstr(cmds, off + 8) === '__TEXT') {
126
+ const nsects = cmds.readUInt32LE(off + 64)
127
+ for (let s = 0; s < nsects; s++) {
128
+ const sec = off + 72 + s * 80
129
+ if (sec + 80 > cmds.length) return null
130
+ if (cstr(cmds, sec) !== '__info_plist') continue
131
+ const size = Number(cmds.readBigUInt64LE(sec + 40))
132
+ const offset = cmds.readUInt32LE(sec + 48)
133
+ if (size <= 0 || size > 64 * 1024) return null
134
+ return readAt(fd, base + offset, size).toString('utf8').replace(/\0+$/, '')
135
+ }
136
+ }
137
+ off += cmdsize
138
+ }
139
+ return null
140
+ } catch { return null } finally { closeSync(fd) }
141
+ }
142
+
143
+ function plistString(xml: string, key: string): string | null {
144
+ const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]{1,128})</string>`).exec(xml)
145
+ return m ? m[1].trim() : null
146
+ }
147
+ /** CFBundleShortVersionString from an XML plist. */
148
+ export function plistVersion(xml: string): string | null { return plistString(xml, 'CFBundleShortVersionString') }
149
+ export function plistIdentifier(xml: string): string | null { return plistString(xml, 'CFBundleIdentifier') }
150
+
151
+ /** 0.17.4 as a whole token, so 0.17.40 or 10.17.4 never pass. */
152
+ export function versionMatches(text: string, want: string = PARAKEET_CLI.version): boolean {
153
+ const escaped = want.replace(/\./g, '\\.')
154
+ return new RegExp(`(^|[^0-9.])v?${escaped}(?![0-9]|\\.[0-9])`).test(text)
155
+ }
156
+
157
+ export function parseCodesignDisplay(output: string): { teamId: string | null; identifier: string | null; adhoc: boolean; unsigned: boolean; plistBound: boolean } {
158
+ const team = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null
159
+ return {
160
+ teamId: team && team !== 'not set' ? team : null,
161
+ identifier: /^Identifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null,
162
+ adhoc: /^Signature=adhoc$/m.test(output) || /flags=0x[0-9a-f]*\([^)]*adhoc/.test(output),
163
+ unsigned: /code object is not signed at all/i.test(output),
164
+ plistBound: /^Info\.plist entries=\d+/m.test(output),
165
+ }
166
+ }
167
+
168
+ /** Every proof, in order; the first that fails names the reason. Never throws. */
169
+ export async function checkDeveloperIdCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
170
+ const no = (reason: CliTrustReason, detail: string): CliTrust => ({ trusted: false, path, reason, detail })
171
+ let size: number
172
+ try { const st = statSync(path); if (!st.isFile()) return no('missing', 'not a file'); size = st.size } catch { return no('missing', 'not found') }
173
+ try {
174
+ const shown = await deps.display(path)
175
+ const d = parseCodesignDisplay(shown.output)
176
+ if (d.unsigned || shown.code !== 0) return no('unsigned', 'the speech tool is not signed')
177
+ if (d.adhoc || !d.teamId) return no('unsigned', 'the speech tool has no Developer ID signature')
178
+ const strict = await deps.verify(path)
179
+ if (strict.code !== 0) return no('signature_invalid', strict.output.trim().split('\n').slice(-1)[0]?.slice(0, 200) || 'the signature does not verify')
180
+ if (d.teamId !== COS_TEAM_ID) return no('wrong_team', `signed by team ${d.teamId}`)
181
+ const anchored = await deps.verify(path, TRUST_REQUIREMENT)
182
+ if (anchored.code !== 0) return no('wrong_team', 'the signature does not chain to Apple for the COS team')
183
+ if (d.identifier !== CLI_IDENTIFIER) return no('wrong_identifier', `signed as ${d.identifier ?? 'no identifier'}`)
184
+ // Only a plist the signature binds counts; the binary is never run to ask its version.
185
+ const plist = d.plistBound ? deps.embeddedPlist(path) : null
186
+ if (!plist) return no('wrong_version', 'no signed Info.plist in the speech tool')
187
+ const bundleId = plistIdentifier(plist)
188
+ if (bundleId !== CLI_IDENTIFIER) return no('wrong_identifier', `its Info.plist names ${bundleId ?? 'no identifier'}`)
189
+ const named = plistVersion(plist)
190
+ if (!named || !versionMatches(named) || named.replace(/^v/, '') !== PARAKEET_CLI.version) return no('wrong_version', `version ${named ?? 'unknown'}, not FluidAudio ${PARAKEET_CLI.version}`)
191
+ const sha256 = await deps.sha256(path)
192
+ // The bytes hashed must be the bytes checked.
193
+ if (statSync(path).size !== size) return no('signature_invalid', 'the file changed while it was checked')
194
+ return { trusted: true, trust: 'developer-id', path, teamId: COS_TEAM_ID, version: PARAKEET_CLI.version, versionSource: 'info-plist', sha256, size }
195
+ } catch (error) {
196
+ return no('signature_invalid', error instanceof Error ? error.message.slice(0, 200) : String(error))
197
+ }
198
+ }
199
+
200
+ // ── Where COS Control's copy is, and a per-process cache for GET ──────────────
201
+
202
+ const HELPER_REL = join('COS Control.app', 'Contents', 'Helpers', 'fluidaudiocli')
203
+
204
+ /** COS Control in /Applications or ~/Applications, plus any path Control registered. */
205
+ export function controlCliCandidates(home: string = homedir()): string[] {
206
+ return [...offered, join(applicationsRoot, HELPER_REL), join(home, 'Applications', HELPER_REL)]
207
+ }
208
+ let applicationsRoot = '/Applications'
209
+ /** Tests only: keep the real /Applications out of a test's view. */
210
+ export function __setApplicationsRootForTests(root: string | null): void { applicationsRoot = root ?? '/Applications' }
211
+
212
+ const offered: string[] = []
213
+ /** An absolute path whose file name is fluidaudiocli. Anything else is never checked or run. */
214
+ export function isCliPathShape(path: unknown): path is string {
215
+ return typeof path === 'string' && path.startsWith('/') && path.length <= 1024 && !path.includes('\0') && basename(path) === 'fluidaudiocli'
216
+ }
217
+ /** Control tells the server where its bundled CLI is (POST /api/stt-settings/parakeet/cli). */
218
+ export function offerControlCli(path: string): void {
219
+ if (!isCliPathShape(path)) return
220
+ if (!offered.includes(path)) offered.unshift(path)
221
+ if (offered.length > 4) offered.length = 4
222
+ }
223
+
224
+ const cache = new Map<string, CliTrust>()
225
+ const pending = new Map<string, Promise<CliTrust>>()
226
+ function cacheKey(path: string): string | null {
227
+ try { const st = statSync(path); return st.isFile() ? `${path}\0${st.size}\0${st.mtimeMs}\0${st.ino}` : null } catch { return null }
228
+ }
229
+
230
+ /** Check (once per path, size, mtime and inode) and remember. */
231
+ export function trustCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
232
+ const key = cacheKey(path)
233
+ if (!key) return Promise.resolve({ trusted: false, path, reason: 'missing', detail: 'not found' })
234
+ const known = cache.get(key)
235
+ if (known) return Promise.resolve(known)
236
+ const running = pending.get(key)
237
+ if (running) return running
238
+ const job = checkDeveloperIdCli(path, deps).then(r => { cache.set(key, r); return r }).finally(() => pending.delete(key))
239
+ pending.set(key, job)
240
+ return job
241
+ }
242
+
243
+ /** Synchronous, for GET: the known result for this file, null while unchecked (a check starts). */
244
+ export function knownCliTrust(path: string): CliTrust | null {
245
+ const key = cacheKey(path)
246
+ if (!key) return { trusted: false, path, reason: 'missing', detail: 'not found' }
247
+ const known = cache.get(key)
248
+ if (known) return known
249
+ void trustCli(path)
250
+ return null
251
+ }
252
+
253
+ /** The first trusted candidate, or null. */
254
+ export async function findTrustedCli(candidates: string[] = controlCliCandidates(), deps?: CliTrustDeps): Promise<Extract<CliTrust, { trusted: true }> | null> {
255
+ for (const c of candidates) {
256
+ const r = await trustCli(c, deps)
257
+ if (r.trusted) return r
258
+ }
259
+ return null
260
+ }
261
+
262
+ export function __resetCliTrustForTests(): void { cache.clear(); pending.clear(); offered.length = 0; applicationsRoot = '/Applications' }