@gotcos/glasses-server 6.1.0 → 6.2.1

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 CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.2.1
4
+
5
+ Foolproofing release — driven by an adversarial onboarding QA pass.
6
+
7
+ - **The server now prints URLs the phone can actually use.** Boot output lists
8
+ your real addresses (`http://100.x.x.x:3141` labeled Tailscale, LAN IPs labeled
9
+ same-Wi-Fi) instead of only the un-pasteable bind address `0.0.0.0`.
10
+ - **Auto-generated API tokens survive restarts.** First boot saves the token to
11
+ `~/.cos-glasses/.env`, so re-running the server no longer silently rotates the
12
+ credential your app already saved (the "worked yesterday, 401 today" trap).
13
+ - **Starter-Kit COS inheritance is real now.** Run `npx @gotcos/glasses-server`
14
+ from your COS folder and glasses chat loads its brain: the launcher records
15
+ your launch directory, and when it contains `.cos/manifest.json`, `AGENTS.md`,
16
+ or `CLAUDE.md`, Claude/Codex spawn there (explicit `COS_SCRIPTS_DIR` still wins).
17
+ - **Transfer-integrity report actually surfaces.** 6.2.0 recorded lost chunks but
18
+ never returned them; the offline-session `finalize` response now includes
19
+ `transferIntegrity` (received/expected/missing/completeness) and a gap-aware
20
+ `transcript` with inline `[… audio gap …]` markers.
21
+ - **One default model everywhere: Sonnet.** The query router, the OpenAI-compat
22
+ surface, and CLI pre-warm all default to Sonnet (was a mix of Opus and Haiku).
23
+ Set `COS_G2_DEFAULT_MODEL` to override; per-query picks unchanged.
24
+
25
+ ## 6.2.0
26
+
27
+ Reliability release — ports the hardening the full COS Glasses app shipped in June.
28
+
29
+ - **Transfer integrity (lost-chunk detection).** The server now records every
30
+ received chunk index. A chunk lost in transit surfaces as an inline
31
+ `[… audio gap …]` marker in the gap-aware transcript instead of being
32
+ silently stitched over. Gap state survives a mid-meeting server restart;
33
+ legacy persisted sessions recover without false alarms. The Even Hub client
34
+ (1.0.153+) already retries failed uploads durably — this is the server half.
35
+ - **Vocab-echo hallucination filter.** Whisper is seeded with your profile
36
+ vocabulary; on silence/music it can echo those terms back as phantom words
37
+ ("POS Nation. Thrift Cart.") the user never said. Bare-name echoes are now
38
+ dropped session-aware (silence echo, back-to-back run, or exact repeat) on
39
+ both the meeting and dictation paths. Real sentences that mention a term are
40
+ never dropped; plain single-word terms (names, cities) never trigger it.
41
+ - **Name corrections on every path.** The `whisper_corrections` map now also
42
+ applies to iPhone-ASR candidate text and the cloud fallback, not just local
43
+ whisper.
44
+ - **SIGTERM parity.** Production stops (service managers, `kill`) now flush
45
+ active session logs exactly like Ctrl-C did.
46
+ - **`COS_G2_DEFAULT_MODEL` fix.** The documented default-model switch now
47
+ applies on the primary query path, not only the OpenAI-compat surface.
48
+
3
49
  ## 6.1.0
4
50
 
5
51
  - **Codex backend.** Chat now routes to your local **Codex CLI** (`codex-high`) in
package/bin/cli.cjs CHANGED
@@ -13,6 +13,12 @@ const { homedir } = require('os')
13
13
  const PKG_ROOT = resolve(__dirname, '..')
14
14
  const CONFIG_DIR = join(homedir(), '.cos-glasses')
15
15
 
16
+ // Record where the user ran `npx @gotcos/glasses-server` from. The server spawns
17
+ // with cwd = PKG_ROOT (the npx cache), so without this the user's Starter-Kit COS
18
+ // (AGENTS.md / CLAUDE.md / .cos/) in their launch folder would never be seen.
19
+ // Chat spawns of claude/codex use this dir when it contains a COS brain.
20
+ if (!process.env.COS_LAUNCH_DIR) process.env.COS_LAUNCH_DIR = process.cwd()
21
+
16
22
  const green = (s) => `\x1b[32m${s}\x1b[0m`
17
23
  const red = (s) => `\x1b[31m${s}\x1b[0m`
18
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
@@ -185,6 +191,12 @@ if (!process.env.BIND_HOST) {
185
191
  }
186
192
 
187
193
  // Step 7: start the bundled server
194
+ try {
195
+ const ld = process.env.COS_LAUNCH_DIR
196
+ if (ld && (existsSync(join(ld, '.cos', 'manifest.json')) || existsSync(join(ld, 'AGENTS.md')) || existsSync(join(ld, 'CLAUDE.md')))) {
197
+ console.log(green(' ✓') + ` COS detected in ${dim(ld)} — glasses chat will load its brain`)
198
+ }
199
+ } catch { /* detection is best-effort */ }
188
200
  console.log('')
189
201
  console.log(dim(' Starting server...'))
190
202
  console.log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.1.0",
3
+ "version": "6.2.1",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -7,7 +7,9 @@ import cors from 'cors'
7
7
  import path from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { createServer as createHttpsServer } from 'node:https'
10
- import { readFileSync, existsSync } from 'node:fs'
10
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
+ import { networkInterfaces, homedir } from 'node:os'
12
+ import { join } from 'node:path'
11
13
  import { execSync } from 'node:child_process'
12
14
  import { randomBytes } from 'node:crypto'
13
15
  import { healthRouter } from './routes/health.js'
@@ -43,6 +45,18 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
43
45
  const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
44
46
  const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
45
47
  process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
48
+ // Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
49
+ // Without this, every re-run mints a new token and the app's saved token starts
50
+ // returning 401 — the symptom looks like a broken app, not a rotated credential.
51
+ let API_TOKEN_PERSISTED = false
52
+ if (API_TOKEN_AUTO) {
53
+ try {
54
+ const envDir = join(homedir(), '.cos-glasses')
55
+ mkdirSync(envDir, { recursive: true })
56
+ appendFileSync(join(envDir, '.env'), `\n# auto-generated by the server so the app token survives restarts\nCOS_API_TOKEN=${API_TOKEN}\n`)
57
+ API_TOKEN_PERSISTED = true
58
+ } catch { /* read-only home — token stays per-session */ }
59
+ }
46
60
 
47
61
  // Server metrics — shared with /api/health for monitoring
48
62
  export const serverMetrics = {
@@ -138,7 +152,13 @@ app.get('/', (_req, res) => {
138
152
  })
139
153
 
140
154
  // Graceful shutdown — stop whisper-server child process
141
- process.on('SIGTERM', () => { stopWhisperServer(); process.exit(0) })
155
+ process.on('SIGTERM', () => {
156
+ // Production stops (kill, service managers) send SIGTERM — flush session logs
157
+ // exactly like SIGINT so active conversations aren't lost on shutdown.
158
+ try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
159
+ stopWhisperServer()
160
+ process.exit(0)
161
+ })
142
162
  process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
143
163
 
144
164
  // Crash protection — log and survive instead of dying mid-meeting
@@ -172,11 +192,31 @@ app.listen(PORT, BIND_HOST, () => {
172
192
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
173
193
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
174
194
 
195
+ // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
196
+ // not paste-able — enumerate real interfaces and label the Tailscale one.
197
+ try {
198
+ const nets = networkInterfaces()
199
+ const addrs: Array<{ ip: string; label: string }> = []
200
+ for (const [name, infos] of Object.entries(nets)) {
201
+ for (const info of infos ?? []) {
202
+ if (info.family !== 'IPv4' || info.internal) continue
203
+ const isTailscale = info.address.startsWith('100.') || name.startsWith('utun') || name.startsWith('tailscale')
204
+ addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
205
+ }
206
+ }
207
+ if (addrs.length > 0) {
208
+ addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
209
+ console.log('')
210
+ console.log('[COS API] Server URL for the COS Glasses app:')
211
+ for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
212
+ }
213
+ } catch { /* interface enumeration is best-effort */ }
214
+
175
215
  // Print the full API token when auto-generated — the user pastes it into the app.
176
216
  if (API_TOKEN_AUTO) {
177
217
  console.log('')
178
218
  console.log(`[COS API] API Token: ${API_TOKEN}`)
179
- console.log('[COS API] ^ paste this into the COS Glasses app (set COS_API_TOKEN in .env for a fixed token)')
219
+ console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
180
220
  console.log('')
181
221
  }
182
222
 
@@ -12,11 +12,12 @@ import { join } from 'node:path'
12
12
  import { appendFileSync } from 'node:fs'
13
13
  import crypto from 'node:crypto'
14
14
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
15
+ import { cosBrainDir } from './launch-dir.js'
15
16
  import { logTokenAudit } from './token-audit.js'
16
17
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
18
  import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
18
19
  import { notifySessionStart, notifyExchange } from './telegram-notify.js'
19
- import { isClaudeModel, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
+ import { isClaudeModel, DEFAULT_MODEL, type ClaudeModelPreference } from '../../shared/model-preference.js'
20
21
  import {
21
22
  finishClaudeRun,
22
23
  getClaudeEffortLevel,
@@ -182,7 +183,7 @@ export async function preWarmCLI(): Promise<void> {
182
183
 
183
184
  const proc = spawn('claude', [
184
185
  '-p',
185
- '--model', 'opus', // Must match default query model — --resume inherits session model
186
+ '--model', DEFAULT_MODEL, // Must match default query model — --resume inherits session model
186
187
  '--effort', getClaudeEffortLevel(),
187
188
  '--output-format', 'stream-json',
188
189
  '--verbose',
@@ -191,7 +192,7 @@ export async function preWarmCLI(): Promise<void> {
191
192
  ], {
192
193
  stdio: ['pipe', 'pipe', 'pipe'],
193
194
  env,
194
- cwd: COS_SCRIPTS_DIR ?? process.cwd(),
195
+ cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
195
196
  })
196
197
 
197
198
  let buffer = ''
@@ -419,7 +420,7 @@ export async function callClaudeStreaming(
419
420
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
420
421
  const env = { ...process.env }
421
422
  delete env.CLAUDECODE
422
- const cliCwd = COS_SCRIPTS_DIR ?? process.cwd()
423
+ const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
423
424
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
424
425
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
425
426
  const wallMax = isExtendedQuery(query) ? WALL_MAX_EXTENDED_MS : defaultWallMax
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
+ import { cosBrainDir } from './launch-dir.js'
5
6
  import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
6
7
  import {
7
8
  CODEX_HIGH_REASONING_EFFORT,
@@ -80,6 +81,10 @@ export function getCodexExecutionCwd(): string {
80
81
  const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
81
82
  if (configured) return resolve(configured)
82
83
  if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
84
+ // A Starter-Kit COS in the directory the user launched npx from — Codex
85
+ // loads its AGENTS.md brain natively when run there.
86
+ const brain = cosBrainDir()
87
+ if (brain) return brain
83
88
  // Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
84
89
  // the server's own working dir (codex glasses is an optional, env-configured feature).
85
90
  return process.cwd()
@@ -11,7 +11,7 @@
11
11
  // isFullHallucination(text) — returns true if the text IS a hallucination in its
12
12
  // entirety (silence artifacts, caption training, foreign script, filler-only).
13
13
 
14
- import { getNegativeRules } from './profile.js'
14
+ import { getNegativeRules, getVocabulary, getOwnerName, loadProfileField } from './profile.js'
15
15
 
16
16
  // ── Whole-chunk silence hallucinations ─────────────────────────────────────
17
17
  const KNOWN_HALLUCINATIONS = [
@@ -312,6 +312,76 @@ export function isRepeatedThankYouOnly(text: string): boolean {
312
312
  return content.length === 0
313
313
  }
314
314
 
315
+ // ── Vocab-echo (prompt-regurgitation) hallucination ────────────────────────
316
+ // Whisper is seeded with an initial_prompt = the profile vocabulary (owner +
317
+ // brands + products + people). On silence / music / ambiguous audio it ECHOES
318
+ // that prompt, emitting the seeded terms the user never said. The brand-URL
319
+ // filters above only catch URL echoes; a bare brand-NAME echo slips through.
320
+ // This detector drops a chunk that is NOTHING but seeded vocab terms (+
321
+ // punctuation). Real speech that MENTIONS a term in a sentence keeps its
322
+ // non-vocab content words and is never dropped.
323
+ let _vocabEchoRe: RegExp | null = null
324
+
325
+ /** Bust the cached vocab matcher after a profile write. */
326
+ export function resetVocabEchoCache(): void { _vocabEchoRe = null }
327
+
328
+ function getVocabEchoMatcher(): RegExp {
329
+ if (_vocabEchoRe) return _vocabEchoRe
330
+ const raw = new Set<string>()
331
+ const owner = getOwnerName()
332
+ if (owner) raw.add(owner)
333
+ for (const v of getVocabulary()) if (v && v.trim()) raw.add(v.trim())
334
+ // Include whisper_corrections key/value variants so the echo matches whatever
335
+ // spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
336
+ try {
337
+ const corrRaw = loadProfileField('whisper_corrections', '')
338
+ if (corrRaw) {
339
+ const map = JSON.parse(corrRaw) as Record<string, string>
340
+ for (const [k, val] of Object.entries(map)) { if (k) raw.add(k); if (val) raw.add(val) }
341
+ }
342
+ } catch { /* malformed corrections — ignore */ }
343
+ // Only UNAMBIGUOUS terms trigger an echo drop: multi-word phrases ("POS Nation",
344
+ // "IT Retail", "Jeremy Sokolic") and brand-shaped single tokens with an internal
345
+ // capital or digit ("POSNation", "CaratIQ", "Jewel360"). Plain single-word tokens
346
+ // ("Austin", "Miles", "Ukaoma") are common words / ambiguous and are EXCLUDED —
347
+ // they carry too much false-drop risk for an always-on list rule.
348
+ const terms = [...raw].filter(t => {
349
+ if (t.length < 2) return false
350
+ if (/\s/.test(t)) return true // multi-word phrase
351
+ return /[A-Z0-9]/.test(t.slice(1)) // single token only if brand-shaped
352
+ }).sort((a, b) => b.length - a.length)
353
+ if (terms.length === 0) { _vocabEchoRe = /(?!)/g; return _vocabEchoRe }
354
+ // Escape regex metachars; flex internal whitespace so "IT Retail" also matches
355
+ // "ITRetail". Word-bounded so terms don't match inside larger words.
356
+ const alt = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s*')).join('|')
357
+ _vocabEchoRe = new RegExp(`\\b(?:${alt})\\b`, 'gi')
358
+ return _vocabEchoRe
359
+ }
360
+
361
+ /** Count distinct seeded-vocab terms present in `text`. */
362
+ export function countVocabTerms(text: string): number {
363
+ if (!text) return 0
364
+ const re = getVocabEchoMatcher()
365
+ re.lastIndex = 0
366
+ const found = new Set<string>()
367
+ let m: RegExpExecArray | null
368
+ while ((m = re.exec(text)) !== null) {
369
+ found.add(m[0].toLowerCase().replace(/\s+/g, ''))
370
+ if (m.index === re.lastIndex) re.lastIndex++ // guard against a zero-width match loop
371
+ }
372
+ return found.size
373
+ }
374
+
375
+ /** True iff `text` is non-empty and contains NOTHING but seeded vocab terms (plus
376
+ * punctuation/whitespace) — the prompt-echo hallucination shape. Any non-vocab
377
+ * content word (incl. connectives like "and"/"the") makes it real speech → false. */
378
+ export function isVocabEchoOnly(text: string): boolean {
379
+ if (!text || !text.trim()) return false
380
+ if (countVocabTerms(text) === 0) return false
381
+ const residual = text.replace(getVocabEchoMatcher(), ' ').replace(/[^a-z0-9]/gi, '')
382
+ return residual.length === 0
383
+ }
384
+
315
385
  /** Streaming-chunk silence-drop decision. Pure + exported so the gate is testable
316
386
  * (sanitizeStreamTranscript is private). Returns a fallbackReason or null. Contract:
317
387
  * brand-URL-only -> 'brand_url' DROP ALWAYS — brand URLs are vocab-seeded,
@@ -320,6 +390,9 @@ export function isRepeatedThankYouOnly(text: string): boolean {
320
390
  * third-party URL during speech is preserved.
321
391
  * thank-you-only -> 'thankyou_silence' DROP only when isQuiet — soft real closings
322
392
  * stay (see isRepeatedThankYouOnly).
393
+ * Vocab-echo (prompt regurgitation) is handled SEPARATELY in sanitizeStreamTranscript
394
+ * because the safe rule is session-aware (drop a silence echo or a back-to-back RUN,
395
+ * but keep a single loud one-off that could be a real terse brand/name list).
323
396
  * Real speech (any chunk with content words) always returns null. */
324
397
  export function streamSilenceDropReason(text: string, isQuiet: boolean): 'brand_url' | 'url_silence' | 'thankyou_silence' | null {
325
398
  if (!text || !text.trim()) return null
@@ -0,0 +1,29 @@
1
+ // COS brain auto-detection for the "glasses inherit your COS" promise.
2
+ //
3
+ // The npx launcher (bin/cli.cjs) records the directory the user ran `npx
4
+ // @gotcos/glasses-server` from as COS_LAUNCH_DIR before re-spawning the server
5
+ // with cwd = the package root. If that launch directory contains a COS brain
6
+ // (a Starter-Kit scaffold: .cos/manifest.json, AGENTS.md, or CLAUDE.md), chat
7
+ // spawns of claude/codex use IT as their working directory — so the CLIs load
8
+ // the user's brain exactly as they would in a terminal session in that folder.
9
+ //
10
+ // Precedence stays: COS_SCRIPTS_DIR (full COS pipeline) > detected brain dir >
11
+ // process.cwd(). Explicit config always wins.
12
+ import { existsSync } from 'node:fs'
13
+ import { join, resolve } from 'node:path'
14
+
15
+ let cached: string | null | undefined
16
+
17
+ /** The user's launch directory IF it contains a COS brain; otherwise null. */
18
+ export function cosBrainDir(): string | null {
19
+ if (cached !== undefined) return cached
20
+ const raw = process.env.COS_LAUNCH_DIR?.trim()
21
+ if (!raw) { cached = null; return cached }
22
+ const dir = resolve(raw)
23
+ const hasBrain =
24
+ existsSync(join(dir, '.cos', 'manifest.json')) ||
25
+ existsSync(join(dir, 'AGENTS.md')) ||
26
+ existsSync(join(dir, 'CLAUDE.md'))
27
+ cached = hasBrain ? dir : null
28
+ return cached
29
+ }
@@ -24,7 +24,11 @@ export async function callModelStreaming(
24
24
  ): Promise<string> {
25
25
  const sid = getOrCreateSession(sessionId)
26
26
  const sessionModel = getSessionModel(sid)
27
- const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? DEFAULT_MODEL
27
+ // COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
28
+ // it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
29
+ // just the OpenAI-compat surface.
30
+ const envDefault = normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL)
31
+ const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
28
32
 
29
33
  setSessionModel(sid, resolvedModel)
30
34
 
@@ -17,6 +17,8 @@ import { enhanceAudio } from './audio-enhance.js'
17
17
  import {
18
18
  stripInlineHallucinationsOneShot,
19
19
  isFullHallucination,
20
+ isVocabEchoOnly,
21
+ countVocabTerms,
20
22
  } from './hallucination-filter.js'
21
23
  import { getOpenAIKey } from './openai-key.js'
22
24
 
@@ -173,7 +175,11 @@ export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?:
173
175
  }
174
176
 
175
177
  const elapsedMs = performance.now() - tStart
176
- if (!text || isFullHallucination(text)) {
178
+ // A one-shot message/dictation that is NOTHING but a list of seeded vocab terms
179
+ // (>=2 distinct) is a whisper prompt-echo, not speech — drop it like the meeting
180
+ // path does. A single terse brand mention stays (could be a real one-word message).
181
+ const vocabEcho = isVocabEchoOnly(text) && countVocabTerms(text) >= 2
182
+ if (!text || isFullHallucination(text) || vocabEcho) {
177
183
  throw new NoSpeechDetectedError(text || '')
178
184
  }
179
185
 
@@ -6,7 +6,7 @@
6
6
  import { Router } from 'express'
7
7
  import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
8
8
  import { callModelStreaming } from '../lib/model-router.js'
9
- import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
9
+ import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
10
10
  import { tryInstantResponse } from '../lib/response-cache.js'
11
11
  import crypto from 'node:crypto'
12
12
 
@@ -79,7 +79,7 @@ function resolveModel(model?: string, _query?: string): ModelPreference {
79
79
  if (model === 'cos-sonnet') return 'sonnet'
80
80
  if (model === 'cos-haiku') return 'haiku'
81
81
  if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
82
- return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? 'haiku'
82
+ return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
83
83
  }
84
84
 
85
85
  const MODEL_NAMES: Record<ModelPreference, string> = {
@@ -14,7 +14,7 @@ import { getOpenAIKey } from '../lib/openai-key.js'
14
14
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
15
  import { emitDisplay } from '../lib/display-bus.js'
16
16
  import { errMsg } from '../lib/utils.js'
17
- import { transcribeLocal, isWhisperLocalAvailable, type WhisperWord } from '../lib/whisper-local.js'
17
+ import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
18
18
  import { enhanceAudio } from '../lib/audio-enhance.js'
19
19
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
20
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
@@ -29,6 +29,7 @@ import {
29
29
  isFullHallucination as sharedIsFullHallucination,
30
30
  clearSessionHallucinationState,
31
31
  streamSilenceDropReason,
32
+ isVocabEchoOnly,
32
33
  } from '../lib/hallucination-filter.js'
33
34
 
34
35
  // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
@@ -170,6 +171,18 @@ interface TranscriptSession {
170
171
  startTime: number
171
172
  title: string
172
173
  providerCandidates?: Record<string, ProviderCandidateRecord>
174
+ // ── Transfer integrity (lost-chunk detection) ──────────────
175
+ // Every chunkIndex the server received an audio POST for — recorded at
176
+ // ingest BEFORE any text filtering, so a "received but silent" chunk counts
177
+ // as delivered (not a gap). A genuine hole (index in [0, maxChunkIndex] that
178
+ // never arrived = a chunk lost in transit) is the only thing flagged as a gap.
179
+ // Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
180
+ receivedIndices?: number[]
181
+ maxChunkIndex?: number
182
+ // Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
183
+ // any real-content chunk. Used to drop a RUN of echoed brand names while keeping
184
+ // a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
185
+ vocabEchoStreak?: number
173
186
  }
174
187
 
175
188
  const sessions = new Map<string, TranscriptSession>()
@@ -238,11 +251,24 @@ function persistSession(sessionId: string): void {
238
251
  const session = sessions.get(sessionId)
239
252
  if (!session) return
240
253
  const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
254
+ // chunksIndexed preserves each chunk's original index (a plain filter()
255
+ // would collapse the sparse array and destroy gap positions on recovery).
256
+ const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
257
+ for (let i = 0; i < session.chunks.length; i++) {
258
+ const c = session.chunks[i]
259
+ if (c && c.text) chunksIndexed.push({ i, c })
260
+ }
241
261
  const data = JSON.stringify({
242
262
  sessionId,
243
263
  startTime: session.startTime,
244
264
  title: session.title,
245
- chunks: session.chunks.filter(c => c && c.text),
265
+ // `chunks` = legacy dense form, kept for backward compatibility with
266
+ // existing readers; `chunksIndexed` preserves original indices so gap
267
+ // detection survives recovery. Recovery prefers chunksIndexed.
268
+ chunks: chunksIndexed.map(e => e.c),
269
+ chunksIndexed,
270
+ receivedIndices: session.receivedIndices ?? [],
271
+ maxChunkIndex: session.maxChunkIndex ?? -1,
246
272
  providerCandidates: session.providerCandidates ?? {},
247
273
  })
248
274
  writeFileSync(filePath, data, 'utf-8')
@@ -259,13 +285,52 @@ function recoverSessions(): void {
259
285
  if (!file.endsWith('.json')) continue
260
286
  try {
261
287
  const data = JSON.parse(readFileSync(resolve(CHUNK_PERSIST_DIR, file), 'utf-8'))
262
- if (data.sessionId && data.chunks && data.chunks.length > 0) {
288
+ // Reconstruct the (sparse) chunk array. New format keeps original
289
+ // indices via chunksIndexed; legacy format stored a dense `chunks`.
290
+ const indexed: Array<{ i: number; c: TranscriptChunk }> | null =
291
+ Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
292
+ const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
293
+ const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
294
+ if (data.sessionId && hasChunks) {
263
295
  // Only recover sessions less than 4 hours old
264
296
  if (Date.now() - data.startTime < 4 * 60 * 60 * 1000) {
297
+ const chunks: TranscriptChunk[] = []
298
+ if (indexed) {
299
+ for (const e of indexed) {
300
+ if (e && Number.isInteger(e.i) && e.i >= 0 && e.c) chunks[e.i] = e.c
301
+ }
302
+ } else if (legacy) {
303
+ for (let k = 0; k < legacy.length; k++) if (legacy[k]) chunks[k] = legacy[k]
304
+ }
305
+ // Restore the received-index ledger. A legacy file (pre-feature)
306
+ // has no ledger and no way to know whether a chunk was truly lost,
307
+ // so deriving from stored positions would FALSELY flag a
308
+ // received-but-silent chunk as a gap. Instead, treat a legacy
309
+ // session as contiguous (0..maxStored) → it reports 100%, never a
310
+ // false alarm. New-format files carry their own ledger and are exact.
311
+ const hasLedger = Array.isArray(data.receivedIndices)
312
+ let receivedIndices: number[]
313
+ let maxChunkIndex: number
314
+ if (hasLedger) {
315
+ receivedIndices = Array.from(new Set(
316
+ (data.receivedIndices as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0),
317
+ )).sort((a, b) => a - b)
318
+ maxChunkIndex = typeof data.maxChunkIndex === 'number' && data.maxChunkIndex >= 0
319
+ ? data.maxChunkIndex
320
+ : (receivedIndices.length > 0 ? receivedIndices[receivedIndices.length - 1] : -1)
321
+ } else {
322
+ let maxStored = -1
323
+ for (let k = 0; k < chunks.length; k++) if (chunks[k]) maxStored = k
324
+ receivedIndices = []
325
+ for (let k = 0; k <= maxStored; k++) receivedIndices.push(k)
326
+ maxChunkIndex = maxStored
327
+ }
265
328
  const session: TranscriptSession = {
266
- chunks: data.chunks,
329
+ chunks,
267
330
  startTime: data.startTime,
268
331
  title: data.title || '',
332
+ receivedIndices,
333
+ maxChunkIndex,
269
334
  providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
270
335
  ? data.providerCandidates
271
336
  : {},
@@ -293,7 +358,8 @@ function recoverSessions(): void {
293
358
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
294
359
  persistSession(data.sessionId) // re-persist cleaned data to disk
295
360
  }
296
- console.log(`[session-recovery] Recovered ${data.chunks.length} chunks for ${data.sessionId}`)
361
+ const gaps = computeGapReport(session).missingIndices.length
362
+ console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
297
363
  } else {
298
364
  // Stale — clean up
299
365
  unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
@@ -410,13 +476,106 @@ export function getSession(sessionId: string): TranscriptSession {
410
476
  return session
411
477
  }
412
478
 
413
- /** Get full accumulated transcript for a session (with speaker labels) */
414
- export function getSessionTranscript(sessionId: string): string | null {
479
+ /** Insert a non-negative integer into a sorted array, keeping it sorted and
480
+ * unique. Common case (a new highest value) is O(1); out-of-order (a retry)
481
+ * is a binary-search insert. Mutates `arr`. Exported for unit tests. */
482
+ export function insertSortedUnique(arr: number[], value: number): void {
483
+ if (!Number.isInteger(value) || value < 0) return
484
+ const last = arr.length > 0 ? arr[arr.length - 1] : -1
485
+ if (value > last) { arr.push(value); return }
486
+ if (value === last) return
487
+ let lo = 0, hi = arr.length
488
+ while (lo < hi) {
489
+ const mid = (lo + hi) >> 1
490
+ if (arr[mid] < value) lo = mid + 1
491
+ else hi = mid
492
+ }
493
+ if (arr[lo] !== value) arr.splice(lo, 0, value)
494
+ }
495
+
496
+ /** Record that the server received an audio POST for this chunk index.
497
+ * Called at ingest before any text filtering, so silent/empty chunks still
498
+ * count as delivered (not a gap). */
499
+ function recordReceivedChunk(session: TranscriptSession, chunkIndex: number): void {
500
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) return
501
+ if (!session.receivedIndices) session.receivedIndices = []
502
+ insertSortedUnique(session.receivedIndices, chunkIndex)
503
+ if (session.maxChunkIndex == null || chunkIndex > session.maxChunkIndex) {
504
+ session.maxChunkIndex = chunkIndex
505
+ }
506
+ }
507
+
508
+ export interface TranscriptGapReport {
509
+ received: number // distinct chunk indices the server got
510
+ stored: number // chunks that survived filtering (have text)
511
+ maxIndex: number // highest chunk index seen (-1 if none)
512
+ expected: number // maxIndex + 1
513
+ missingIndices: number[] // indices in [0, maxIndex] never received = lost in transit
514
+ completeness: number // received / expected (1 when nothing expected)
515
+ }
516
+
517
+ /** Pure gap math over a received-index ledger. Exported for unit tests. */
518
+ export function analyzeChunkGaps(receivedIndices: number[], maxChunkIndex: number, storedCount = 0): TranscriptGapReport {
519
+ const maxIndex = maxChunkIndex
520
+ const expected = maxIndex + 1
521
+ const recvSet = new Set(receivedIndices.filter(n => Number.isInteger(n) && n >= 0))
522
+ const missingIndices: number[] = []
523
+ for (let i = 0; i <= maxIndex; i++) {
524
+ if (!recvSet.has(i)) missingIndices.push(i)
525
+ }
526
+ const completeness = expected > 0 ? recvSet.size / expected : 1
527
+ return { received: recvSet.size, stored: storedCount, maxIndex, expected, missingIndices, completeness }
528
+ }
529
+
530
+ /** Gap report bound to a live session's ledger + stored-chunk count. */
531
+ function computeGapReport(session: TranscriptSession): TranscriptGapReport {
532
+ const received = session.receivedIndices ?? []
533
+ const maxIndex = session.maxChunkIndex ?? (received.length > 0 ? received[received.length - 1] : -1)
534
+ const stored = session.chunks.filter(c => c && c.text).length
535
+ return analyzeChunkGaps(received, maxIndex, stored)
536
+ }
537
+
538
+ /** Transfer-integrity report for a live session — null if the session is gone. */
539
+ export function analyzeTranscriptGaps(sessionId: string): TranscriptGapReport | null {
415
540
  const session = sessions.get(sessionId)
416
541
  if (!session) return null
417
- return session.chunks
418
- .map(c => c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
419
- .join('\n')
542
+ return computeGapReport(session)
543
+ }
544
+
545
+ /** Get full accumulated transcript for a session (with speaker labels).
546
+ * With { withGaps: true }, walks the index sequence and inserts an explicit
547
+ * marker wherever one or more chunks were never received — so permanently
548
+ * lost audio is visible in the saved transcript instead of silently stitched. */
549
+ export function getSessionTranscript(sessionId: string, opts: { withGaps?: boolean } = {}): string | null {
550
+ const session = sessions.get(sessionId)
551
+ if (!session) return null
552
+ const renderChunk = (c: TranscriptChunk): string => (c.speaker ? `[${c.speaker}]: ${c.text}` : c.text)
553
+ if (!opts.withGaps) {
554
+ return session.chunks.map(renderChunk).join('\n')
555
+ }
556
+ const report = computeGapReport(session)
557
+ if (report.missingIndices.length === 0) {
558
+ return session.chunks.map(renderChunk).join('\n')
559
+ }
560
+ const missing = new Set(report.missingIndices)
561
+ const lines: string[] = []
562
+ let gapRun = 0
563
+ const flushGap = (): void => {
564
+ if (gapRun > 0) {
565
+ // Marker is intentionally >40 inner chars so it can't match the
566
+ // bracket-shaped hallucination filter (/^\s*\[[^\]\n]{1,40}\]\s*$/).
567
+ lines.push(`[… audio gap — ${gapRun} chunk${gapRun > 1 ? 's' : ''} lost in transit, not received by server …]`)
568
+ gapRun = 0
569
+ }
570
+ }
571
+ for (let i = 0; i <= report.maxIndex; i++) {
572
+ if (missing.has(i)) { gapRun++; continue }
573
+ flushGap()
574
+ const c = session.chunks[i]
575
+ if (c && c.text) lines.push(renderChunk(c))
576
+ }
577
+ flushGap()
578
+ return lines.join('\n')
420
579
  }
421
580
 
422
581
  /** Get structured chunks with timing + speaker confidence (for blended meeting pipeline) */
@@ -599,6 +758,26 @@ function sanitizeStreamTranscript(sessionId: string, session: TranscriptSession,
599
758
  return { text: '', fallbackReason: dropReason }
600
759
  }
601
760
  }
761
+ // Vocab-echo (whisper regurgitating its seeded vocab prompt — phantom brand names).
762
+ // Session-aware so we never drop a real one-off: a chunk that is NOTHING but seeded
763
+ // terms is dropped only when (a) the audio is quiet (a silence echo), (b) it's the
764
+ // 2nd+ consecutive such chunk (a RUN — the "repeated multiple times" symptom), or
765
+ // (c) it exactly repeats a recent chunk. A single loud, non-repeating vocab-only
766
+ // chunk is KEPT (it could be a real terse brand/name list). Accepted trade: if a
767
+ // user genuinely dictates a brand list with NO connectives across consecutive
768
+ // chunks ("POS Nation," | "Thrift Cart," | "and IT Retail"), the middle chunk can
769
+ // be dropped — rare (whisper usually bundles a spoken list into one chunk, which
770
+ // is kept) and far less harmful than the phantom-brand spam this prevents.
771
+ if (trimmedText && STRIP_BRAND_URLS && isVocabEchoOnly(trimmedText)) {
772
+ const streak = (session.vocabEchoStreak ?? 0) + 1
773
+ session.vocabEchoStreak = streak
774
+ if (isQuiet || streak >= 2 || isCrossChunkRepeat(session, trimmedText)) {
775
+ console.log(`[hallucination] Dropped (vocab_echo, q=${isQuiet ? 1 : 0}, streak=${streak}): "${trimmedText.slice(0, 60)}"`)
776
+ return { text: '', fallbackReason: 'vocab_echo' }
777
+ }
778
+ } else if (trimmedText) {
779
+ session.vocabEchoStreak = 0
780
+ }
602
781
  if (trimmedText && isServerHallucination(trimmedText)) {
603
782
  return { text: '', fallbackReason: 'hallucination' }
604
783
  }
@@ -741,6 +920,9 @@ async function processStreamChunk(opts: {
741
920
 
742
921
  const audioSha256 = sha256Hex(audioBuffer)
743
922
  const session = getSession(sessionId)
923
+ // Transfer integrity: log this index as delivered before any text filtering,
924
+ // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
925
+ recordReceivedChunk(session, chunkIndex)
744
926
  const alreadyCanonical = session.chunks[chunkIndex]
745
927
 
746
928
  let candidateRecordKey: string | undefined
@@ -819,11 +1001,17 @@ async function processStreamChunk(opts: {
819
1001
  backend = result.backend
820
1002
  }
821
1003
 
1004
+ // Apply deterministic name corrections (whisper_corrections map) on EVERY live
1005
+ // path: the iPhone-ASR candidate path and the cloud fallback skip
1006
+ // transcribeLocal's internal pass, so without this the lens would show names
1007
+ // uncorrected for those sources. Idempotent for the local path.
1008
+ rawText = applyCorrections(rawText)
1009
+
822
1010
  let sanitized = sanitizeStreamTranscript(sessionId, session, rawText, isQuiet)
823
1011
  if (candidate && (!sanitized.text || sanitized.fallbackReason)) {
824
1012
  fallbackReason = sanitized.fallbackReason || 'empty_candidate'
825
1013
  const result = await transcribeWithServerWhisper(audioBuffer, whisperAudio, whisperContext, isQuiet)
826
- rawText = result.text
1014
+ rawText = applyCorrections(result.text)
827
1015
  words = result.words
828
1016
  backend = result.backend
829
1017
  asrProvider = 'server-whisper'
@@ -982,11 +1170,16 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/:sessionId/fina
982
1170
  const chunks = getSessionChunks(sessionId)
983
1171
  if (!chunks || chunks.length === 0) throw makeHttpError(404, 'offline session has no chunks', 'session_not_found')
984
1172
  await drainSessionAudioWrites(sessionId)
985
- const transcript = getSessionTranscript(sessionId) ?? ''
1173
+ // Gap-aware assembly: chunks lost in transit surface as inline
1174
+ // "[… audio gap …]" markers instead of being silently stitched over.
1175
+ const transcript = getSessionTranscript(sessionId, { withGaps: true }) ?? ''
1176
+ const transferIntegrity = analyzeTranscriptGaps(sessionId)
986
1177
  res.json({
987
1178
  sessionId,
988
1179
  chunks: chunks.length,
989
1180
  transcriptChars: transcript.length,
1181
+ transcript,
1182
+ transferIntegrity,
990
1183
  readyToSave: true,
991
1184
  })
992
1185
  } catch (err: unknown) {
@@ -2,7 +2,7 @@ export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
2
2
  export type CodexModelPreference = 'codex-high'
3
3
  export type ModelPreference = ClaudeModelPreference | CodexModelPreference
4
4
 
5
- export const DEFAULT_MODEL = 'opus' as const
5
+ export const DEFAULT_MODEL = 'sonnet' as const
6
6
  export const CODEX_HIGH_MODEL: CodexModelPreference = 'codex-high'
7
7
 
8
8
  // Optional codex model passed to `codex exec --model`. Empty (the default) means