@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 +46 -0
- package/bin/cli.cjs +12 -0
- package/package.json +1 -1
- package/server/index.ts +43 -3
- package/server/lib/claude-bridge.ts +5 -4
- package/server/lib/codex-run-ledger.ts +5 -0
- package/server/lib/hallucination-filter.ts +74 -1
- package/server/lib/launch-dir.ts +29 -0
- package/server/lib/model-router.ts +5 -1
- package/server/lib/transcribe-audio.ts +7 -1
- package/server/routes/openai-compat.ts +2 -2
- package/server/routes/transcribe-stream.ts +205 -12
- package/shared/model-preference.ts +1 -1
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
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', () => {
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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) ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
414
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|