@gotcos/glasses-server 6.78.2 → 6.79.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.
@@ -6,6 +6,8 @@
6
6
  // GET /live-cues/status -> engine snapshot + capability
7
7
  // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
8
8
  // POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
9
+ // POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (6.79.0)
10
+ // GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 -> { v: 1, cues, counts, truncated, skipped, maxCuesPerMeeting, meetings? } (6.79.0)
9
11
 
10
12
  import { Router } from 'express'
11
13
  import { errMsg } from '../lib/utils.js'
@@ -16,7 +18,8 @@ import {
16
18
  LiveCuesArmError,
17
19
  } from '../lib/live-cues-engine.js'
18
20
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
19
- import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
21
+ import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
22
+ import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
20
23
 
21
24
  const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
22
25
  const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
@@ -80,6 +83,8 @@ liveCuesRouter.get('/live-cues/status', (req, res) => {
80
83
  capability: liveCuesCapability(),
81
84
  sessions: getLiveCuesStatus(),
82
85
  ...(liveCuesV2Advertised() ? { v2: liveCuesV2Advertised() } : {}),
86
+ // 6.79.0: always, so Control can rate past cues with v2 switched off.
87
+ verdicts: { protocol: LIVE_CUE_VERDICTS_PROTOCOL },
83
88
  })
84
89
  })
85
90
 
@@ -95,17 +100,87 @@ liveCuesRouter.post('/live-cues/pause', (req, res) => {
95
100
  return res.json(liveCuesV2().setPaused(sessionId, paused, seq as number | undefined))
96
101
  })
97
102
 
98
- // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue }
103
+ // POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue } (the lens; unchanged)
104
+ // POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (COS Control, 6.79.0)
99
105
  liveCuesRouter.post('/live-cues/feedback', (req, res) => {
100
- const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown }
106
+ const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown; verdict?: unknown; note?: unknown }
101
107
  const sessionId = bodySessionId(req.body)
102
108
  const cueId = typeof body.cueId === 'string' ? body.cueId.trim() : ''
109
+ if (body.verdict !== undefined) {
110
+ // Exactly one of vote and verdict: a body carrying both is ambiguous, so neither is applied.
111
+ if (body.vote !== undefined || !SESSION_ID.test(sessionId) || !CUE_ID.test(cueId)
112
+ || !(isCueVerdict(body.verdict) || body.verdict === 'clear') || (body.note !== undefined && typeof body.note !== 'string')) {
113
+ return res.status(400).json({ error: 'sessionId, cueId and verdict (useful|late|repeat|wrong|obvious|clear) required, without vote', reason: 'invalid_request' })
114
+ }
115
+ // Before the v2 gate: last week's cues are rated from the log. peek, never liveCuesV2(): a v2-off server must not build
116
+ // the engine (it constructs the Jev and SubQ clients) just to answer a rating.
117
+ try {
118
+ const result = liveCueVerdicts().rate({ sessionId, cueId, verdict: body.verdict, note: body.note as string | undefined }, liveCueOwner(cueId))
119
+ if (!result.ok && result.error === 'unknown_cue') return res.status(404).json({ error: 'unknown cue', reason: result.error })
120
+ if (!result.ok) return res.status(409).json({ error: 'this cue belongs to another meeting', reason: result.error })
121
+ return res.json(result.response)
122
+ } catch (error) {
123
+ if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
124
+ return res.status(500).json({ error: 'verdict_write_failed' })
125
+ }
126
+ }
103
127
  if (!SESSION_ID.test(sessionId) || !CUE_ID.test(cueId) || (body.vote !== 'up' && body.vote !== 'down')
104
128
  || (body.reason !== undefined && typeof body.reason !== 'string')) {
105
129
  return res.status(400).json({ error: 'sessionId, cueId and vote (up|down) required', reason: 'invalid_request' })
106
130
  }
107
131
  if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
108
- return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
132
+ const result = liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined })
133
+ // Kept beside any verdict, never over it, for a cue known under this meeting. Best effort: the lens answer is the same.
134
+ try { liveCueVerdicts().vote(sessionId, cueId, body.vote, liveCueOwner(cueId)) } catch (error) { console.warn(`[live-cues] lens vote not stored: ${errMsg(error)}`) }
135
+ return res.json(result)
136
+ })
137
+
138
+ /** The meeting the running engine holds this cue for, if an engine exists. Never creates one. */
139
+ function liveCueOwner(cueId: string): string | null {
140
+ return peekLiveCuesV2()?.findCue(cueId)?.sessionId ?? null
141
+ }
142
+
143
+ // GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 (6.79.0). Oldest first, the newest `limit` rows; counts
144
+ // cover the window after the sessionId filter. Not gated on the v2 switch, and no maintenance lease (a 15 s poll must
145
+ // never hold up an update drain). `meetings` (only with sessionId) are the durable counters, so Control never derives them.
146
+ function intParam(raw: unknown, fallback: number, min: number, max: number): number | null {
147
+ if (raw === undefined) return fallback
148
+ if (typeof raw !== 'string' || !/^\d{1,4}$/.test(raw)) return null
149
+ const n = Number(raw)
150
+ return n >= min && n <= max ? n : null
151
+ }
152
+ /** Absent or empty: no filter ([]). Else up to 5 comma-separated ids (a saved meeting can hold several captures). */
153
+ function sessionIdsParam(raw: unknown): string[] | null {
154
+ if (raw === undefined) return []
155
+ if (typeof raw !== 'string') return null
156
+ if (!raw.trim()) return []
157
+ const ids = [...new Set(raw.split(',').map(x => x.trim()))]
158
+ return ids.length <= LIVE_CUE_VERDICT_LIMITS.maxSessionIds && ids.every(id => SESSION_ID.test(id)) ? ids : null
159
+ }
160
+ liveCuesRouter.get('/live-cues/history', (req, res) => {
161
+ res.set('Cache-Control', 'private, no-store')
162
+ const L = LIVE_CUE_VERDICT_LIMITS
163
+ const days = intParam(req.query.days, L.defaultDays, 1, L.maxDays)
164
+ const limit = intParam(req.query.limit, L.defaultLimit, 1, L.maxLimit)
165
+ const sessionIds = sessionIdsParam(req.query.sessionId)
166
+ const unrated = req.query.unrated
167
+ if (days === null || limit === null || sessionIds === null || (unrated !== undefined && unrated !== '1' && unrated !== '0')) {
168
+ return res.status(400).json({ error: 'days 1..30, limit 1..1000, unrated 0|1 and up to 5 valid sessionIds', reason: 'invalid_request' })
169
+ }
170
+ try {
171
+ const verdicts = liveCueVerdicts()
172
+ const history = verdicts.history({ days, limit, unrated: unrated === '1', ...(sessionIds.length ? { sessionIds } : {}) })
173
+ const C = LIVE_CUES_V2_LIMITS
174
+ const meetings = sessionIds.length ? Object.fromEntries(sessionIds.map(id => {
175
+ // The engine's counters expire 24 h after a meeting; past that the log's count stands in and writerCalls is unknown.
176
+ const counters = liveCuesV2MeetingCounters(id)
177
+ return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: C.maxCuesPerMeeting, writerCalls: counters?.writerCalls ?? null, writerCap: C.maxWriterCallsPerMeeting }]
178
+ })) : undefined
179
+ return res.json({ ...history, maxCuesPerMeeting: C.maxCuesPerMeeting, ...(meetings ? { meetings } : {}) })
180
+ } catch (error) {
181
+ if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
182
+ return res.status(500).json({ error: 'history_unavailable' })
183
+ }
109
184
  })
110
185
 
111
186
  // COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
@@ -2,8 +2,7 @@ import { isDictationCleanModel, type DictationCleanModel } from '../lib/dictatio
2
2
  import { Router } from 'express'
3
3
  import { createHash } from 'node:crypto'
4
4
  import type { Response } from 'express'
5
- import { existsSync, mkdirSync, readFileSync } from 'node:fs'
6
- import { resolve, dirname } from 'node:path'
5
+ import { resolve } from 'node:path'
7
6
  import {
8
7
  createPromptDraft,
9
8
  loadPromptDraftMeta,
@@ -43,11 +42,11 @@ import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
43
42
  import { currentDictationPlan, runParakeetDictation } from '../lib/dictation-stt.js'
44
43
  import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '../lib/stt-settings.js'
45
44
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
45
+ import { fillerPassEnabled, stripDisfluencies } from '../lib/dictation-fillers.js'
46
+ import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from '../lib/dictation-polish-budget.js'
46
47
  import { getVocabulary } from '../lib/profile.js'
47
48
  import { createBreaker } from '../lib/claude-circuit.js'
48
49
  import { logTokenAudit } from '../lib/token-audit.js'
49
- import { atomicWriteFileSync } from '../lib/atomic-fs.js'
50
- import { dataPath } from '../lib/data-dir.js'
51
50
  import { emitDisplay } from '../lib/display-bus.js'
52
51
  import {
53
52
  acquireMaintenanceWork,
@@ -83,26 +82,10 @@ const autoCleanBreakers = {
83
82
  'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
84
83
  haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
85
84
  }
86
- const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
87
85
  const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
88
- const autoCleanDailyCap = () => {
89
- const value = Number.parseInt(process.env.COS_DICTATION_AUTOCLEAN_MAX_PER_DAY || '200', 10)
90
- return Number.isFinite(value) && value > 0 ? value : 200
91
- }
92
- function autoCleanCountToday(): number {
93
- try {
94
- const raw = JSON.parse(readFileSync(autoCleanCountFile(), 'utf-8'))
95
- if (raw?.date === new Date().toISOString().slice(0, 10) && Number.isFinite(raw?.count)) return raw.count
96
- } catch {}
97
- return 0
98
- }
99
- function recordAutoCleanCall(): void {
100
- try {
101
- const file = autoCleanCountFile()
102
- if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
103
- atomicWriteFileSync(file, JSON.stringify({ date: new Date().toISOString().slice(0, 10), count: autoCleanCountToday() + 1 }), { mode: 0o600 })
104
- } catch {}
105
- }
86
+
87
+ /** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
88
+ export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed'
106
89
 
107
90
  interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
108
91
  function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
@@ -114,7 +97,10 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
114
97
  return { enabled, model: isDictationCleanModel(rawModel) ? rawModel : undefined }
115
98
  }
116
99
 
117
- async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
100
+ async function cleanOutboundDictation(
101
+ text: string,
102
+ opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal },
103
+ ): Promise<{ text: string; polishSkipped?: PolishSkipped }> {
118
104
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
119
105
  // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
120
106
  // already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
@@ -137,24 +123,28 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
137
123
  } catch (fuzzyErr: any) {
138
124
  console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
139
125
  }
140
- if (!(opts.enabled ?? autoCleanDefaultEnabled())) return cleaned
126
+ // 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
127
+ if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
128
+ if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
141
129
  const model = opts.model ?? 'haiku'
142
130
  const autoCleanBreaker = autoCleanBreakers[model]
143
- if (cleaned.length > AUTOCLEAN_MAX_CHARS || autoCleanBreaker.isOpen() || autoCleanCountToday() >= autoCleanDailyCap()) return cleaned
131
+ if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
132
+ if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
133
+ if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
144
134
  const startedAt = Date.now()
145
- recordAutoCleanCall()
135
+ recordPolishCall(opts.bucket)
146
136
  try {
147
137
  const polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
148
138
  autoCleanBreaker.recordSuccess()
149
139
  logTokenAudit({
150
140
  source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
151
- durationMs: Date.now() - startedAt, caller: 'dictation_autoclean',
141
+ durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
152
142
  })
153
- return polished || cleaned
143
+ return { text: polished || cleaned }
154
144
  } catch (err: any) {
155
145
  autoCleanBreaker.recordFailure()
156
146
  console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
157
- return cleaned
147
+ return { text: cleaned, polishSkipped: 'failed' }
158
148
  }
159
149
  }
160
150
 
@@ -178,12 +168,14 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
178
168
 
179
169
  const abort = new AbortController()
180
170
  res.on('close', () => { if (!res.writableEnded) abort.abort() })
181
- const finalText = await cleanOutboundDictation(text, {
171
+ const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, {
182
172
  enabled: true,
183
173
  model,
174
+ bucket: surface,
184
175
  signal: abort.signal,
185
176
  })
186
- res.json({ text: finalText, surface, polished: finalText !== text })
177
+ // 6.79.1: polishSkipped only when the model did not polish, so a polished answer is unchanged.
178
+ res.json({ text: finalText, surface, polished: finalText !== text, ...(polishSkipped ? { polishSkipped } : {}) })
187
179
  })
188
180
 
189
181
  async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
@@ -446,7 +438,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
446
438
  await markPromptDraftError(draftId, 'No speech detected')
447
439
  throw new NoSpeechDetectedError()
448
440
  }
449
- const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
441
+ const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
450
442
  const finalized = await markPromptDraftFinalized(draftId, finalText)
451
443
  const qualities = transcriptRecords.map(record => record.actualQuality)
452
444
  const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
@@ -474,6 +466,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
474
466
  // 6.73.0, additive.
475
467
  engine: engines.length === 1 ? engines[0] : engines.length === 0 ? plan.engine : 'mixed',
476
468
  ...(engineReason ? { engineReason } : {}),
469
+ // 6.79.1, additive.
470
+ ...(polishSkipped ? { polishSkipped } : {}),
477
471
  }
478
472
  }
479
473