@gotcos/glasses-server 6.73.5 → 6.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/package.json +1 -1
- package/server/index.ts +5 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -0
- package/server/lib/diarizer-backend.ts +24 -2
- package/server/lib/dictation-benchmark.ts +197 -0
- package/server/lib/dictation-stt.ts +61 -2
- package/server/lib/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/parakeet-cli-trust.ts +262 -0
- package/server/lib/parakeet.ts +156 -29
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/stt-runtime.ts +43 -11
- package/server/lib/stt-settings.ts +88 -3
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +87 -7
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +113 -13
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/stt-settings.ts +55 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +74 -6
- package/server/routes/work-chat.ts +10 -4
- package/server/routes/work-search.ts +2 -2
|
@@ -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,
|
|
@@ -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 =
|
|
353
|
+
const fallbackTitle = `${mac ? 'Mac' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
|
|
317
354
|
const title = normalizeTitle(input.title, fallbackTitle)
|
|
318
|
-
const stem = filenameStem(title,
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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' }
|