@gotcos/glasses-server 6.73.3 → 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.
- package/CHANGELOG.md +74 -0
- package/package.json +2 -1
- package/server/index.ts +7 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -0
- package/server/lib/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/maintenance-lifecycle.ts +1 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +9 -1
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +122 -0
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +20 -2
- package/server/routes/work-board.ts +42 -3
- package/server/routes/work-chat.ts +31 -0
- 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
|
|
537
|
-
*
|
|
588
|
+
* applied. The list and `probeMeetings` share it so a dot and a row agree about
|
|
589
|
+
* how many meetings a day holds: each source is counted by the rule that lists
|
|
590
|
+
* it. The counts are not capped the way a row page is, except the store's Mac
|
|
591
|
+
* recordings, which stop at 200 a month (macStoreDayCounts).
|
|
538
592
|
*/
|
|
539
593
|
export function supersededDayCounts(
|
|
540
594
|
month: string,
|
|
@@ -568,9 +622,13 @@ export function supersededDayCounts(
|
|
|
568
622
|
// into this layout at all: on a pipeline Mac the operations tree is the one
|
|
569
623
|
// library, and a derived record here would be a second row for one meeting.
|
|
570
624
|
const directories = operationsMonthDirs(month, domain)
|
|
625
|
+
const merged = operationsMergedSessions(directories, options.declaredSessions)
|
|
571
626
|
return mergeDayCounts([
|
|
572
627
|
listCosOperationsMeetingDays(month, domain),
|
|
573
|
-
sessionDropCounts(directories,
|
|
628
|
+
sessionDropCounts(directories, merged, options.recordingsRoot),
|
|
629
|
+
// 6.73.14: the store's Mac recordings, which the list now shows (routes/meetings.ts withMacStoreRows), less any the
|
|
630
|
+
// operations side already counts.
|
|
631
|
+
macStoreDayCounts(store, month, domain, () => operationsHeldSessions(directories, merged, options.recordingsRoot)),
|
|
574
632
|
])
|
|
575
633
|
}
|
|
576
634
|
|
package/server/lib/jev.ts
CHANGED
|
@@ -81,7 +81,7 @@ function dailyCap(): number {
|
|
|
81
81
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : JEV_LIMITS.defaultDailyTokens
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export interface JevAnswers { answers: Record<string, { choice?: string; probabilities?: Record<string, number>; noul?: number }>; model?: string; inputTokens: number }
|
|
84
|
+
export interface JevAnswers { provider?: 'jev' | 'subq'; answers: Record<string, { type?: 'choice' | 'noul' | 'score'; choice?: string; probabilities?: Record<string, number>; noul?: number; confidence?: number; score?: number; legend?: Record<string, unknown> }>; model?: string; inputTokens: number }
|
|
85
85
|
|
|
86
86
|
/** One Jev client per process: it owns the breaker and the usage ledger. */
|
|
87
87
|
export class JevClient {
|
|
@@ -128,14 +128,14 @@ export class JevClient {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/** Ask one request. `estimate` is the caller's input-token estimate, checked against the cap BEFORE sending. */
|
|
131
|
-
async ask(state: unknown, questions: Record<string, unknown>, estimate: number): Promise<JevAnswers> {
|
|
131
|
+
async ask(state: unknown, questions: Record<string, unknown>, estimate: number, options?: { signal?: AbortSignal }): Promise<JevAnswers> {
|
|
132
132
|
const key = resolveJevKey()
|
|
133
133
|
if (!key) throw new JevError('jev_not_configured')
|
|
134
134
|
if (this.breakerUntil > this.now().getTime()) throw new JevError('jev_breaker_open')
|
|
135
135
|
if (this.usedToday() + estimate > this.cap()) this.fail('jev_cap_reached', false)
|
|
136
136
|
let res: Response
|
|
137
137
|
try {
|
|
138
|
-
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.timeout(JEV_LIMITS.timeoutMs),
|
|
138
|
+
res = await this.fetchImpl(`${JEV_ENDPOINT}/systemone`, { method: 'POST', signal: AbortSignal.any([AbortSignal.timeout(JEV_LIMITS.timeoutMs), ...(options?.signal ? [options.signal] : [])]),
|
|
139
139
|
headers: { Authorization: `Bearer ${key.key}`, 'Content-Type': 'application/json' },
|
|
140
140
|
body: JSON.stringify({ state, model: JEV_MODEL, questions }) })
|
|
141
141
|
} catch { return this.fail('jev_unavailable', true) }
|
|
@@ -200,9 +200,14 @@ function lastJsonObject(stdout: string): any {
|
|
|
200
200
|
|
|
201
201
|
// --- Claude ---------------------------------------------------------------------------
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
/** `claude -p --effort` levels COS passes (6.73.12). The CLI also takes xhigh and max; COS does not use them. */
|
|
204
|
+
export const CLAUDE_GIST_EFFORTS = ['low', 'medium', 'high'] as const
|
|
205
|
+
export type ClaudeGistEffort = typeof CLAUDE_GIST_EFFORTS[number]
|
|
206
|
+
|
|
207
|
+
/** `effort` defaults to low, so the lens gist and Meeting Ask argv stay exactly as before 6.73.12. */
|
|
208
|
+
export function buildClaudeGistArgs(model: string, system: string, effort: ClaudeGistEffort = 'low'): string[] {
|
|
204
209
|
return [
|
|
205
|
-
'-p', '--model', model, '--effort',
|
|
210
|
+
'-p', '--model', model, '--effort', effort,
|
|
206
211
|
// No tools: nothing to act on an instruction hidden in the reply, and ~1.2k tokens a
|
|
207
212
|
// call instead of a cache of tool definitions. claude-permissions.ts records an empty
|
|
208
213
|
// --tools causing a spurious compaction and a synthetic 400 on the 2026-08-26 CLI, in
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// On-demand meeting assistance. No poll, background summary, chat persistence or write tools.
|
|
2
|
+
import { MAC_ASK_SESSION } from './ask-session-id.js'
|
|
2
3
|
import { connectedEvidence } from './meeting-ask-evidence.js'
|
|
3
4
|
import { REVIEW_SYSTEM, normalizeReviewConfidence, reviewRepairDiagnostics, reviewProblem, unverifiedReview, isFactCheckCTA, pageWasRead } from './meeting-ask-review.js'
|
|
4
5
|
import { followupContext } from './meeting-ask-followup.js'
|
|
@@ -35,7 +36,7 @@ const TASKS: Record<AskAction, string> = {
|
|
|
35
36
|
}
|
|
36
37
|
export function normalizeAsk(value: unknown): AskInput {
|
|
37
38
|
const v = value as any
|
|
38
|
-
if (!v || typeof v !== 'object' || typeof v.sessionId !== 'string' ||
|
|
39
|
+
if (!v || typeof v !== 'object' || typeof v.sessionId !== 'string' || !(/^meeting_[A-Za-z0-9_-]{1,100}$/.test(v.sessionId ?? '') || MAC_ASK_SESSION.test(v.sessionId ?? '')) || !ASK_ACTIONS.includes(v.action)) throw new AskError('invalid_request')
|
|
39
40
|
if (typeof v.question !== 'string' || v.question.length > 2000 || (v.action === 'ask' && !v.question.trim())) throw new AskError('invalid_question')
|
|
40
41
|
if (!Number.isFinite(v.elapsedMs) || v.elapsedMs < 0 || v.elapsedMs > 7 * 86400_000) throw new AskError('invalid_time')
|
|
41
42
|
if (!Array.isArray(v.turns) || v.turns.length < 1 || v.turns.length > 6000) throw new AskError('invalid_transcript')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Manual Mac canonical chunks follow the saved-meeting model choice. The
|
|
2
|
+
// normal preview resolver may fall back to Turbo; canonical Mac audio must not.
|
|
3
|
+
import { ParakeetError, transcribeParakeet, type ParakeetFailure } from './parakeet.js'
|
|
4
|
+
import type { WhisperWord } from './whisper-local.js'
|
|
5
|
+
|
|
6
|
+
export const MAC_PARAKEET_TIMEOUT_MS = 30_000
|
|
7
|
+
|
|
8
|
+
export class MacParakeetUnavailableError extends Error {
|
|
9
|
+
readonly status = 503
|
|
10
|
+
readonly retryable = true
|
|
11
|
+
readonly model = 'parakeet-ultra'
|
|
12
|
+
readonly reason: `mac_parakeet_${ParakeetFailure}`
|
|
13
|
+
|
|
14
|
+
constructor(cause: unknown) {
|
|
15
|
+
const reason = cause instanceof ParakeetError ? cause.reason : 'cli_failed'
|
|
16
|
+
super(`Selected Parakeet Ultra could not transcribe this Mac chunk (${reason}); audio is preserved for retry`)
|
|
17
|
+
this.name = 'MacParakeetUnavailableError'
|
|
18
|
+
this.reason = `mac_parakeet_${reason}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Interactive admission is bounded and never queues behind saved HQ work.
|
|
23
|
+
* The shared worker verifies the pinned installation and offline sandbox.
|
|
24
|
+
* Cold compilation may use this deadline; no Whisper/cloud fallback is allowed. */
|
|
25
|
+
export async function transcribeMacParakeet(audio: Buffer, speechSeconds: number | null): Promise<{
|
|
26
|
+
text: string
|
|
27
|
+
words: WhisperWord[]
|
|
28
|
+
model: 'parakeet-ultra'
|
|
29
|
+
backend: 'fluidaudio-coreml'
|
|
30
|
+
asrProvider: 'server-parakeet'
|
|
31
|
+
latencyMs: number
|
|
32
|
+
}> {
|
|
33
|
+
try {
|
|
34
|
+
const result = await transcribeParakeet(audio, {
|
|
35
|
+
model: 'parakeet-ultra',
|
|
36
|
+
lane: 'interactive',
|
|
37
|
+
timeoutMs: MAC_PARAKEET_TIMEOUT_MS,
|
|
38
|
+
})
|
|
39
|
+
if (result.model !== 'parakeet-ultra') {
|
|
40
|
+
throw new ParakeetError('bad_output', 'Parakeet did not return the selected model')
|
|
41
|
+
}
|
|
42
|
+
// 6.73.8: no words over detected speech (laughter, music, crosstalk) is an answer, not a failure. As a retryable
|
|
43
|
+
// 503 it replayed the same audio forever and froze every later chunk of a live meeting (2026-10-15, chunk 51).
|
|
44
|
+
// The saved meeting re-transcribes the full audio with strict Parakeet, so these words are not lost.
|
|
45
|
+
if (!result.text.trim() && speechSeconds !== null && speechSeconds >= 0.5) {
|
|
46
|
+
console.warn(`[mac-asr] Parakeet returned no words over ${speechSeconds.toFixed(2)}s of detected speech; recorded empty`)
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
text: result.text,
|
|
50
|
+
words: result.words.map(word => ({ ...word, probability: word.probability ?? 0 })),
|
|
51
|
+
model: result.model,
|
|
52
|
+
backend: 'fluidaudio-coreml',
|
|
53
|
+
asrProvider: 'server-parakeet',
|
|
54
|
+
latencyMs: result.wallMs,
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new MacParakeetUnavailableError(error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Manual, remote-playback-only Mac capture. This registry is independent of
|
|
2
|
+
* live transcription state so provenance and the strict policy survive save,
|
|
3
|
+
* quarantine, finalization and recovery. No capture or model is started here. */
|
|
4
|
+
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { dataPath } from './data-dir.js'
|
|
7
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
8
|
+
import { readSpeakerPolicy, writeSpeakerPolicy } from './meeting-speaker-policy.js'
|
|
9
|
+
|
|
10
|
+
export const MAC_SOURCE_TYPE = 'mac_system_audio' as const
|
|
11
|
+
export const MAC_CAPTURE_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
12
|
+
export const MAC_AUDIO = { sourceType: MAC_SOURCE_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16, maxChunkMs: 30000, maxChunkBytes: 960044, speakerPolicy: 'high-confidence' as const }
|
|
13
|
+
export interface MacRecording { version: 1; captureId: string; sessionId: string; sourceType: typeof MAC_SOURCE_TYPE; startTime: number; speakerPolicy: 'high-confidence'; closedAt?: number }
|
|
14
|
+
export function macError(status: number, reason: string, message: string): Error & { status: number; reason: string } {
|
|
15
|
+
return Object.assign(new Error(message), { status, reason })
|
|
16
|
+
}
|
|
17
|
+
function registryPath(id: string): string {
|
|
18
|
+
if (!MAC_CAPTURE_ID.test(id)) throw macError(400, 'invalid_capture_id', 'Invalid Mac capture ID')
|
|
19
|
+
return join(dataPath('mac-recordings'), id + '.json')
|
|
20
|
+
}
|
|
21
|
+
export function readMacRecording(id: string): MacRecording | null {
|
|
22
|
+
if (!MAC_CAPTURE_ID.test(id)) return null
|
|
23
|
+
try {
|
|
24
|
+
const path = registryPath(id)
|
|
25
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error('symbolic link')
|
|
26
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd)
|
|
29
|
+
if (!stat.isFile() || stat.size > 4096) throw new Error('invalid registry file')
|
|
30
|
+
const value = JSON.parse(readFileSync(fd, 'utf8'))
|
|
31
|
+
if (value.version !== 1 || value.captureId !== id || value.sessionId !== id || value.sourceType !== MAC_SOURCE_TYPE || value.speakerPolicy !== 'high-confidence' || !Number.isSafeInteger(value.startTime) || value.startTime <= 0) throw new Error('invalid registry metadata')
|
|
32
|
+
return value
|
|
33
|
+
} finally { closeSync(fd) }
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
36
|
+
throw macError(409, 'mac_capture_metadata_unreadable', 'Mac capture metadata is unreadable; recording was not admitted')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function registerMacRecording(input: unknown): { recording: MacRecording; replayed: boolean } {
|
|
40
|
+
const body = input as Partial<MacRecording> | null
|
|
41
|
+
if (!body || typeof body.captureId !== 'string') throw macError(400, 'invalid_capture_id', 'Mac capture ID required')
|
|
42
|
+
const path = registryPath(body.captureId)
|
|
43
|
+
if (body.sourceType !== MAC_SOURCE_TYPE || !Number.isSafeInteger(body.startTime) || Number(body.startTime) <= 0 || Number(body.startTime) > Date.now() + 300000) throw macError(400, 'invalid_mac_capture', 'Source type and valid capture start time required')
|
|
44
|
+
const prior = readMacRecording(body.captureId)
|
|
45
|
+
if (prior) {
|
|
46
|
+
if (prior.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
|
|
47
|
+
if (prior.startTime !== body.startTime) throw macError(409, 'mac_capture_conflict', 'Capture ID already belongs to another start time')
|
|
48
|
+
if (readSpeakerPolicy(body.captureId).mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_changed', 'Mac capture requires high-confidence identity policy')
|
|
49
|
+
return { recording: prior, replayed: true }
|
|
50
|
+
}
|
|
51
|
+
const policy = readSpeakerPolicy(body.captureId)
|
|
52
|
+
// A policy written before a crash can be reused, but never overwrite another policy.
|
|
53
|
+
if (policy.revision !== 'unset' && policy.mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_conflict', 'Capture ID has an incompatible identity policy')
|
|
54
|
+
if (policy.revision === 'unset') writeSpeakerPolicy(body.captureId, 'high-confidence', policy.revision)
|
|
55
|
+
const recording: MacRecording = { version: 1, captureId: body.captureId, sessionId: body.captureId, sourceType: MAC_SOURCE_TYPE, startTime: Number(body.startTime), speakerPolicy: 'high-confidence' }
|
|
56
|
+
mkdirSync(dataPath('mac-recordings'), { recursive: true, mode: 0o700 })
|
|
57
|
+
// Registry is the admission commit marker; strict policy is durable first.
|
|
58
|
+
durableAtomicWriteFileSync(path, JSON.stringify(recording), { mode: 0o600 })
|
|
59
|
+
return { recording, replayed: false }
|
|
60
|
+
}
|
|
61
|
+
export function requireMacRecording(id: string): MacRecording {
|
|
62
|
+
registryPath(id)
|
|
63
|
+
const value = readMacRecording(id)
|
|
64
|
+
if (value?.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
|
|
65
|
+
if (!value) throw macError(409, 'mac_capture_not_started', 'Start this Mac capture before sending audio')
|
|
66
|
+
if (readSpeakerPolicy(id).mode !== 'high-confidence') throw macError(409, 'mac_capture_policy_changed', 'Mac capture requires high-confidence identity policy')
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
/** Native bridge intentionally sends one canonical WAV format, never raw HAL buffers. */
|
|
70
|
+
export function validateMacWav(audio: Buffer): number {
|
|
71
|
+
if (audio.length < 46 || audio.length > MAC_AUDIO.maxChunkBytes || audio.length % 2 !== 0 ||
|
|
72
|
+
audio.toString('ascii', 0, 4) !== 'RIFF' || audio.readUInt32LE(4) !== audio.length - 8 ||
|
|
73
|
+
audio.toString('ascii', 8, 12) !== 'WAVE' || audio.toString('ascii', 12, 16) !== 'fmt ' || audio.readUInt32LE(16) !== 16 ||
|
|
74
|
+
audio.readUInt16LE(20) !== 1 || audio.readUInt16LE(22) !== 1 || audio.readUInt32LE(24) !== 16000 || audio.readUInt32LE(28) !== 32000 || audio.readUInt16LE(32) !== 2 || audio.readUInt16LE(34) !== 16 ||
|
|
75
|
+
audio.toString('ascii', 36, 40) !== 'data' || audio.readUInt32LE(40) !== audio.length - 44) {
|
|
76
|
+
throw macError(400, 'invalid_mac_audio', 'Expected a bounded canonical 16-kHz mono PCM16 WAV')
|
|
77
|
+
}
|
|
78
|
+
return (audio.length - 44) / 32
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function closeMacRecording(id: string): void {
|
|
82
|
+
const value = readMacRecording(id)
|
|
83
|
+
if (value && !value.closedAt) durableAtomicWriteFileSync(registryPath(id), JSON.stringify({ ...value, closedAt: Date.now() }), { mode: 0o600 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MacChunkReceipt { chunkIndex: number; endElapsed: number; durationMs: number; audioSha256: string }
|
|
87
|
+
export function macChunkReceipt(id: string, index: number): MacChunkReceipt | null {
|
|
88
|
+
registryPath(id)
|
|
89
|
+
if (!Number.isInteger(index) || index < 0 || index > 9999) throw macError(400, 'invalid_chunk_index', 'Invalid chunk index')
|
|
90
|
+
const path = join(dataPath('mac-recording-chunks'), id, index + '.json')
|
|
91
|
+
try {
|
|
92
|
+
const stat = lstatSync(path)
|
|
93
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw new Error('invalid receipt')
|
|
94
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
95
|
+
if (value.chunkIndex !== index || !Number.isFinite(value.endElapsed) || !Number.isFinite(value.durationMs) || value.durationMs <= 0 || value.durationMs > MAC_AUDIO.maxChunkMs || value.endElapsed < value.durationMs || !/^[0-9a-f]{64}$/.test(value.audioSha256)) throw new Error('invalid receipt')
|
|
96
|
+
return value
|
|
97
|
+
} catch(error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
99
|
+
throw macError(409, 'mac_chunk_metadata_unreadable', 'Mac chunk receipt is unreadable')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function persistMacChunkReceipt(id: string, value: MacChunkReceipt): void {
|
|
103
|
+
const prior = macChunkReceipt(id, value.chunkIndex)
|
|
104
|
+
if (prior) {
|
|
105
|
+
if (prior.audioSha256 !== value.audioSha256 || prior.endElapsed !== value.endElapsed || prior.durationMs !== value.durationMs) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio or timing')
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const dir = join(dataPath('mac-recording-chunks'), id)
|
|
109
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
110
|
+
durableAtomicWriteFileSync(join(dir, value.chunkIndex + '.json'), JSON.stringify(value), { mode: 0o600 })
|
|
111
|
+
}
|
|
@@ -282,6 +282,7 @@ export function toEngineSentences(rows: unknown): FirefliesSentenceInput[] {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
function g2FromSidecar(doc: Record<string, unknown>, sessionId: string): G2RecordingInput | null {
|
|
285
|
+
if (doc.sourceType === 'mac_system_audio') return null // Mac pilot never enters the G2-only blend engine.
|
|
285
286
|
const startMs = numberOr(doc.startTime, Number.NaN)
|
|
286
287
|
const durationMs = numberOr(doc.durationMs, Number.NaN)
|
|
287
288
|
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) return null
|
|
@@ -35,8 +35,8 @@ export async function saveAskFollowUp(raw: unknown, deps: FollowUpDependencies):
|
|
|
35
35
|
try { meeting = await deps.resolveMeeting(descriptor) } catch { throw new WorkIntakeError('meeting_unavailable', 409) }
|
|
36
36
|
if (meeting.recordId !== descriptor.recordId) throw new WorkIntakeError('meeting_identity_changed', 409)
|
|
37
37
|
const captures = new Set([meeting.sessionId, meeting.sourceSessionId, ...(meeting.g2SessionIds ?? []),
|
|
38
|
-
...(meeting.contextKeys ?? []).filter(k => k.startsWith('g2:')).map(k => k.slice(
|
|
39
|
-
...(meeting.sources ?? []).filter(s => s.kind === 'g2').map(s => s.id)])
|
|
38
|
+
...(meeting.contextKeys ?? []).filter(k => k.startsWith('g2:') || k.startsWith('mac:')).map(k => k.slice(k.indexOf(':') + 1)),
|
|
39
|
+
...(meeting.sources ?? []).filter(s => s.kind === 'g2' || s.kind === 'mac').map(s => s.id)])
|
|
40
40
|
if (!captures.has(exchange.sessionId) || meeting.contextAmbiguous) throw new WorkIntakeError('meeting_capture_mismatch', 409)
|
|
41
41
|
const ids = body.sourceIds === undefined ? exchange.sources.slice(0, 8).map(s => s.id) : body.sourceIds
|
|
42
42
|
if (!Array.isArray(ids) || ids.length > 8 || ids.some(id => typeof id !== 'string' || !exchange.sources.some(s => s.id === id))) throw new WorkIntakeError('invalid_follow_up_source', 400)
|
|
@@ -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
|
-
}
|
|
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),
|
|
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'),
|
|
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
|
|
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,
|
|
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
|
|
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,
|