@gotcos/glasses-server 6.83.0 → 6.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,15 @@ import { MAC_CAPTURE_ID } from './mac-recording.js'
24
24
  import { MacParakeetUnavailableError } from './mac-recording-asr.js'
25
25
  import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
26
26
  import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
27
- import { getVocabulary } from './profile.js'
27
+ import { getVocabulary, getWhisperCorrections } from './profile.js'
28
+ import { decoderContextWindow } from './glossary-learn.js'
29
+
30
+ /** 6.83.1: the decoder's priming window, with correction-only forms put back to speech before the cut. */
31
+ function decoderWindow(previousText?: string): string | undefined {
32
+ let corrections: Record<string, string> = {}
33
+ try { corrections = getWhisperCorrections() } catch { /* a test double without it reads as none */ }
34
+ return decoderContextWindow(previousText, corrections)
35
+ }
28
36
  import { captureSpeakerMode, policyLabel } from './meeting-speaker-policy.js'
29
37
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
30
38
  import { isMetalBatchPreempted } from './whisper-metal-gate.js'
@@ -526,7 +534,7 @@ async function decodeWhisperOnce(
526
534
  audioDir: string,
527
535
  ): Promise<HighQualityTranscriptionResult> {
528
536
  try {
529
- return await deps.whisper(input, previousText?.slice(-250), { priority: 'batch', timestamps })
537
+ return await deps.whisper(input, decoderWindow(previousText), { priority: 'batch', timestamps })
530
538
  } catch (error) {
531
539
  // A live meeting took the GPU mid-segment. The truncated Metal output
532
540
  // was already discarded upstream (BLOCKER contract) — it is never
@@ -539,7 +547,7 @@ async function decodeWhisperOnce(
539
547
  + 'retrying once on CPU',
540
548
  )
541
549
  refreshPendingLease(audioDir)
542
- return deps.whisper(input, previousText?.slice(-250), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
550
+ return deps.whisper(input, decoderWindow(previousText), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
543
551
  }
544
552
  }
545
553
 
@@ -1043,7 +1051,7 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
1043
1051
  const identity = currentCheckpointIdentity()
1044
1052
  const enhanced = await asrFrontEnd(combined, identity.frontEnd)
1045
1053
  if (controller.signal.aborted || state.closed) return
1046
- const decoded = await transcribeHighQuality(enhanced, pending.previousText?.slice(-250), {
1054
+ const decoded = await transcribeHighQuality(enhanced, decoderWindow(pending.previousText), {
1047
1055
  priority: 'batch',
1048
1056
  forceCpu: true,
1049
1057
  forceCpuReason: 'progressive_checkpoint',
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
6
  import { resolve, dirname } from 'node:path'
7
7
  import { atomicWriteFileSync } from './atomic-fs.js'
8
+ import { looksLikeUrlEmailPath } from './glossary-learn.js'
8
9
 
9
10
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
11
 
@@ -136,7 +137,8 @@ export function getVocabulary(): string[] {
136
137
  if (typeof value !== 'string') return []
137
138
  const term = value.trim()
138
139
  const key = term.toLowerCase()
139
- if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key)) return []
140
+ // 6.83.1: a link, email, handle or path never steers the decoder, even hand-written here.
141
+ if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key) || looksLikeUrlEmailPath(term)) return []
140
142
  seen.add(key)
141
143
  return [term]
142
144
  })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 6.84.1: the in-flight query jobs, as COS Control's Activity home lists them in its Running
3
+ * panel (GET /api/query-jobs/active, docs/active-query-jobs.md).
4
+ *
5
+ * Read-only and in memory. The store hands each non-terminal hydrated job to
6
+ * `projectActiveQueryJob` and nothing else leaves it: never the prompt body, the reference
7
+ * turn, the partial answer, tool activity or the trail. The title is the prompt's first
8
+ * non-blank line, redacted with the store's own credential and path patterns, flattened to
9
+ * one line and cut to ACTIVE_QUERY_JOB_TITLE_MAX characters.
10
+ */
11
+ import {
12
+ flattenDisplayText,
13
+ redactQueryJobText,
14
+ type QueryJobProviderLinkage,
15
+ type QueryJobRequest,
16
+ type QueryJobSnapshot,
17
+ type QueryJobStatus,
18
+ } from './query-job-types.js'
19
+
20
+ export const ACTIVE_QUERY_JOBS_PROTOCOL = 1 as const
21
+ /** Longest title, in characters (grapheme clusters where the runtime can count them), ellipsis included. */
22
+ export const ACTIVE_QUERY_JOB_TITLE_MAX = 80
23
+ /** Most items one answer carries. `count` is always the full number. */
24
+ export const ACTIVE_QUERY_JOBS_LIST_MAX = 50
25
+ /** Only this much of a prompt (up to 48,000 characters) is ever read to find its first line. */
26
+ const TITLE_SCAN_CHARS = 1_000
27
+ const ELLIPSIS = '…'
28
+
29
+ /**
30
+ * Who asked, as far as the job record can tell:
31
+ * - `person`: typed on the phone or spoken on the glasses. The phone app sends both the same
32
+ * way (no `origin`, with a message number), so the server cannot tell the two apart.
33
+ * - `routine`: a scheduled routine (the morning brief and the routines scheduler).
34
+ * - `task`: a dispatch of a captured task.
35
+ * - `other`: anything else with no message number (COS Control Work sessions, meeting reviews).
36
+ */
37
+ export type ActiveQueryJobOrigin = 'person' | 'routine' | 'task' | 'other'
38
+
39
+ /** Coarse state for a list row. `status` carries the job model's own value. */
40
+ export type ActiveQueryJobState = 'queued' | 'running' | 'finishing'
41
+
42
+ export type ActiveQueryJobStatus = Exclude<QueryJobStatus, 'completed' | 'failed' | 'canceled' | 'interrupted'>
43
+
44
+ export interface ActiveQueryJob {
45
+ jobId: string
46
+ generation: number
47
+ origin: ActiveQueryJobOrigin
48
+ /** The routine slug or task id, only for `routine` and `task`. */
49
+ originId?: string
50
+ state: ActiveQueryJobState
51
+ status: ActiveQueryJobStatus
52
+ acceptedAt: string
53
+ startedAt?: string
54
+ /** Milliseconds since the job was accepted (the person's wait), never negative. */
55
+ elapsedMs: number
56
+ provider?: QueryJobProviderLinkage['provider']
57
+ /** The model asked for (a slot such as `opus`, or `profile:<id>`). */
58
+ model?: string
59
+ /** The model the provider reported, once it started. */
60
+ resolvedModel?: string
61
+ /** The message number the phone minted for this question. */
62
+ messageNo?: number
63
+ title: string
64
+ }
65
+
66
+ export interface ActiveQueryJobsList {
67
+ items: ActiveQueryJob[]
68
+ /** Non-terminal jobs whose record is not held in memory (listed only in `count`). */
69
+ unlisted: number
70
+ }
71
+
72
+ export function activeQueryJobState(status: ActiveQueryJobStatus): ActiveQueryJobState {
73
+ if (status === 'accepted') return 'queued'
74
+ if (status === 'answer_ready') return 'finishing'
75
+ return 'running'
76
+ }
77
+
78
+ export function activeQueryJobOrigin(request: Readonly<Pick<QueryJobRequest, 'origin' | 'globalMsgNum'>>): { origin: ActiveQueryJobOrigin; originId?: string } {
79
+ if (request.origin?.kind === 'routine') return { origin: 'routine', originId: request.origin.id }
80
+ if (request.origin?.kind === 'task') return { origin: 'task', originId: request.origin.id }
81
+ if (typeof request.globalMsgNum === 'number' && request.globalMsgNum > 0) return { origin: 'person' }
82
+ return { origin: 'other' }
83
+ }
84
+
85
+ const graphemeSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
86
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
87
+ : null
88
+
89
+ function characters(text: string): string[] {
90
+ return graphemeSegmenter ? Array.from(graphemeSegmenter.segment(text), part => part.segment) : Array.from(text)
91
+ }
92
+
93
+ /** At most `max` characters, the last one an ellipsis when cut, back to a word break in the second half. */
94
+ export function truncateActiveTitle(text: string, max = ACTIVE_QUERY_JOB_TITLE_MAX): string {
95
+ const chars = characters(text)
96
+ if (chars.length <= max) return text
97
+ const cut = chars.slice(0, max - 1)
98
+ const space = cut.lastIndexOf(' ')
99
+ const kept = (space >= (max - 1) / 2 ? cut.slice(0, space) : cut).join('').trimEnd()
100
+ return `${kept}${ELLIPSIS}`
101
+ }
102
+
103
+ /** The prompt's first non-blank line as plain text: redacted, one line, at most 80 characters. */
104
+ export function activeQueryJobTitle(query: string): string {
105
+ if (typeof query !== 'string' || !query) return ''
106
+ const head = query.slice(0, TITLE_SCAN_CHARS)
107
+ const line = head.split(/\r\n|[\n\r\u2028\u2029]/).find(candidate => candidate.trim().length > 0) ?? ''
108
+ if (!line) return ''
109
+ return truncateActiveTitle(flattenDisplayText(redactQueryJobText(line)))
110
+ }
111
+
112
+ /** One row. The caller passes only non-terminal jobs (QueryJobStore.listInFlight checks the status). */
113
+ export function projectActiveQueryJob(request: Readonly<QueryJobRequest>, snapshot: Readonly<QueryJobSnapshot>, nowMs: number): ActiveQueryJob {
114
+ const status = snapshot.status as ActiveQueryJobStatus
115
+ const acceptedMs = Date.parse(snapshot.acceptedAt)
116
+ const elapsedMs = Number.isFinite(acceptedMs) ? Math.max(0, Math.round(nowMs - acceptedMs)) : 0
117
+ const model = snapshot.requestedModel ?? request.model
118
+ // A COS Control Work session carries the task's display name; it reads better than a prompt line.
119
+ const named = request.sessionName ? truncateActiveTitle(flattenDisplayText(redactQueryJobText(request.sessionName))) : ''
120
+ return {
121
+ jobId: snapshot.jobId,
122
+ generation: snapshot.generation,
123
+ ...activeQueryJobOrigin(request),
124
+ state: activeQueryJobState(status),
125
+ status,
126
+ acceptedAt: snapshot.acceptedAt,
127
+ ...(snapshot.startedAt ? { startedAt: snapshot.startedAt } : {}),
128
+ elapsedMs,
129
+ ...(snapshot.provider ? { provider: snapshot.provider } : {}),
130
+ ...(model ? { model } : {}),
131
+ ...(snapshot.resolvedModel ? { resolvedModel: snapshot.resolvedModel } : {}),
132
+ ...(typeof snapshot.globalMsgNum === 'number' ? { messageNo: snapshot.globalMsgNum } : {}),
133
+ title: named || activeQueryJobTitle(request.query),
134
+ }
135
+ }
136
+
137
+ /** Oldest first; a stable order for a list that refreshes every minute. */
138
+ export function compareActiveQueryJobs(a: ActiveQueryJob, b: ActiveQueryJob): number {
139
+ return a.acceptedAt.localeCompare(b.acceptedAt) || a.jobId.localeCompare(b.jobId)
140
+ }
@@ -32,6 +32,7 @@ import {
32
32
  type QueryJobStoreHealth,
33
33
  } from './query-job-types.js'
34
34
  import type { QueryJobKeyAnswerFields } from './key-answer-fields.js'
35
+ import { compareActiveQueryJobs, projectActiveQueryJob, type ActiveQueryJob } from './query-job-active.js'
35
36
  import { SteerPort, steerClock, steerCutoffMs, type PendingSteer, type QueryProvider } from './claude-steer.js'
36
37
  import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
37
38
  import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
@@ -1111,6 +1112,13 @@ export class QueryJobCoordinator {
1111
1112
  return this.store.listLiveMessageReservations()
1112
1113
  }
1113
1114
 
1115
+ /** 6.84.1: the in-flight jobs for GET /api/query-jobs/active. In memory and synchronous. */
1116
+ activeJobs(nowMs: number = Date.now()): { items: ActiveQueryJob[]; count: number } {
1117
+ const { items, unlisted } = this.store.listInFlight((request, snapshot) => projectActiveQueryJob(request, snapshot, nowMs))
1118
+ items.sort(compareActiveQueryJobs)
1119
+ return { items, count: items.length + unlisted }
1120
+ }
1121
+
1114
1122
  getHealth(): QueryJobCoordinatorHealth {
1115
1123
  return {
1116
1124
  activeRuns: this.active.size,
@@ -1829,6 +1829,31 @@ export class QueryJobStore {
1829
1829
  return out
1830
1830
  }
1831
1831
 
1832
+ /**
1833
+ * 6.84.1: the jobs admitted and not yet terminal, each passed to `project` with its live
1834
+ * request and snapshot (READ ONLY: nothing is cloned, so a projection must copy what it
1835
+ * keeps and never write). Synchronous and in memory, like listLiveMessageReservations: no
1836
+ * hydration, no journal read, no await. A non-terminal identity whose body is not hydrated
1837
+ * (the LRU evicts terminal jobs first, so only past QUERY_JOB_LIMITS.hydratedJobs live
1838
+ * ones) is counted in `unlisted` instead of read from disk.
1839
+ */
1840
+ listInFlight<T>(project: (request: Readonly<QueryJobRequest>, snapshot: Readonly<QueryJobSnapshot>) => T | null): { items: T[]; unlisted: number } {
1841
+ const items: T[] = []
1842
+ let unlisted = 0
1843
+ for (const identity of this.identitiesByJobId.values()) {
1844
+ if (isTerminalQueryJobStatus(identity.status)) continue
1845
+ const hydrated = this.jobs.get(identity.jobId)
1846
+ if (!hydrated) {
1847
+ unlisted++
1848
+ continue
1849
+ }
1850
+ // The identity's status is rebuilt with the snapshot on every record, so one check covers both.
1851
+ const item = project(hydrated.request, hydrated.snapshot)
1852
+ if (item != null) items.push(item)
1853
+ }
1854
+ return { items, unlisted }
1855
+ }
1856
+
1832
1857
  async findByClientGeneration(clientJobId: string, generation: number): Promise<QueryJobSnapshot | undefined> {
1833
1858
  await this.ensureInitialized()
1834
1859
  const identity = this.identitiesByKey.get(identityKey(clientJobId.toLowerCase(), generation))
@@ -34,6 +34,8 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
34
34
  sessionId?: string
35
35
  clientJobId?: string
36
36
  usageKind?: 'provider_final' | 'estimated'
37
+ /** 6.85.0, dictation polish only: whether the user's polish instructions were in the prompt, and their hash. */
38
+ polishInstructions?: { on: boolean; hash: string }
37
39
  }
38
40
 
39
41
  /**
@@ -61,6 +63,11 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
61
63
  ...(entry.lane ? { lane: entry.lane } : {}),
62
64
  ...(entry.credentialKind ? { credentialKind: entry.credentialKind } : {}),
63
65
  ...(typeof entry.usd === 'number' && Number.isFinite(entry.usd) ? { usd: entry.usd } : {}),
66
+ // 6.85.0: set only by the dictation polish caller, so every other row is unchanged. Never the text.
67
+ ...(entry.polishInstructions ? {
68
+ custom_instructions: entry.polishInstructions.on,
69
+ ...(entry.polishInstructions.hash ? { instructions_hash: entry.polishInstructions.hash } : {}),
70
+ } : {}),
64
71
  }
65
72
  }
66
73
 
@@ -43,8 +43,16 @@ export interface QuarantineManifest {
43
43
  reason: string
44
44
  chunkFiles: number
45
45
  bytes: number
46
+ /** 6.83.2: a shorter clock for this one capture (a Mac capture discarded with no sound). Never longer than the global one. */
47
+ retentionMs?: number
46
48
  }
47
49
 
50
+ /** 6.83.2: how long a discarded no-sound Mac capture stays quarantined: long enough to undo a wrong discard by hand. */
51
+ export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
52
+
53
+ /** The receipt a discard writes, so the capture is terminal (`no_speech`) and never recovered on its own. */
54
+ export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
55
+
48
56
  export interface UnsavedCapture {
49
57
  sessionId: string
50
58
  dirName: string
@@ -57,6 +65,12 @@ export interface UnsavedCapture {
57
65
  recovered: boolean
58
66
  }
59
67
 
68
+ /** The clock that applies to one capture: its own when it set a shorter one, else the global retention. */
69
+ export function effectiveRetentionMs(manifest: { retentionMs?: unknown } | null, retentionMs: number): number {
70
+ const own = manifest?.retentionMs
71
+ return typeof own === 'number' && Number.isFinite(own) && own > 0 ? Math.min(retentionMs, own) : retentionMs
72
+ }
73
+
60
74
  export function unsavedAudioRoot(): string {
61
75
  return dataPath('unsaved-audio')
62
76
  }
@@ -99,6 +113,7 @@ export function quarantineSessionAudio(
99
113
  sourceDir: string,
100
114
  reason: string,
101
115
  root: string = unsavedAudioRoot(),
116
+ options: { retentionMs?: number; receipt?: QuarantineReceipt } = {},
102
117
  ): string | null {
103
118
  const sessionId = basename(sourceDir)
104
119
  try {
@@ -114,6 +129,7 @@ export function quarantineSessionAudio(
114
129
  reason,
115
130
  chunkFiles: countChunkWavs(target),
116
131
  bytes: dirBytes(target),
132
+ ...(options.retentionMs ? { retentionMs: options.retentionMs } : {}),
117
133
  }
118
134
  try {
119
135
  writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
@@ -121,6 +137,19 @@ export function quarantineSessionAudio(
121
137
  mode: 0o600,
122
138
  })
123
139
  } catch { /* manifest is observability; the audio move already succeeded */ }
140
+ if (options.receipt) {
141
+ // 6.83.2: a discarded capture is terminal. The receipt is what keeps the auto-recover sweep and the
142
+ // unsaved-captures count off it (listUnsavedCaptures reads it as recovered, readCaptureDisposition as no_speech).
143
+ try {
144
+ writeFileSync(resolve(target, RECOVERED_RECEIPT), `${JSON.stringify({
145
+ schemaVersion: 1,
146
+ recoveredAt: new Date().toISOString(),
147
+ chunkFiles: manifest.chunkFiles,
148
+ words: 0,
149
+ ...options.receipt,
150
+ })}\n`, { encoding: 'utf8', mode: 0o600 })
151
+ } catch { /* the audio is quarantined either way; without the receipt it is merely listed as recoverable */ }
152
+ }
124
153
  return target
125
154
  } catch {
126
155
  // Rename failed (cross-device, permissions, race). Leave the source alone —
@@ -220,6 +249,7 @@ export function listUnsavedCaptures(
220
249
  } catch { continue }
221
250
  const manifest = readManifest(dirPath)
222
251
  const atMs = quarantinedAtMs(dirPath, manifest)
252
+ const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
223
253
  captures.push({
224
254
  sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
225
255
  dirName: dir,
@@ -228,7 +258,7 @@ export function listUnsavedCaptures(
228
258
  chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
229
259
  bytes: manifest?.bytes ?? dirBytes(dirPath),
230
260
  reason: manifest?.reason ?? null,
231
- expiresAt: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
261
+ expiresAt: atMs != null ? new Date(atMs + ownRetentionMs).toISOString() : null,
232
262
  recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
233
263
  })
234
264
  }
@@ -248,9 +278,10 @@ export function purgeExpiredQuarantine(
248
278
  // sitting exactly at the retention boundary would otherwise lose its
249
279
  // audio mid-run, in the module whose thesis is "never delete evidence".
250
280
  if (isRecoveryActive(capture.dirName)) continue
251
- const atMs = capture.quarantinedAt ? Date.parse(capture.quarantinedAt) : NaN
252
- if (!Number.isFinite(atMs)) continue
253
- if (Date.now() - atMs > retentionMs) {
281
+ // The capture's own expiry: a discarded no-sound capture sets a shorter clock (6.83.2).
282
+ const expiresMs = capture.expiresAt ? Date.parse(capture.expiresAt) : NaN
283
+ if (!Number.isFinite(expiresMs)) continue
284
+ if (Date.now() > expiresMs) {
254
285
  try {
255
286
  rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
256
287
  purged.push(capture.dirName)
@@ -433,7 +464,7 @@ function dispositionOf(sessionId: string, dirPath: string, retentionMs: number):
433
464
  quarantineReason: manifest?.reason ?? null,
434
465
  savedFilename,
435
466
  recovering: isRecoveryActive(basename(dirPath)),
436
- retainedUntil: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
467
+ retainedUntil: atMs != null ? new Date(atMs + effectiveRetentionMs(manifest, retentionMs)).toISOString() : null,
437
468
  }
438
469
  }
439
470
 
@@ -16,6 +16,7 @@ import crypto from 'node:crypto'
16
16
  import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
17
17
  import { readSttSettings, type FrontEndId } from './stt-settings.js'
18
18
  import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
19
+ import { decoderSafeContext } from './glossary-learn.js'
19
20
  import { stripBrandUrls } from './hallucination-filter.js'
20
21
  import {
21
22
  batchHqMetalEnabled,
@@ -1243,8 +1244,9 @@ export async function transcribeHighQuality(
1243
1244
  }
1244
1245
  }
1245
1246
 
1246
- /** Build prompt with optional previous transcript context for continuity */
1247
- function buildPrompt(context?: string, isQuiet?: boolean): string {
1247
+ /** Build prompt with optional previous transcript context for continuity.
1248
+ * Exported (6.83.1) only so tests can assert on the exact decoder prompt. */
1249
+ export function buildPrompt(context?: string, isQuiet?: boolean): string {
1248
1250
  // During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
1249
1251
  if (isQuiet) return ''
1250
1252
  if (!context || context.length < 10) return getWhisperPrompt()
@@ -1259,6 +1261,9 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
1259
1261
  // prior chunk would otherwise feed back through this context and re-seed the
1260
1262
  // next decode. Strip brand URLs from the priming context (output unaffected).
1261
1263
  if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
1264
+ // 6.83.1: a correction-only form ("/pricing") came from the corrections pass, not the
1265
+ // speaker's audio; the decoder is primed with the spoken form instead (glossary-learn.ts).
1266
+ sanitized = decoderSafeContext(sanitized, getWhisperCorrections())
1262
1267
  // Previous transcript helps Whisper maintain proper noun consistency,
1263
1268
  // continue sentences across chunk boundaries, and reduce hallucinations
1264
1269
  return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
@@ -1379,14 +1384,34 @@ function buildCorrections(): Array<[RegExp, string]> {
1379
1384
 
1380
1385
  // Escape correction keys before interpolation. Names such as "A.C.M.E."
1381
1386
  // are literal vocabulary, never regular-expression programs.
1382
- for (const [pattern, replacement] of Object.entries(getWhisperCorrections())) {
1383
- const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1387
+ // 6.83.1: the words of a multi-word key may be split by a run of spaces or tabs, or by one
1388
+ // hyphen ("forward-slash pricing"); never by a line break or a spaced dash (" - ").
1389
+ for (const [pattern, replacement] of correctionOrder(getWhisperCorrections())) {
1390
+ const escaped = pattern.trim().split(/\s+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:[ \\t]+|-)')
1384
1391
  corrections.push([new RegExp(`\\b${escaped}\\b`, 'gi'), replacement])
1385
1392
  }
1386
1393
 
1387
1394
  return corrections
1388
1395
  }
1389
1396
 
1397
+ /**
1398
+ * The profile's order, except that a key goes ahead of any earlier key whose words it
1399
+ * contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
1400
+ * the shorter key would cut the longer phrase short. Chains the person relies on
1401
+ * ("Myles" then "Miles Ukoma") keep their order, since neither key contains the other.
1402
+ */
1403
+ export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
1404
+ const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
1405
+ const out: Array<[string, string]> = []
1406
+ for (const entry of Object.entries(corrections)) {
1407
+ const mine = norm(entry[0])
1408
+ const at = out.findIndex(([key]) => mine !== norm(key) && mine.includes(norm(key)))
1409
+ if (at < 0) out.push(entry)
1410
+ else out.splice(at, 0, entry)
1411
+ }
1412
+ return out
1413
+ }
1414
+
1390
1415
  let _corrections: Array<[RegExp, string]> | null = null
1391
1416
  function getCorrections(): Array<[RegExp, string]> {
1392
1417
  if (!_corrections) _corrections = buildCorrections()
@@ -1399,7 +1424,8 @@ function getCorrections(): Array<[RegExp, string]> {
1399
1424
  export function applyCorrections(text: string): string {
1400
1425
  let result = text
1401
1426
  for (const [pattern, replacement] of getCorrections()) {
1402
- result = result.replace(pattern, replacement)
1427
+ // A function, so a written form holding "$&" or "$1" is inserted literally (6.83.1).
1428
+ result = result.replace(pattern, () => replacement)
1403
1429
  }
1404
1430
  return result
1405
1431
  }
@@ -1474,7 +1500,8 @@ export async function transcribeLocal(
1474
1500
  console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
1475
1501
  serverConsecutiveFailures = 0
1476
1502
  }
1477
- console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words): "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`)
1503
+ // 6.85.0: counts only. Every meeting chunk, dictation and draft passes here, so the text stays out of the log.
1504
+ console.log(`[whisper-local] Server transcribed in ${elapsed}ms (${words?.length ?? 0} words, ${text.length} chars)`)
1478
1505
  return { text, backend: 'server', words }
1479
1506
  } catch (err: any) {
1480
1507
  if (previewLease?.signal.aborted) throw new MetalPreviewContendedError()
@@ -0,0 +1,91 @@
1
+ // 6.85.0 "Your polish instructions". Token-protected by the /api middleware.
2
+ //
3
+ // GET /api/dictation/polish/instructions the saved instructions (or ""), the cap, a hash
4
+ // PUT /api/dictation/polish/instructions {"customInstructions": "..."}; "" turns it off
5
+ // DELETE /api/dictation/polish/instructions Clear (the same as PUT "")
6
+ // GET /api/dictation/polish/rules the built-in rules in use (v3, or v2 when rolled
7
+ // back) and the block the instructions go into
8
+ //
9
+ // Clients find it through capabilities.dictationCleanup.customInstructions on /api/health and
10
+ // /api/models; an older server omits it (and answers 404 here), so the clients hide the box.
11
+ // The text is never logged and never returned anywhere but GET/PUT here.
12
+
13
+ import { Router } from 'express'
14
+ import {
15
+ DEFAULT_DICTATION_POLISH_PROMPT, DICTATION_POLISH_SYSTEM_PROMPTS, dictationPolishPromptVersion, dictationPolishRules,
16
+ USER_PREFERENCES_INTRO, USER_PREFERENCES_PRECEDENCE,
17
+ } from '../lib/dictation-clean.js'
18
+ import {
19
+ CUSTOM_INSTRUCTIONS_MAX_CHARS, CUSTOM_INSTRUCTIONS_PROTOCOL, customInstructionsHash, readCustomInstructions,
20
+ sanitizeCustomInstructions, writeCustomInstructions, type StoredInstructions,
21
+ } from '../lib/dictation-polish-instructions.js'
22
+
23
+ /** Where the instructions apply (prompt-drafts.ts polishInstructionsFor): never meeting lines. */
24
+ export const CUSTOM_INSTRUCTIONS_APPLY_TO = ['message', 'prompt_draft'] as const
25
+
26
+ /** What capabilities.dictationCleanup carries on both health surfaces. Codes and numbers only. */
27
+ export function customInstructionsCapability() {
28
+ return { protocol: CUSTOM_INSTRUCTIONS_PROTOCOL, maxChars: CUSTOM_INSTRUCTIONS_MAX_CHARS }
29
+ }
30
+
31
+ function view(stored: StoredInstructions) {
32
+ return {
33
+ customInstructions: stored.customInstructions,
34
+ enabled: stored.customInstructions.length > 0,
35
+ updatedAt: stored.updatedAt,
36
+ instructionsHash: customInstructionsHash(stored.customInstructions),
37
+ maxChars: CUSTOM_INSTRUCTIONS_MAX_CHARS,
38
+ appliesTo: CUSTOM_INSTRUCTIONS_APPLY_TO,
39
+ }
40
+ }
41
+
42
+ /** The placeholder the rules view shows where the user's own words go. */
43
+ export const INSTRUCTIONS_PLACEHOLDER = '(your polish instructions)'
44
+
45
+ export const dictationPolishSettingsRouter = Router()
46
+
47
+ dictationPolishSettingsRouter.get('/dictation/polish/instructions', (_req, res) => {
48
+ res.json(view(readCustomInstructions()))
49
+ })
50
+
51
+ dictationPolishSettingsRouter.put('/dictation/polish/instructions', (req, res) => {
52
+ const body = req.body
53
+ if (!body || typeof body !== 'object' || Array.isArray(body) || !Object.keys(body).every(key => key === 'customInstructions')) {
54
+ return res.status(400).json({ error: 'invalid_body', expected: { customInstructions: 'string' } })
55
+ }
56
+ const clean = sanitizeCustomInstructions(body.customInstructions)
57
+ if (!clean.ok) {
58
+ return res.status(clean.error === 'too_long' ? 413 : 400).json({
59
+ error: clean.error === 'too_long' ? 'instructions_too_long' : 'instructions_not_a_string',
60
+ maxChars: clean.maxChars, ...(clean.length !== undefined ? { length: clean.length } : {}),
61
+ })
62
+ }
63
+ try {
64
+ res.json(view(writeCustomInstructions(clean.value)))
65
+ } catch {
66
+ // The reason stays generic: an fs error can carry the path, never the text, but say less.
67
+ res.status(500).json({ error: 'instructions_not_saved' })
68
+ }
69
+ })
70
+
71
+ dictationPolishSettingsRouter.delete('/dictation/polish/instructions', (_req, res) => {
72
+ try {
73
+ res.json(view(writeCustomInstructions('')))
74
+ } catch {
75
+ res.status(500).json({ error: 'instructions_not_saved' })
76
+ }
77
+ })
78
+
79
+ dictationPolishSettingsRouter.get('/dictation/polish/rules', (_req, res) => {
80
+ const version = dictationPolishPromptVersion()
81
+ res.json({
82
+ version,
83
+ defaultVersion: DEFAULT_DICTATION_POLISH_PROMPT,
84
+ rolledBack: version !== DEFAULT_DICTATION_POLISH_PROMPT,
85
+ systemPrompt: DICTATION_POLISH_SYSTEM_PROMPTS[version],
86
+ rules: dictationPolishRules(version).join('\n'),
87
+ // The block exactly as the model sees it, with a placeholder for the user's words.
88
+ userBlock: [...USER_PREFERENCES_INTRO, '<user-style-preferences>', INSTRUCTIONS_PLACEHOLDER, '</user-style-preferences>', USER_PREFERENCES_PRECEDENCE].join('\n'),
89
+ maxChars: CUSTOM_INSTRUCTIONS_MAX_CHARS,
90
+ })
91
+ })
@@ -8,7 +8,9 @@
8
8
  // left unchanged). whisper_corrections is stored as a JSON STRING in the
9
9
  // profile (legacy decoder contract), so the route encodes/decodes it here.
10
10
  // POST /api/transcription-glossary/correction {heard, written, source} → learn
11
- // ONE correction and its spelling (6.73.11, Fix a word), logged for undo.
11
+ // ONE correction and its spelling (6.73.11, Fix a word), logged for undo. A
12
+ // written link, email or path is a correction only (6.83.1): the answer adds
13
+ // correctionOnly: true and a note, and the vocabulary is untouched.
12
14
  // GET /api/transcription-glossary/recent → { entries } newest first.
13
15
  // POST /api/transcription-glossary/correction/undo {id} → revert that entry.
14
16
  // POST /api/transcription-glossary/vocabulary/add {term}, /vocabulary/remove {term},
@@ -32,6 +34,8 @@ import {
32
34
  MAX_TERM_LEN,
33
35
  addVocabulary,
34
36
  appendLog,
37
+ CORRECTION_ONLY_NOTE,
38
+ isCorrectionOnly,
35
39
  learnCorrection,
36
40
  looksLikeUrlEmailPath,
37
41
  parseLog,
@@ -219,7 +223,15 @@ export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
219
223
  if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
220
224
  if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
221
225
  commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
222
- return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
226
+ // 6.83.1: a link, email or path is kept as a correction only; say so in words a client can show.
227
+ const correctionOnly = isCorrectionOnly(learned.entry.written)
228
+ return {
229
+ status: 200,
230
+ body: {
231
+ unchanged: false, entry: learned.entry, glossary: currentGlossary(),
232
+ ...(correctionOnly ? { correctionOnly: true, note: CORRECTION_ONLY_NOTE } : {}),
233
+ },
234
+ }
223
235
  })
224
236
  }
225
237
 
@@ -1,4 +1,5 @@
1
1
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
2
+ import { customInstructionsCapability } from './dictation-polish-settings.js'
2
3
  import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
3
4
  import { taskBridgeAvailable } from '../lib/task-bridge.js'
4
5
  import { Router } from 'express'
@@ -59,6 +60,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
59
60
  // whether they are actually registered.
60
61
  import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
61
62
  import { durableQueryJobsCapability, messagesSteerCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
63
+ import { ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
62
64
  import { claudeSteerSupportKnown } from '../lib/claude-cli-support.js'
63
65
  import { jobTrailStats } from '../lib/job-trail.js'
64
66
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
@@ -296,6 +298,11 @@ healthRouter.get('/health', async (_req, res) => {
296
298
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
297
299
  durableQueryJobs: durableJobs.enabled,
298
300
  durableQueryJobsProtocol: durableJobs.protocolVersion,
301
+ // 6.84.1: GET /api/query-jobs/active (docs/active-query-jobs.md), the in-flight jobs for
302
+ // COS Control's Running panel. Static: this build has the route; it lists nothing while
303
+ // durableQueryJobs is false. Absent on older servers, which answer that path 400.
304
+ activeQueryJobs: true,
305
+ activeQueryJobsProtocol: ACTIVE_QUERY_JOBS_PROTOCOL,
299
306
  // Static: this build carries the scheduler. The live schedule is under
300
307
  // capabilities.morningBrief and may be absent while the runtime boots.
301
308
  morningBrief: true,
@@ -473,7 +480,7 @@ healthRouter.get('/health', async (_req, res) => {
473
480
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
474
481
  capabilities: {
475
482
  meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
476
- dictationCleanup: { models: DICTATION_CLEAN_MODELS },
483
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability() },
477
484
  // 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
478
485
  // reads it before it holds a token, and an older server omits it, which is how
479
486
  // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
@@ -660,7 +667,7 @@ healthRouter.get('/models', async (req, res) => {
660
667
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
661
668
  capabilities: {
662
669
  meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
663
- dictationCleanup: { models: DICTATION_CLEAN_MODELS },
670
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability() },
664
671
  durableQueryJobs: {
665
672
  enabled: durableJobs.enabled,
666
673
  protocolVersion: durableJobs.protocolVersion,