@gotcos/glasses-server 6.89.0 → 6.91.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/.env.example +28 -0
- package/CHANGELOG.md +182 -0
- package/README.md +66 -4
- package/managed-runtime-contract.json +6 -1
- package/package.json +3 -1
- package/server/lib/dictation-clean.ts +151 -14
- package/server/lib/dictation-learned-style.ts +203 -0
- package/server/lib/dictation-meaning-guard.ts +449 -0
- package/server/lib/dictation-polish-instructions.ts +2 -2
- package/server/lib/dictation-polish-options.ts +98 -0
- package/server/lib/dictation-polish-pipeline.ts +292 -0
- package/server/lib/dictation-prewarm.ts +18 -6
- package/server/lib/dictation-register.ts +114 -0
- package/server/lib/mac-voice-labels.ts +50 -0
- package/server/lib/meeting-batch-transcribe.ts +9 -3
- package/server/lib/meeting-relabel-enrolment.ts +12 -1
- package/server/lib/meeting-relabel.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +4 -1
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-final.ts +314 -7
- package/server/lib/nemotron-live.ts +3 -1
- package/server/lib/speaker-embeddings.ts +39 -1
- package/server/lib/tailscale-nudge.ts +8 -4
- package/server/lib/token-audit.ts +18 -0
- package/server/routes/dictation-polish-settings.ts +193 -3
- package/server/routes/health.ts +11 -3
- package/server/routes/prompt-drafts.ts +35 -140
- package/server/routes/transcribe-stream.ts +13 -2
|
@@ -26,9 +26,9 @@ export const CUSTOM_INSTRUCTIONS_PROTOCOL = 1
|
|
|
26
26
|
/**
|
|
27
27
|
* The prompt's own delimiters. A preference that contains one could close its block early and
|
|
28
28
|
* write text the model would read as COS's, so every one of them is removed, in any case and
|
|
29
|
-
* with any spacing or attributes.
|
|
29
|
+
* with any spacing or attributes. 6.91.0: the learned-style block's tag too.
|
|
30
30
|
*/
|
|
31
|
-
const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|known-spellings|dictation)\b[^>]*>/gi
|
|
31
|
+
const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|learned-style|known-spellings|dictation)\b[^>]*>/gi
|
|
32
32
|
/** C0 and C1 controls other than line feed and tab, plus DEL. */
|
|
33
33
|
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
|
|
34
34
|
/** Invisible format characters: zero-width, bidi embeddings, overrides and isolates, BOM, soft hyphen. */
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, statSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
3
|
+
import { dataPath } from './data-dir.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 6.91.0: "Previous polish", the one polish option COS Control can set (plan r2). The person can
|
|
7
|
+
* go back to the 6.90.0 prompt (v3) from Control's own Settings, with no terminal and no restart,
|
|
8
|
+
* and G2 follows because the option lives on the server.
|
|
9
|
+
*
|
|
10
|
+
* - `promptVersion: "default"` (prompt v4, the default) or `"v3"`.
|
|
11
|
+
* - The environment wins when it names a version (COS_DICTATION_POLISH_PROMPT, or the
|
|
12
|
+
* COS_DICTATION_PROMPT_VERSION alias): dictation-clean.ts dictationPolishPromptVersion.
|
|
13
|
+
* - Stored atomically in the data dir, mode 0600; "default" deletes the file. A missing or
|
|
14
|
+
* malformed file reads as "default".
|
|
15
|
+
*/
|
|
16
|
+
export const POLISH_OPTIONS_PROTOCOL = 1
|
|
17
|
+
export const POLISH_OPTIONS_FILE = 'dictation-polish-options.json'
|
|
18
|
+
export const POLISH_PROMPT_OPTIONS = ['default', 'v3'] as const
|
|
19
|
+
export type PolishPromptOption = typeof POLISH_PROMPT_OPTIONS[number]
|
|
20
|
+
|
|
21
|
+
export interface PolishOptions { promptVersion: PolishPromptOption; updatedAt: string | null }
|
|
22
|
+
|
|
23
|
+
const filePath = () => dataPath(POLISH_OPTIONS_FILE)
|
|
24
|
+
let cache: { mtimeMs: number; size: number; value: PolishOptions } | null = null
|
|
25
|
+
|
|
26
|
+
export function readPolishOptions(): PolishOptions {
|
|
27
|
+
const off: PolishOptions = { promptVersion: 'default', updatedAt: null }
|
|
28
|
+
let stat: { mtimeMs: number; size: number }
|
|
29
|
+
try { const s = statSync(filePath()); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { cache = null; return off }
|
|
30
|
+
if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return { ...cache.value }
|
|
31
|
+
let value = off
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(filePath(), 'utf8'))
|
|
34
|
+
if ((POLISH_PROMPT_OPTIONS as readonly string[]).includes(parsed?.promptVersion)) {
|
|
35
|
+
value = { promptVersion: parsed.promptVersion, updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null }
|
|
36
|
+
}
|
|
37
|
+
} catch { /* malformed reads as default */ }
|
|
38
|
+
cache = { ...stat, value }
|
|
39
|
+
return { ...value }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writePolishOptions(promptVersion: PolishPromptOption, now: Date = new Date()): PolishOptions {
|
|
43
|
+
cache = null
|
|
44
|
+
if (promptVersion === 'default') {
|
|
45
|
+
try { unlinkSync(filePath()) } catch (err: any) { if (err?.code !== 'ENOENT') throw err }
|
|
46
|
+
return { promptVersion, updatedAt: null }
|
|
47
|
+
}
|
|
48
|
+
const stored: PolishOptions = { promptVersion, updatedAt: now.toISOString() }
|
|
49
|
+
atomicWriteFileSync(filePath(), JSON.stringify(stored, null, 2) + '\n', { mode: 0o600 })
|
|
50
|
+
return stored
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function __resetPolishOptionsCacheForTests(): void { cache = null }
|
|
54
|
+
|
|
55
|
+
export type DictationPolishPromptVersion = 'v1' | 'v2' | 'v3' | 'v4'
|
|
56
|
+
/** The newest prompt, the default on the models in V4_DEFAULT_MODELS. */
|
|
57
|
+
export const DEFAULT_DICTATION_POLISH_PROMPT: DictationPolishPromptVersion = 'v4'
|
|
58
|
+
export const DICTATION_POLISH_PROMPT_VERSIONS: readonly DictationPolishPromptVersion[] = ['v1', 'v2', 'v3', 'v4']
|
|
59
|
+
export type PromptVersionSource = 'env' | 'option' | 'default'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 6.91.0 (canary C1): prompt v4 passed on Sonnet and failed on Haiku (CHANGELOG), so it is the
|
|
63
|
+
* default on Sonnet only. Haiku, Luna and any other or unnamed model keep v3, byte for byte the
|
|
64
|
+
* 6.90.0 prompt. The environment and the "Previous polish" option still override every model.
|
|
65
|
+
*/
|
|
66
|
+
export const V4_DEFAULT_MODELS: readonly string[] = ['sonnet']
|
|
67
|
+
export const POLISH_MODEL_DEFAULTS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
|
|
68
|
+
|
|
69
|
+
export function defaultPromptFor(model: string | undefined): DictationPolishPromptVersion {
|
|
70
|
+
return model !== undefined && V4_DEFAULT_MODELS.includes(model) ? DEFAULT_DICTATION_POLISH_PROMPT : 'v3'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which prompt runs, and why: COS_DICTATION_POLISH_PROMPT (v1 to v4) when it names one, else the
|
|
75
|
+
* alias COS_DICTATION_PROMPT_VERSION (3 or v3), else this file's option ("v3"), else the model's
|
|
76
|
+
* default (v4 on Sonnet, v3 otherwise). With no model named, the newest default (v4) is reported.
|
|
77
|
+
*/
|
|
78
|
+
export function dictationPolishPromptChoice(env: NodeJS.ProcessEnv = process.env, model?: string): { version: DictationPolishPromptVersion; source: PromptVersionSource } {
|
|
79
|
+
const raw = (env.COS_DICTATION_POLISH_PROMPT ?? '').trim().toLowerCase()
|
|
80
|
+
if ((DICTATION_POLISH_PROMPT_VERSIONS as readonly string[]).includes(raw)) return { version: raw as DictationPolishPromptVersion, source: 'env' }
|
|
81
|
+
const alias = (env.COS_DICTATION_PROMPT_VERSION ?? '').trim().toLowerCase().replace(/^(\d)$/, 'v$1')
|
|
82
|
+
if ((DICTATION_POLISH_PROMPT_VERSIONS as readonly string[]).includes(alias)) return { version: alias as DictationPolishPromptVersion, source: 'env' }
|
|
83
|
+
if (readPolishOptions().promptVersion === 'v3') return { version: 'v3', source: 'option' }
|
|
84
|
+
return { version: model === undefined ? DEFAULT_DICTATION_POLISH_PROMPT : defaultPromptFor(model), source: 'default' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function dictationPolishPromptVersion(env: NodeJS.ProcessEnv = process.env, model?: string): DictationPolishPromptVersion {
|
|
88
|
+
return dictationPolishPromptChoice(env, model).version
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 6.91.0: the model and prompt pairs the learned style and the literal register were canaried on
|
|
93
|
+
* (C2 and C3 ran on Sonnet with v4 only). Any other pair gets neither: no learned style, no
|
|
94
|
+
* register block, no literal check, and no `polishRegister` in the answer.
|
|
95
|
+
*/
|
|
96
|
+
export function styleAndRegisterApply(model: string | undefined, version: DictationPolishPromptVersion): boolean {
|
|
97
|
+
return model === 'sonnet' && version === 'v4'
|
|
98
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { DictationCleanModel } from './dictation-clean-models.js'
|
|
2
|
+
import { applyNegativeRules } from './hallucination-filter.js'
|
|
3
|
+
import { applyCorrections } from './whisper-local.js'
|
|
4
|
+
import { applyFuzzyCorrections } from './fuzzy-correct.js'
|
|
5
|
+
import { getAllSpeakerNames } from './speaker-embeddings.js'
|
|
6
|
+
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
|
|
7
|
+
import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
|
|
8
|
+
import { fillerPassEnabled, stripDisfluencies } from './dictation-fillers.js'
|
|
9
|
+
import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
|
|
10
|
+
import { polishNeed, skipCleanEnabled } from './dictation-polish-need.js'
|
|
11
|
+
import { discardPolishPrewarm, PrewarmUnavailableError, runWarmPolish, takePolishPrewarm } from './dictation-prewarm.js'
|
|
12
|
+
import { getVocabulary, getWhisperCorrections } from './profile.js'
|
|
13
|
+
import { correctionOnlyForms } from './glossary-learn.js'
|
|
14
|
+
import { polishLinkProblem, renderSpokenPaths, spokenPathsEnabled } from './dictation-format.js'
|
|
15
|
+
import { customInstructionsHash, readCustomInstructions } from './dictation-polish-instructions.js'
|
|
16
|
+
import { learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle } from './dictation-learned-style.js'
|
|
17
|
+
import { literalAnswer, type DictationRegister } from './dictation-register.js'
|
|
18
|
+
import { meaningGuardCounts, meaningGuardEnabled, meaningGuardProblem, type MeaningGuardKind } from './dictation-meaning-guard.js'
|
|
19
|
+
import { createBreaker } from './claude-circuit.js'
|
|
20
|
+
import { logTokenAudit } from './token-audit.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The outbound dictation polish: the one pipeline behind POST /api/dictation/finalize and the
|
|
24
|
+
* prompt-draft finalize (routes/prompt-drafts.ts). 6.91.0 moved it here, unchanged in order, so
|
|
25
|
+
* the offline evals call this function itself instead of a copy of it:
|
|
26
|
+
*
|
|
27
|
+
* dictionary (exact corrections and negative rules), the fuzzy name pass, the filler pass and
|
|
28
|
+
* spoken paths (not for meeting lines), the gates (disabled, too long, clean, breaker, daily
|
|
29
|
+
* cap), the model (warm when a prewarm token matches, else cold), then the guards: the link
|
|
30
|
+
* guard on every surface; for message and prompt_draft on the canaried pair only (Sonnet with
|
|
31
|
+
* prompt v4), the literal register's word check and the meaning guard.
|
|
32
|
+
*
|
|
33
|
+
* Meeting lines are a faithful record and stay as 6.90.0 had them: prompt v3 (or older), no
|
|
34
|
+
* register, no instructions, no learned style, no meaning guard.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const autoCleanBreakers = {
|
|
38
|
+
sonnet: createBreaker({ label: 'dictation-autoclean-sonnet' }),
|
|
39
|
+
'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
|
|
40
|
+
haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
|
|
41
|
+
}
|
|
42
|
+
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
43
|
+
|
|
44
|
+
/** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish).
|
|
45
|
+
* 6.91.0: a guard that refuses the model's answer says "failed" too (link_guard keeps its own
|
|
46
|
+
* code), so 0.5.325 to 0.5.343 log fillers_only:failed and never claim polish. */
|
|
47
|
+
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed' | 'clean' | 'link_guard'
|
|
48
|
+
/** 6.79.2, additive when finalize was given a prewarmToken: what became of that warm process. */
|
|
49
|
+
export type PrewarmOutcome = 'used' | 'missed' | 'discarded'
|
|
50
|
+
/** 6.91.0, additive, present exactly when the model answered: what became of its answer. */
|
|
51
|
+
export type PolishOutcome = 'polished' | 'unchanged' | 'guarded'
|
|
52
|
+
export type PolishGuard = 'link' | 'literal' | MeaningGuardKind
|
|
53
|
+
/** 6.91.0, additive: the prompt the model ran, as a closed set (v1 and v2 are not reported). */
|
|
54
|
+
export type PolishPrompt = 'v3' | 'v4'
|
|
55
|
+
|
|
56
|
+
export interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
|
|
57
|
+
export type OutboundOptions = AutoCleanRequest & {
|
|
58
|
+
bucket: PolishBucket
|
|
59
|
+
signal?: AbortSignal
|
|
60
|
+
prewarmToken?: string
|
|
61
|
+
/** 6.91.0: the target app's register (finalize reads it from `context.app`). */
|
|
62
|
+
register?: DictationRegister
|
|
63
|
+
/** Offline evals only: the prompt to use instead of the configured one. */
|
|
64
|
+
promptVersion?: DictationPolishPromptVersion
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 6.85.0 and 6.91.0: additive, present only when the model was given them. */
|
|
68
|
+
export interface PolishInstructionsUse {
|
|
69
|
+
customInstructions?: true
|
|
70
|
+
instructionsHash?: string
|
|
71
|
+
learnedStyle?: true
|
|
72
|
+
learnedStyleHash?: string
|
|
73
|
+
/** 6.91.0: the register the model was given, only on a pair the register was canaried on (Sonnet with v4). */
|
|
74
|
+
polishRegister?: DictationRegister
|
|
75
|
+
/** 6.91.0: the prompt the model ran (v3 or v4), whenever the model was called. */
|
|
76
|
+
polishPrompt?: PolishPrompt
|
|
77
|
+
/** 6.91.0: false whenever the model was called without the learned style. */
|
|
78
|
+
learnedStyleOff?: true
|
|
79
|
+
}
|
|
80
|
+
export type PolishResult = {
|
|
81
|
+
text: string
|
|
82
|
+
polishSkipped?: PolishSkipped
|
|
83
|
+
prewarm?: PrewarmOutcome
|
|
84
|
+
polishOutcome?: PolishOutcome
|
|
85
|
+
guard?: PolishGuard
|
|
86
|
+
/** Never sent by a route: the model's own answer, before any guard (the offline evals score it). */
|
|
87
|
+
modelText?: string
|
|
88
|
+
} & PolishInstructionsUse
|
|
89
|
+
|
|
90
|
+
export async function cleanOutboundDictation(
|
|
91
|
+
text: string,
|
|
92
|
+
opts: OutboundOptions,
|
|
93
|
+
): Promise<PolishResult> {
|
|
94
|
+
const result = await polishOutboundDictation(text, opts)
|
|
95
|
+
// A warm process the model call did not take must not sit until its TTL.
|
|
96
|
+
if (opts.prewarmToken && !result.prewarm) {
|
|
97
|
+
return { ...result, prewarm: discardPolishPrewarm(opts.prewarmToken) ? 'discarded' : 'missed' }
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 6.85.0: the user's polish instructions apply to dictation (Mac dictation and G2 messages, which
|
|
104
|
+
* are surface "message", and prompt drafts). Meeting lines are a faithful record, so they stay on
|
|
105
|
+
* the built-in rules alone, the same way they skip the filler pass and spoken paths.
|
|
106
|
+
*/
|
|
107
|
+
export function polishInstructionsFor(bucket: PolishBucket): string {
|
|
108
|
+
return bucket === 'meeting' ? '' : readCustomInstructions().customInstructions
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 6.91.0: the learned style for one polish: the lines for its register and the setting's hash.
|
|
113
|
+
* Never for meeting lines or a literal target, and nothing at all when the server switch
|
|
114
|
+
* (COS_DICTATION_LEARNED_STYLE=0) or the person's own switch (enabled: false) is off.
|
|
115
|
+
*/
|
|
116
|
+
export function learnedStyleFor(bucket: PolishBucket, register: DictationRegister): { lines: string; hash: string } {
|
|
117
|
+
if (bucket === 'meeting' || !learnedStyleEnabled()) return { lines: '', hash: '' }
|
|
118
|
+
const stored = readLearnedStyle()
|
|
119
|
+
const lines = learnedStyleLinesFor(stored, register)
|
|
120
|
+
return lines ? { lines, hash: stored.hash } : { lines: '', hash: '' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 6.91.0: the prompt for one polish. The configured prompt for its model (v4 on Sonnet, v3 on Haiku,
|
|
125
|
+
* Luna or anything else, unless the environment or "Previous polish" says otherwise); meeting lines
|
|
126
|
+
* never take v4.
|
|
127
|
+
*/
|
|
128
|
+
export function polishPromptVersionFor(bucket: PolishBucket, wanted?: DictationPolishPromptVersion, model?: string): DictationPolishPromptVersion {
|
|
129
|
+
const version = wanted ?? dictationPolishPromptVersion(process.env, model)
|
|
130
|
+
return bucket === 'meeting' && version === 'v4' ? 'v3' : version
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The deterministic passes, in route order: what the model is given ("cleaned"). */
|
|
134
|
+
export function prepareOutboundDictation(text: string, bucket: PolishBucket): string {
|
|
135
|
+
let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
|
|
136
|
+
// applyCorrections above is an EXACT string map, so it only fixes misspellings someone
|
|
137
|
+
// already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
|
|
138
|
+
// through it. The Levenshtein pass is what catches those, and until now it had exactly
|
|
139
|
+
// ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
|
|
140
|
+
// Moonshine dictation, which arrives here as text, never got it. Same construction and
|
|
141
|
+
// same non-fatal posture as that site, deliberately: targets are speaker names plus
|
|
142
|
+
// vocabulary, and a throw must never cost the user their transcript.
|
|
143
|
+
//
|
|
144
|
+
// Runs BEFORE the autoclean LLM so the model sees corrected proper nouns rather than
|
|
145
|
+
// being asked to guess at them, and it still helps on every path where autoclean is
|
|
146
|
+
// off, over the char cap, or breaker-open.
|
|
147
|
+
try {
|
|
148
|
+
const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
|
|
149
|
+
const { text: corrected, replacements } = applyFuzzyCorrections(cleaned, fuzzyTargets)
|
|
150
|
+
if (replacements > 0) {
|
|
151
|
+
console.log(`[prompt-draft] Fuzzy corrected ${replacements} word(s)`)
|
|
152
|
+
cleaned = corrected
|
|
153
|
+
}
|
|
154
|
+
} catch (fuzzyErr: any) {
|
|
155
|
+
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
156
|
+
}
|
|
157
|
+
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
158
|
+
if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
|
|
159
|
+
// 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
|
|
160
|
+
// at a phrase end only; slugs of several words are the model's (dictation-format.ts).
|
|
161
|
+
if (bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
|
|
162
|
+
return cleaned
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 6.91.0: the guards after the model, for one answer. Pure apart from reading the dictionary.
|
|
167
|
+
* `text` is what goes out: the answer (repaired for a literal target), or `cleaned` when a guard
|
|
168
|
+
* refused it. The link guard runs on every answer, as in 6.83.1. The literal check and the meaning
|
|
169
|
+
* guard run only on the canaried pair (Sonnet with prompt v4, `canaried`), never on meeting lines:
|
|
170
|
+
* Haiku, Luna, any other model and prompt v3 (the environment or "Previous polish") get 6.90.0's
|
|
171
|
+
* answer, guard for guard.
|
|
172
|
+
*/
|
|
173
|
+
export function guardPolishAnswer(
|
|
174
|
+
cleaned: string, polished: string, bucket: PolishBucket, register: DictationRegister, canaried: boolean,
|
|
175
|
+
): { text: string; outcome: PolishOutcome; guard?: PolishGuard; polishSkipped?: PolishSkipped } {
|
|
176
|
+
// 6.83.1: the model never invents a link and never drops one the dictionary wrote. Its
|
|
177
|
+
// answer is refused (the pre-polish text goes out, polishSkipped "link_guard") rather than
|
|
178
|
+
// pasting a URL nobody said. The call still counted: it ran.
|
|
179
|
+
const linkProblem = polished ? polishLinkProblem(cleaned, polished, correctionOnlyForms(getWhisperCorrections()), getVocabulary()) : null
|
|
180
|
+
if (linkProblem) {
|
|
181
|
+
console.warn(`[prompt-draft] auto-clean answer refused: ${linkProblem}`)
|
|
182
|
+
return { text: cleaned, outcome: 'guarded', guard: 'link', polishSkipped: 'link_guard' }
|
|
183
|
+
}
|
|
184
|
+
let answer = polished || cleaned
|
|
185
|
+
if (canaried && bucket !== 'meeting') {
|
|
186
|
+
// 6.91.0: a terminal gets the speaker's words. Anything but fillers, stutters, punctuation and
|
|
187
|
+
// case changed means the input goes out; otherwise the input's casing is put back and no
|
|
188
|
+
// closing period is added (dictation-register.ts literalAnswer).
|
|
189
|
+
if (register === 'literal') {
|
|
190
|
+
const literal = literalAnswer(cleaned, answer)
|
|
191
|
+
if (literal === null) {
|
|
192
|
+
console.warn('[prompt-draft] auto-clean answer guarded: literal')
|
|
193
|
+
return { text: cleaned, outcome: 'guarded', guard: 'literal', polishSkipped: 'failed' }
|
|
194
|
+
}
|
|
195
|
+
answer = literal || cleaned
|
|
196
|
+
}
|
|
197
|
+
// 6.91.0: nor may it lose (or add) a negation, number, date or name the speaker kept. A
|
|
198
|
+
// self-correction may drop what it replaced. Counts only in the log, never a word.
|
|
199
|
+
if (meaningGuardEnabled()) {
|
|
200
|
+
let problem: ReturnType<typeof meaningGuardProblem>
|
|
201
|
+
try {
|
|
202
|
+
problem = meaningGuardProblem(cleaned, answer, [...getVocabulary(), ...getAllSpeakerNames()])
|
|
203
|
+
} catch {
|
|
204
|
+
// A guard that could not read the answer does not vouch for it.
|
|
205
|
+
console.warn('[prompt-draft] auto-clean answer guarded: unverified')
|
|
206
|
+
return { text: cleaned, outcome: 'guarded', polishSkipped: 'failed' }
|
|
207
|
+
}
|
|
208
|
+
if (problem) {
|
|
209
|
+
console.warn(`[prompt-draft] auto-clean answer guarded: ${meaningGuardCounts(problem)}`)
|
|
210
|
+
return { text: cleaned, outcome: 'guarded', guard: problem.kind, polishSkipped: 'failed' }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { text: answer, outcome: answer !== cleaned ? 'polished' : 'unchanged' }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function polishOutboundDictation(
|
|
218
|
+
text: string,
|
|
219
|
+
opts: OutboundOptions,
|
|
220
|
+
): Promise<PolishResult> {
|
|
221
|
+
const cleaned = prepareOutboundDictation(text, opts.bucket)
|
|
222
|
+
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
223
|
+
const model = opts.model ?? 'haiku'
|
|
224
|
+
const autoCleanBreaker = autoCleanBreakers[model]
|
|
225
|
+
if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
|
|
226
|
+
// 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
|
|
227
|
+
if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
|
|
228
|
+
if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
|
|
229
|
+
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
230
|
+
const startedAt = Date.now()
|
|
231
|
+
recordPolishCall(opts.bucket)
|
|
232
|
+
// Read once per polish: the warm and cold paths see the same prompt.
|
|
233
|
+
const meeting = opts.bucket === 'meeting'
|
|
234
|
+
const promptVersion = polishPromptVersionFor(opts.bucket, opts.promptVersion, model)
|
|
235
|
+
const customInstructions = polishInstructionsFor(opts.bucket)
|
|
236
|
+
const instructionsHash = customInstructionsHash(customInstructions)
|
|
237
|
+
// 6.91.0: the register, the learned style and the meaning guard only where they were canaried
|
|
238
|
+
// (Sonnet with v4); every other pair gets none of them, so its prompt and its answer are 6.90.0's.
|
|
239
|
+
// Meeting lines never reach v4 (polishPromptVersionFor), so they never get any.
|
|
240
|
+
const canaried = styleAndRegisterApply(model, promptVersion)
|
|
241
|
+
const register: DictationRegister = canaried ? opts.register ?? 'default' : 'default'
|
|
242
|
+
const style = canaried ? learnedStyleFor(opts.bucket, register) : { lines: '', hash: '' }
|
|
243
|
+
const used: PolishInstructionsUse = {
|
|
244
|
+
...(customInstructions ? { customInstructions: true, instructionsHash } : {}),
|
|
245
|
+
...(style.lines ? { learnedStyle: true, learnedStyleHash: style.hash } : { learnedStyleOff: true }),
|
|
246
|
+
...(canaried ? { polishRegister: register } : {}),
|
|
247
|
+
...(promptVersion === 'v3' || promptVersion === 'v4' ? { polishPrompt: promptVersion } : {}),
|
|
248
|
+
}
|
|
249
|
+
const promptExtras = { customInstructions, learnedStyle: style.lines, register, promptVersion }
|
|
250
|
+
// 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
|
|
251
|
+
const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
|
|
252
|
+
let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
|
|
253
|
+
try {
|
|
254
|
+
let polished: string | null = null
|
|
255
|
+
if (warmEntry) {
|
|
256
|
+
try {
|
|
257
|
+
polished = (await runWarmPolish(warmEntry, cleaned, getVocabulary(), { signal: opts.signal, ...promptExtras })).trim()
|
|
258
|
+
prewarm = 'used'
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// Nothing was sent: the cold path below is the same call, not a second one.
|
|
261
|
+
if (!(err instanceof PrewarmUnavailableError)) throw err
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (polished === null) polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal, ...promptExtras })).trim()
|
|
265
|
+
// An empty answer is a failed polish, as 6.90.0 reported it (both model paths already refuse
|
|
266
|
+
// one; this holds if either ever stops): the input goes out, polishSkipped "failed", and the
|
|
267
|
+
// breaker counts it. Never "unchanged".
|
|
268
|
+
if (!polished) throw new Error('Auto-clean returned empty text')
|
|
269
|
+
autoCleanBreaker.recordSuccess()
|
|
270
|
+
const guarded = guardPolishAnswer(cleaned, polished, opts.bucket, register, canaried)
|
|
271
|
+
logTokenAudit({
|
|
272
|
+
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
273
|
+
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
274
|
+
polishInstructions: { on: Boolean(customInstructions), hash: instructionsHash },
|
|
275
|
+
// 6.91.0: codes and a hash only, never the app name or a word.
|
|
276
|
+
learnedStyle: { on: Boolean(style.lines), hash: style.hash },
|
|
277
|
+
...(canaried ? { polishRegister: register } : {}),
|
|
278
|
+
...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
|
|
279
|
+
polishOutcome: guarded.outcome, ...(guarded.guard ? { polishGuard: guarded.guard } : {}),
|
|
280
|
+
})
|
|
281
|
+
return {
|
|
282
|
+
text: guarded.text, ...(prewarm ? { prewarm } : {}),
|
|
283
|
+
...(guarded.polishSkipped ? { polishSkipped: guarded.polishSkipped } : {}),
|
|
284
|
+
polishOutcome: guarded.outcome, ...(guarded.guard ? { guard: guarded.guard } : {}),
|
|
285
|
+
modelText: polished, ...used,
|
|
286
|
+
}
|
|
287
|
+
} catch (err: any) {
|
|
288
|
+
autoCleanBreaker.recordFailure()
|
|
289
|
+
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
290
|
+
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -6,8 +6,8 @@ import { join } from 'node:path'
|
|
|
6
6
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
7
7
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
8
8
|
import {
|
|
9
|
-
buildDictationPolishPrompt, claudePolishArgs, dictationChildEnv, dictationPolishPromptVersion,
|
|
10
|
-
type DictationPolishPromptVersion,
|
|
9
|
+
buildDictationPolishPrompt, claudePolishArgs, DICTATION_POLISH_SYSTEM_PROMPTS, dictationChildEnv, dictationPolishPromptVersion,
|
|
10
|
+
type DictationPolishPromptVersion, type DictationPromptExtras,
|
|
11
11
|
} from './dictation-clean.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -67,11 +67,11 @@ function retire(entry: WarmPolish): void {
|
|
|
67
67
|
if (!entry.exited) void terminateProviderProcess(entry.proc).catch(() => {})
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
/** `version` defaults to the configured prompt; only the offline evals pass one (6.91.0). */
|
|
71
|
+
export function startPolishPrewarm(model: PrewarmModel, version: DictationPolishPromptVersion = dictationPolishPromptVersion(process.env, model)): { token: string; expiresMs: number } {
|
|
71
72
|
const binary = resolveProviderBinary('claude')
|
|
72
73
|
if (!binary.ok) throw new PrewarmUnavailableError('Dictation cleanup Claude CLI unavailable')
|
|
73
74
|
while (warm.size >= PREWARM_MAX) retire(warm.values().next().value!)
|
|
74
|
-
const version = dictationPolishPromptVersion()
|
|
75
75
|
const scratch = mkdtempSync(join(tmpdir(), 'cos-dictation-prewarm-'))
|
|
76
76
|
const workspace = join(scratch, 'workspace')
|
|
77
77
|
mkdirSync(workspace, { mode: 0o700 })
|
|
@@ -134,6 +134,15 @@ export function takePolishPrewarm(token: string | undefined, model: string): War
|
|
|
134
134
|
return entry
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 6.91.0: the version the one message is built with. A caller may ask for another version (meeting
|
|
139
|
+
* lines stay on v3 while dictation runs v4) only when both share the system prompt the process was
|
|
140
|
+
* started with; otherwise the process's own version is used, as before.
|
|
141
|
+
*/
|
|
142
|
+
export function messageVersion(started: DictationPolishPromptVersion, wanted: DictationPolishPromptVersion | undefined): DictationPolishPromptVersion {
|
|
143
|
+
return wanted && DICTATION_POLISH_SYSTEM_PROMPTS[wanted] === DICTATION_POLISH_SYSTEM_PROMPTS[started] ? wanted : started
|
|
144
|
+
}
|
|
145
|
+
|
|
137
146
|
/**
|
|
138
147
|
* Write the one message and read the one result. PrewarmUnavailableError means nothing was
|
|
139
148
|
* sent (the process had already gone), so the caller may spawn cold. Any later failure means
|
|
@@ -143,7 +152,7 @@ export async function runWarmPolish(
|
|
|
143
152
|
entry: WarmPolish,
|
|
144
153
|
text: string,
|
|
145
154
|
terms: string[],
|
|
146
|
-
opts: { signal?: AbortSignal; customInstructions?: string } = {},
|
|
155
|
+
opts: { signal?: AbortSignal; customInstructions?: string; promptVersion?: DictationPolishPromptVersion } & DictationPromptExtras = {},
|
|
147
156
|
): Promise<string> {
|
|
148
157
|
if (entry.exited || !entry.proc.stdin || entry.proc.stdin.destroyed) {
|
|
149
158
|
retire(entry)
|
|
@@ -180,7 +189,10 @@ export async function runWarmPolish(
|
|
|
180
189
|
opts.signal?.addEventListener('abort', abort, { once: true })
|
|
181
190
|
const message = JSON.stringify({
|
|
182
191
|
type: 'user',
|
|
183
|
-
message: {
|
|
192
|
+
message: {
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: buildDictationPolishPrompt(text, terms, messageVersion(entry.version, opts.promptVersion), opts.customInstructions ?? '', { learnedStyle: opts.learnedStyle, register: opts.register }),
|
|
195
|
+
},
|
|
184
196
|
})
|
|
185
197
|
try {
|
|
186
198
|
entry.proc.stdin!.write(message + '\n')
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readSwitch } from './env-switch.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 6.91.0: the register a dictation is written in, chosen from the app it is going to.
|
|
5
|
+
*
|
|
6
|
+
* COS Control has sent the frontmost app's localized name as `context.app` on every Mac polish
|
|
7
|
+
* since 0.5.325; until 6.91.0 the server ignored it. The MVP has two registers (plan r2):
|
|
8
|
+
*
|
|
9
|
+
* - literal: terminals (iTerm2, Terminal, Ghostty, Warp, WezTerm, Alacritty, kitty). The model
|
|
10
|
+
* adds no sentence capital, no trailing period and no learned style, and keeps the speaker's
|
|
11
|
+
* words (self-corrections and spoken paths included); a deterministic check then requires the
|
|
12
|
+
* answer's words to equal its input's apart from fillers, stutters, punctuation and case, and
|
|
13
|
+
* falls back to the input otherwise (finalize `guard: "literal"`).
|
|
14
|
+
* - default: everything else, Claude, Cursor and Codex included, and an unknown, blank or
|
|
15
|
+
* malformed app name. G2 messages (no app) and meeting lines are default too.
|
|
16
|
+
*
|
|
17
|
+
* ONE table, matched on the exact name (case and surrounding space ignored), published to Control
|
|
18
|
+
* by GET /api/dictation/learned-style (`registers`) so Control never keeps a second copy. The app
|
|
19
|
+
* name is read here, never logged, audited or echoed.
|
|
20
|
+
*
|
|
21
|
+
* The literal register is OPT-IN (COS_DICTATION_REGISTERS=1): terminal targets are mostly AI
|
|
22
|
+
* prompts in prose (Claude Code, Codex), not shell commands, and literal would stop resolving
|
|
23
|
+
* corrections, fixing grammar and capitalizing them. Off (the default), a terminal gets the
|
|
24
|
+
* default register, and the prompt carries no register block at all. The map is published either
|
|
25
|
+
* way; `registersEnabled` says whether it is applied.
|
|
26
|
+
*/
|
|
27
|
+
export type DictationRegister = 'literal' | 'default'
|
|
28
|
+
export const DICTATION_REGISTERS: readonly DictationRegister[] = ['literal', 'default']
|
|
29
|
+
|
|
30
|
+
export const LITERAL_APPS: readonly string[] = ['iTerm2', 'Terminal', 'Ghostty', 'Warp', 'WezTerm', 'Alacritty', 'kitty']
|
|
31
|
+
|
|
32
|
+
const LITERAL = new Set(LITERAL_APPS.map(app => app.toLowerCase()))
|
|
33
|
+
|
|
34
|
+
/** Off unless COS_DICTATION_REGISTERS is 1, true or on. */
|
|
35
|
+
export function registersEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
+
return readSwitch(env.COS_DICTATION_REGISTERS, false)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The register for a frontmost-app name: an exact table match (case and surrounding space ignored)
|
|
41
|
+
* or `default`. A long, multi-line or non-string value can never match the table, so it is default
|
|
42
|
+
* without a separate check (the 6.91.0 mutation gate showed one was unreachable).
|
|
43
|
+
*/
|
|
44
|
+
export function registerForApp(app: unknown, env: NodeJS.ProcessEnv = process.env): DictationRegister {
|
|
45
|
+
if (!registersEnabled(env) || typeof app !== 'string') return 'default'
|
|
46
|
+
return LITERAL.has(app.trim().toLowerCase()) ? 'literal' : 'default'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `context.app` from a finalize body, without trusting its shape (a primitive or array has no `app`). */
|
|
50
|
+
export function registerFromContext(context: unknown, env: NodeJS.ProcessEnv = process.env): DictationRegister {
|
|
51
|
+
return registerForApp((context as { app?: unknown } | null | undefined)?.app, env)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What GET /api/dictation/learned-style publishes: every app that is not default, applied or not. */
|
|
55
|
+
export function publishedRegisters(): Record<string, 'literal'> {
|
|
56
|
+
return Object.fromEntries(LITERAL_APPS.map(app => [app, 'literal' as const]))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Verbal tics the literal check lets the model drop (one word or two). Never a negation or number. */
|
|
60
|
+
const LITERAL_DROPPABLE = new Set(['um', 'umm', 'uh', 'uhh', 'uhm', 'er', 'erm', 'ah', 'ahh', 'hmm', 'hm', 'mm', 'mhm',
|
|
61
|
+
'like', 'basically', 'so', 'well'])
|
|
62
|
+
const LITERAL_DROPPABLE_PAIRS = new Set(['you know', 'i mean', 'kind of', 'sort of'])
|
|
63
|
+
|
|
64
|
+
interface Word { lower: string; start: number; end: number }
|
|
65
|
+
function words(text: string): Word[] {
|
|
66
|
+
const out: Word[] = []
|
|
67
|
+
const re = /[\p{L}\p{N}]+(?:['’][\p{L}]+)*/gu
|
|
68
|
+
let m: RegExpExecArray | null
|
|
69
|
+
while ((m = re.exec(text))) out.push({ lower: m[0].toLowerCase().replace(/’/g, "'"), start: m.index, end: m.index + m[0].length })
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The words that carry the text: fillers out, a stutter ("the the") collapsed. */
|
|
74
|
+
function core(list: Word[]): Word[] {
|
|
75
|
+
const out: Word[] = []
|
|
76
|
+
for (let i = 0; i < list.length; i++) {
|
|
77
|
+
const pair = i + 1 < list.length ? `${list[i].lower} ${list[i + 1].lower}` : ''
|
|
78
|
+
if (pair && LITERAL_DROPPABLE_PAIRS.has(pair)) { i++; continue }
|
|
79
|
+
if (LITERAL_DROPPABLE.has(list[i].lower)) continue
|
|
80
|
+
if (out.length && out[out.length - 1].lower === list[i].lower) continue
|
|
81
|
+
out.push(list[i])
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The literal register's deterministic check and repair. Pure.
|
|
88
|
+
*
|
|
89
|
+
* `null` when the answer changed a word (beyond fillers, stutters, punctuation and case): the
|
|
90
|
+
* caller sends the input instead. Otherwise the answer with the input's own casing put back on
|
|
91
|
+
* every word, and without a trailing period the input did not have, so a terminal gets no added
|
|
92
|
+
* capital and no added period whatever the model did.
|
|
93
|
+
*/
|
|
94
|
+
export function literalAnswer(input: string, answer: string): string | null {
|
|
95
|
+
const a = core(words(answer))
|
|
96
|
+
const b = core(words(input))
|
|
97
|
+
if (a.length !== b.length || a.some((w, i) => w.lower !== b[i].lower)) return null
|
|
98
|
+
const inputWords = words(input)
|
|
99
|
+
const original = new Map<string, string[]>()
|
|
100
|
+
for (const w of inputWords) original.set(w.lower, [...(original.get(w.lower) ?? []), input.slice(w.start, w.end)])
|
|
101
|
+
let out = ''
|
|
102
|
+
let last = 0
|
|
103
|
+
const seen = new Map<string, number>()
|
|
104
|
+
for (const w of words(answer)) {
|
|
105
|
+
const forms = original.get(w.lower)
|
|
106
|
+
const k = seen.get(w.lower) ?? 0
|
|
107
|
+
seen.set(w.lower, k + 1)
|
|
108
|
+
out += answer.slice(last, w.start) + (forms ? forms[Math.min(k, forms.length - 1)] : answer.slice(w.start, w.end))
|
|
109
|
+
last = w.end
|
|
110
|
+
}
|
|
111
|
+
out += answer.slice(last)
|
|
112
|
+
if (/\.\s*$/.test(out) && !/\.\s*$/.test(input)) out = out.replace(/\.(\s*)$/, '$1')
|
|
113
|
+
return out.trim()
|
|
114
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 6.90.0: unnamed, meeting-stable voice labels for Mac recordings.
|
|
2
|
+
//
|
|
3
|
+
// A Mac recording hears call audio, video and podcasts through one global tap.
|
|
4
|
+
// Naming a voice from that audio is not safe (no calibration exists, see
|
|
5
|
+
// CHANGELOG 6.73.6 and the 2026-10-14 Mac plan section 5), so every Mac chunk is
|
|
6
|
+
// `Ext`. What IS safe is telling voices apart: one Nemotron pass over the whole
|
|
7
|
+
// saved meeting gives channels that are consistent for the call, and each
|
|
8
|
+
// confident channel becomes `Voice 1`, `Voice 2`, ... in order of first speech.
|
|
9
|
+
//
|
|
10
|
+
// A `Voice N` label asserts NO identity. Everywhere a real name is checked it is
|
|
11
|
+
// a placeholder, exactly like `Ext` and `Unidentified N`: it is never a profile
|
|
12
|
+
// name, never enrolled, never trained, never written into the meeting's
|
|
13
|
+
// `speakers` list. A person may rename it (text only) through the relabel route.
|
|
14
|
+
//
|
|
15
|
+
// Switch: COS_MAC_VOICE_LABELS, on by default; 0, false, off or no turns it off
|
|
16
|
+
// and Mac recordings stay all `Ext` exactly as 6.89.0 saved them.
|
|
17
|
+
|
|
18
|
+
// No imports on purpose: placeholder checks are read from the speaker-review,
|
|
19
|
+
// relabel and store layers, and this must never pull the Mac registry into them.
|
|
20
|
+
const MAC_SOURCE_TYPE = 'mac_system_audio'
|
|
21
|
+
|
|
22
|
+
/** `Voice 1` .. `Voice 99`. No leading zero, single space, nothing after. */
|
|
23
|
+
export const VOICE_LABEL = /^Voice [1-9]\d?$/
|
|
24
|
+
|
|
25
|
+
export function isVoiceLabel(label: unknown): boolean {
|
|
26
|
+
return typeof label === 'string' && VOICE_LABEL.test(label)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function voiceLabel(n: number): string {
|
|
30
|
+
if (!Number.isInteger(n) || n < 1 || n > 99) throw new Error('voice number out of range')
|
|
31
|
+
return `Voice ${n}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** On unless explicitly switched off. Read at use, so a managed restart is not needed to turn it off. */
|
|
35
|
+
export function macVoiceLabelsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
36
|
+
const raw = String(env.COS_MAC_VOICE_LABELS ?? '').trim().toLowerCase()
|
|
37
|
+
return !['0', 'false', 'off', 'no'].includes(raw)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A sidecar written by the Mac recording save path (`sourceType`), or any `mac_`
|
|
42
|
+
* id. Deliberately broader than MAC_CAPTURE_ID: this answers "could this be Mac
|
|
43
|
+
* audio", and a guard that asks it must fail closed on a malformed Mac id too.
|
|
44
|
+
*/
|
|
45
|
+
export function isMacCaptureSidecar(sidecar: Record<string, unknown> | null | undefined, sessionId?: string): boolean {
|
|
46
|
+
if (typeof sessionId === 'string' && sessionId.startsWith('mac_')) return true
|
|
47
|
+
if (!sidecar || typeof sidecar !== 'object') return false
|
|
48
|
+
if (sidecar.sourceType === MAC_SOURCE_TYPE) return true
|
|
49
|
+
return typeof sidecar.sessionId === 'string' && sidecar.sessionId.startsWith('mac_')
|
|
50
|
+
}
|