@gotcos/glasses-server 6.65.0 → 6.66.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 CHANGED
@@ -340,3 +340,8 @@ BIND_HOST=0.0.0.0
340
340
  # additionally requires a client that acknowledges displaying continue_note.
341
341
  # Keep this in ~/.cos-glasses/.env; Control does not yet persist it on update.
342
342
  COS_CONTINUE_FULL_PERMISSIONS=1
343
+
344
+ # Optional absolute executable paths for COS-managed whisper.cpp.
345
+ # Unset uses Homebrew then PATH. An invalid explicit path fails closed.
346
+ # COS_WHISPER_CLI_BIN=/absolute/path/to/whisper-cli
347
+ # COS_WHISPER_SERVER_BIN=/absolute/path/to/whisper-server
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 6.66.0 — 2026-10-08 — Managed voice setup and Haiku polish
2
+
3
+ - Advertise Haiku, Sonnet and GPT-5.6 Luna Fast. Default omitted-model polish to Haiku for lower cost, while honoring explicit model choices and retaining the transcript on failure.
4
+ - Glasses app 6.10.616 explicitly sends the chosen model and already works with server 6.65.0.
5
+ - Setup, live preview, committed transcription and HQ share explicit `COS_WHISPER_CLI_BIN` / `COS_WHISPER_SERVER_BIN` resolution. A broken explicit path fails closed; existing Homebrew/PATH detection remains the fallback.
6
+ - COS Control can prepare Turbo first for a local benchmark, then the chosen tier, without overwriting saved voice settings. Both preparation flags require `--setup-transcription --prepare-only`.
7
+ - Pairs with COS Control 0.5.273 and the signed/notarized whisper.cpp 1.9.1 runtime.
8
+ - Claude health probes resolve the installed absolute binary path, including managed-service environments without the interactive shell PATH.
9
+
1
10
  ## 6.65.0 — 2026-10-07 — Work search by meaning, and task dates
2
11
 
3
12
  - `POST /api/work/search` `{ query, domain?, scope?, ids? }` finds the Work card the user means, not only the words they typed. Pairs with COS Control 0.5.259, which shows its own word matches at once and asks this after a pause.
package/README.md CHANGED
@@ -646,18 +646,24 @@ and counts only, never a speaker name, path or error text; those stay in
646
646
 
647
647
  ## Final dictation polish (6.61.5)
648
648
 
649
- With glasses app 6.10.573, **Settings → Voice → Polish model** offers **Sonnet**
650
- (the default, using your Claude CLI sign-in) and **GPT-5.6 Luna Fast** (using your
651
- Cursor CLI sign-in). Luna uses `gpt-5.6-luna-none-fast`; it needs server 6.61.5.
652
- Both phone text and recovered audio drafts honor the choice. The server advertises
653
- these options in `capabilities.dictationCleanup.models` on health and models.
649
+ With glasses app 6.10.616, **Settings → Voice → Polish model** offers **Haiku 5.5**
650
+ (the lower-cost default), **Sonnet 5.5** (higher-quality polish), and **GPT-5.6 Luna Fast**.
651
+ Haiku and Sonnet use your Claude CLI sign-in; Luna uses your Cursor CLI sign-in.
652
+ Luna uses `gpt-5.6-luna-none-fast` and needs server 6.61.5. Existing explicit choices
653
+ are preserved. Unset or unknown app preferences use Haiku. Both phone text and
654
+ recovered audio drafts honor the choice. Existing server 6.65.0 already accepts
655
+ explicit Haiku requests, so the new app does not require a server update.
656
+
657
+ The next server release also defaults omitted-model requests to Haiku and advertises
658
+ all three options in `capabilities.dictationCleanup.models`. Until that release,
659
+ 6.65.0 still defaults requests without a model to Sonnet; 6.10.616 sends its choice
660
+ explicitly. This is a polish-model change, not a change to the speech recognition engine.
654
661
 
655
662
  Cleanup uses subscription CLIs only, with no API-key fallback or automatic model
656
663
  substitution. A failed or unavailable cleanup keeps the glossary-corrected
657
664
  transcript. Turn **Final polish (AI)** off to skip the model call. Claude tools
658
665
  and hooks are disabled; Cursor runs in read-only ask mode with a temporary profile
659
666
  and empty workspace. Live preview and canonical meeting transcription are unchanged.
660
- Older clients may still explicitly request Haiku.
661
667
 
662
668
  ## HQ dictation
663
669
 
@@ -917,3 +923,23 @@ This is a local owner instance, separate from app.gotcos.com tenants and hosted
917
923
  ## Provider parity in 6.62.0
918
924
 
919
925
  See [provider capabilities and limits](docs/provider-parity.md) for Continue permissions, client compatibility, hook trust, observation limits and rollback. Glasses 6.10.600 shows the server's Continue note. Older clients retain Cursor Ask behavior; forks remain read-only.
926
+
927
+ ### Managed Whisper runtime (6.66.0 candidate)
928
+
929
+ COS Control 0.5.272 prepares a signed, notarized whisper.cpp 1.9.1 runtime during
930
+ voice setup on Apple silicon. It supplies absolute `COS_WHISPER_CLI_BIN` and
931
+ `COS_WHISPER_SERVER_BIN` paths to the server; Homebrew is not needed for that flow.
932
+ Direct server installs still fall back to Homebrew and then PATH when overrides
933
+ are unset. A configured but missing/non-executable override is an error, never a
934
+ silent change of runtime.
935
+
936
+ `--setup-transcription --prepare-only --voice-benchmark-prepare` prepares Turbo
937
+ without changing saved tier keys. Control measures the device and follows with
938
+ `--setup-transcription --prepare-only --transcription-tier balanced|max
939
+ --preserve-voice-settings`. The latter prepares all required models, including
940
+ Large-v3 for saved-meeting HQ in both tiers. Applying a tier remains Control's
941
+ transactional action. Downloading or cancelling preparation does not change it.
942
+ Runtime/model setup is local; the existing provider sign-in requirements remain.
943
+
944
+ Candidate only: publish the versioned runtime asset and server package before
945
+ promoting the matching Control appcast entry.
package/bin/cli.cjs CHANGED
@@ -25,7 +25,12 @@ const { homedir } = require('os')
25
25
  const PKG_ROOT = resolve(__dirname, '..')
26
26
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
27
  const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
+ const VOICE_BENCHMARK_PREPARE = process.argv.includes('--voice-benchmark-prepare')
28
29
  const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
30
+ if ((VOICE_BENCHMARK_PREPARE || process.argv.includes('--preserve-voice-settings')) && !(SETUP_TRANSCRIPTION && PREPARE_ONLY)) {
31
+ console.error('Voice preparation flags require --setup-transcription --prepare-only.');
32
+ process.exit(64)
33
+ }
29
34
  const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
30
35
  const HOOKS_ACTION = process.argv.includes('--hooks') ? process.argv[process.argv.indexOf('--hooks') + 1] : undefined
31
36
  function optionValue(name) {
@@ -540,12 +545,15 @@ function upsertEnvValue(file, key, value) {
540
545
  chmodSync(file, 0o600)
541
546
  }
542
547
 
543
- if (SETUP_TRANSCRIPTION) {
548
+ if (SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
549
+ const preserveVoiceSettings = process.argv.includes('--preserve-voice-settings')
544
550
  const previewModel = TRANSCRIPTION_TIER === 'max' ? 'turbo' : 'small.en'
545
551
  const commitModel = TRANSCRIPTION_TIER === 'max' ? 'large-v3' : 'turbo'
546
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
547
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
548
- upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
552
+ if (!preserveVoiceSettings) {
553
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_TRANSCRIPTION_TIER', TRANSCRIPTION_TIER)
554
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_PREVIEW_MODEL', previewModel)
555
+ upsertEnvValue(ENV_FILE, 'COS_WHISPER_COMMIT_MODEL', commitModel)
556
+ }
549
557
  process.env.COS_WHISPER_TRANSCRIPTION_TIER = TRANSCRIPTION_TIER
550
558
  process.env.COS_WHISPER_PREVIEW_MODEL = previewModel
551
559
  process.env.COS_WHISPER_COMMIT_MODEL = commitModel
@@ -573,8 +581,7 @@ if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
573
581
 
574
582
  // Step 5: local Whisper detection + model download. Voice stays local-only by
575
583
  // default; cloud fallback requires an explicit flag plus a configured key.
576
- const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
577
- const WHISPER_SERVER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
584
+ const { resolveWhisperBinary } = require('./whisper-runtime.cjs')
578
585
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
579
586
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
580
587
  const WHISPER_MODEL_PARTIAL = WHISPER_MODEL_PATH + '.partial'
@@ -591,24 +598,8 @@ const WHISPER_LARGE_MODEL_MIN_BYTES = 2_800_000_000
591
598
  const WHISPER_MODEL_EXPECTED_BYTES = 1_624_555_275
592
599
  const WHISPER_SMALL_MODEL_EXPECTED_BYTES = 487_614_201
593
600
  const WHISPER_LARGE_MODEL_EXPECTED_BYTES = 3_095_033_483
594
- function findWhisperCli() {
595
- for (const p of WHISPER_KNOWN_PATHS) { if (existsSync(p)) return p }
596
- try {
597
- const found = execSync('command -v whisper-cli 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
598
- return found || null
599
- } catch {
600
- return null
601
- }
602
- }
603
- function findWhisperServer() {
604
- for (const p of WHISPER_SERVER_KNOWN_PATHS) { if (existsSync(p)) return p }
605
- try {
606
- const found = execSync('command -v whisper-server 2>/dev/null', { shell: '/bin/sh', stdio: 'pipe', timeout: 2000 }).toString().trim()
607
- return found || null
608
- } catch {
609
- return null
610
- }
611
- }
601
+ function findWhisperCli() { return resolveWhisperBinary('whisper-cli') }
602
+ function findWhisperServer() { return resolveWhisperBinary('whisper-server') }
612
603
  function isValidWhisperModel(p, minBytes = WHISPER_MODEL_MIN_BYTES) {
613
604
  if (!existsSync(p)) return false
614
605
  try { return statSync(p).size >= minBytes } catch { return false }
@@ -652,10 +643,10 @@ if (SETUP_TRANSCRIPTION && process.env.SKIP_WHISPER_DOWNLOAD !== '1') {
652
643
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
653
644
  const targets = [
654
645
  [WHISPER_MODEL_PATH, WHISPER_MODEL_PARTIAL, WHISPER_MODEL_MIN_BYTES, WHISPER_MODEL_EXPECTED_BYTES],
655
- ...(TRANSCRIPTION_TIER === 'balanced'
646
+ ...(!VOICE_BENCHMARK_PREPARE && TRANSCRIPTION_TIER === 'balanced'
656
647
  ? [[WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_PARTIAL, WHISPER_SMALL_MODEL_MIN_BYTES, WHISPER_SMALL_MODEL_EXPECTED_BYTES]]
657
648
  : []),
658
- [WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES],
649
+ ...(!VOICE_BENCHMARK_PREPARE ? [[WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_PARTIAL, WHISPER_LARGE_MODEL_MIN_BYTES, WHISPER_LARGE_MODEL_EXPECTED_BYTES]] : []),
659
650
  ]
660
651
  const missingBytes = targets.reduce((sum, [path, , minimum, expected]) =>
661
652
  sum + (isValidWhisperModel(path, minimum) ? 0 : expected), 0)
@@ -716,7 +707,7 @@ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
716
707
 
717
708
  const previewChoice = (process.env.COS_WHISPER_PREVIEW_MODEL || process.env.COS_WHISPER_REALTIME_MODEL || 'auto').trim().toLowerCase()
718
709
  const wantsSmallPreview = ['small', 'small.en', 'ggml-small.en.bin'].includes(previewChoice)
719
- if (whisperCliPath && wantsSmallPreview) {
710
+ if (whisperCliPath && wantsSmallPreview && !VOICE_BENCHMARK_PREPARE) {
720
711
  if (isValidWhisperModel(WHISPER_SMALL_MODEL_PATH, WHISPER_SMALL_MODEL_MIN_BYTES)) {
721
712
  console.log(green(' ✓') + ' Small.en preview model ready ' + dim('— Turbo remains authoritative'))
722
713
  } else {
@@ -745,7 +736,7 @@ if (whisperCliPath && wantsSmallPreview) {
745
736
  }
746
737
  }
747
738
 
748
- if (whisperCliPath && SETUP_TRANSCRIPTION) {
739
+ if (whisperCliPath && SETUP_TRANSCRIPTION && !VOICE_BENCHMARK_PREPARE) {
749
740
  if (isValidWhisperModel(WHISPER_LARGE_MODEL_PATH, WHISPER_LARGE_MODEL_MIN_BYTES)) {
750
741
  const largeRole = TRANSCRIPTION_TIER === 'max'
751
742
  ? 'live preview + commit and saved-meeting polish'
@@ -789,7 +780,7 @@ if (whisperCliPath && SETUP_TRANSCRIPTION) {
789
780
  // -- sixty times larger -- downloaded here with SKIP_WHISPER_DOWNLOAD as the
790
781
  // escape hatch. Same shape, same opt-out, so nobody on a metered or restricted
791
782
  // network is forced into it.
792
- if (!SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
783
+ if (!VOICE_BENCHMARK_PREPARE && !SETUP_SPEAKER_MODEL && process.env.SKIP_SPEAKER_MODEL_DOWNLOAD !== '1') {
793
784
  const spkDest = join(CONFIG_DIR, 'models', SPEAKER_MODEL.filename)
794
785
  if (existsSync(spkDest) && sha256File(spkDest) === SPEAKER_MODEL.sha256) {
795
786
  console.log(green(' ✓') + ' Voiceprint model ready ' + dim('— named speakers available'))
@@ -0,0 +1,17 @@
1
+ // Shared by setup and both live Whisper workers. Explicit overrides fail closed.
2
+ const { accessSync, constants, statSync } = require('node:fs')
3
+ const { isAbsolute, join } = require('node:path')
4
+ function executable(path) {
5
+ try { accessSync(path, constants.X_OK); return statSync(path).isFile() } catch { return false }
6
+ }
7
+ function resolveWhisperBinary(name, env = process.env, available = executable) {
8
+ if (!['whisper-cli', 'whisper-server'].includes(name)) throw new Error('Unknown Whisper binary')
9
+ const key = name === 'whisper-cli' ? 'COS_WHISPER_CLI_BIN' : 'COS_WHISPER_SERVER_BIN'
10
+ if (env[key] !== undefined && env[key] !== '') {
11
+ const path = env[key].trim()
12
+ return isAbsolute(path) && available(path) ? path : null
13
+ }
14
+ const candidates = ['/opt/homebrew/bin', '/usr/local/bin', ...(env.PATH || '').split(':')]
15
+ return candidates.filter(isAbsolute).map(p => join(p, name)).find(available) || null
16
+ }
17
+ module.exports = { resolveWhisperBinary }
@@ -0,0 +1 @@
1
+ export function resolveWhisperBinary(name: 'whisper-cli' | 'whisper-server', env?: NodeJS.ProcessEnv, available?: (path: string) => boolean): string | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.65.0",
3
+ "version": "6.66.0",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,7 +77,9 @@
77
77
  "docs/work-task-runtime.md",
78
78
  "!server/scripts/build-work-task-runtime.py",
79
79
  "bin/hooks/cos-cursor-observer.cjs",
80
- "docs/provider-parity.md"
80
+ "docs/provider-parity.md",
81
+ "bin/whisper-runtime.cjs",
82
+ "bin/whisper-runtime.d.cts"
81
83
  ],
82
84
  "author": "COS Contributors",
83
85
  "license": "MIT",
@@ -1,6 +1,6 @@
1
- /** Public choices. Haiku remains accepted only for older clients. */
2
- export const DICTATION_CLEAN_MODELS = ['sonnet', 'luna-5.6-fast'] as const
3
- export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number] | 'haiku'
1
+ /** Public polish choices. Haiku is the cost-first default. */
2
+ export const DICTATION_CLEAN_MODELS = ['haiku', 'sonnet', 'luna-5.6-fast'] as const
3
+ export type DictationCleanModel = typeof DICTATION_CLEAN_MODELS[number]
4
4
  export const LUNA_DICTATION_CLI_MODEL = 'gpt-5.6-luna-none-fast'
5
5
  export function isDictationCleanModel(value: unknown): value is DictationCleanModel {
6
6
  return value === 'sonnet' || value === 'luna-5.6-fast' || value === 'haiku'
@@ -32,7 +32,7 @@ export async function autoCleanDictation(
32
32
  opts: { model?: string; signal?: AbortSignal } = {},
33
33
  ): Promise<string> {
34
34
  if (opts.signal?.aborted) throw new Error('Auto-clean aborted')
35
- const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'sonnet').toLowerCase()
35
+ const model = (opts.model || process.env.COS_DICTATION_AUTOCLEAN_MODEL || 'haiku').toLowerCase()
36
36
  if (!isDictationCleanModel(model)) throw new Error('Unsupported dictation cleanup model')
37
37
  const cursor = model === 'luna-5.6-fast'
38
38
  const binary = resolveProviderBinary(cursor ? 'cursor' : 'claude')
@@ -123,9 +123,11 @@ async function probePython(): Promise<string> {
123
123
  }
124
124
  }
125
125
 
126
- async function probeClaude(): Promise<{ value: string; available: boolean }> {
126
+ export async function probeClaude(): Promise<{ value: string; available: boolean }> {
127
127
  try {
128
- const result = await execute('claude', ['--version'])
128
+ const resolved = resolveProviderBinary('claude')
129
+ if (!resolved.ok) return { value: `unresolved (${resolved.detail})`, available: false }
130
+ const result = await execute(resolved.path, ['--version'])
129
131
  return { value: firstNonemptyLine(result.stdout) ?? 'available', available: true }
130
132
  } catch {
131
133
  return { value: 'error', available: false }
@@ -1,3 +1,4 @@
1
+ import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
1
2
  // Local Whisper transcription via whisper-server (persistent) or whisper-cli (fallback)
2
3
  // Eliminates 1-3s OpenAI API round-trip by running inference on M3 Ultra locally.
3
4
  //
@@ -130,17 +131,9 @@ interface WhisperJsonResponse {
130
131
  text?: unknown
131
132
  }
132
133
 
133
- // Resolve whisper.cpp binaries across Homebrew prefixes (Apple Silicon
134
- // /opt/homebrew, Intel /usr/local). Downstream code existsSync-guards these
135
- // before use, so a missing binary degrades to CLI/cloud rather than crashing.
136
- function resolveWhisperBin(name: string): string {
137
- for (const prefix of ['/opt/homebrew/bin', '/usr/local/bin']) {
138
- if (existsSync(`${prefix}/${name}`)) return `${prefix}/${name}`
139
- }
140
- return `/opt/homebrew/bin/${name}`
141
- }
142
- const WHISPER_CLI = resolveWhisperBin('whisper-cli')
143
- const WHISPER_SERVER = resolveWhisperBin('whisper-server')
134
+ // Same resolution as setup and preview; a broken explicit override never falls through to Homebrew.
135
+ const WHISPER_CLI = resolveWhisperBinary('whisper-cli') ?? '/__cos_whisper_unavailable__/whisper-cli'
136
+ const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
144
137
  const MODEL_DIR = join(process.env.HOME ?? homedir(), '.local/share/whisper-models')
145
138
  export const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
146
139
  export const WHISPER_LARGE_V3_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3.bin')
@@ -1,3 +1,4 @@
1
+ import { resolveWhisperBinary } from '../../bin/whisper-runtime.cjs'
1
2
  // Adaptive provisional transcription:
2
3
  // Balanced -> isolated Small.en cosmetic preview + Turbo live commit
3
4
  // Max -> isolated Turbo cosmetic preview + Large-v3 live commit
@@ -55,8 +56,7 @@ export const WHISPER_SMALL_EN_MODEL_PATH = join(MODEL_DIR, 'ggml-small.en.bin')
55
56
  const WHISPER_TURBO_MODEL_PATH = join(MODEL_DIR, 'ggml-large-v3-turbo.bin')
56
57
  const VAD_MODEL_PATH = join(MODEL_DIR, 'ggml-silero-v5.1.2.bin')
57
58
  const VAD_ENABLED = process.env.COS_WHISPER_VAD !== '0'
58
- const WHISPER_SERVER = ['/opt/homebrew/bin/whisper-server', '/usr/local/bin/whisper-server']
59
- .find(existsSync) ?? '/opt/homebrew/bin/whisper-server'
59
+ const WHISPER_SERVER = resolveWhisperBinary('whisper-server') ?? '/__cos_whisper_unavailable__/whisper-server'
60
60
  const PREVIEW_PORT = 8177
61
61
  const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}`
62
62
  const PROCESS_PROBE_TIMEOUT_MS = 2_000
@@ -136,7 +136,7 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
136
136
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
137
137
  }
138
138
  if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
139
- const model = opts.model ?? 'sonnet'
139
+ const model = opts.model ?? 'haiku'
140
140
  const autoCleanBreaker = autoCleanBreakers[model]
141
141
  if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
142
142
  const startedAt = Date.now()