@gotcos/glasses-server 6.84.0 → 6.86.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +272 -6
  2. package/README.md +23 -0
  3. package/docs/active-query-jobs.md +123 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +6 -1
  6. package/server/index.ts +3 -0
  7. package/server/lib/dictation-clean.ts +53 -8
  8. package/server/lib/dictation-polish-instructions.ts +118 -0
  9. package/server/lib/dictation-prewarm.ts +2 -2
  10. package/server/lib/fuzzy-correct.ts +3 -5
  11. package/server/lib/live-cue-verdicts.ts +3 -2
  12. package/server/lib/live-cues-v2-prompts-kinds.ts +38 -0
  13. package/server/lib/live-cues-v2.ts +95 -21
  14. package/server/lib/live-provisional.ts +86 -0
  15. package/server/lib/meeting-ask-owner.ts +1 -1
  16. package/server/lib/meeting-fact-check.ts +1 -1
  17. package/server/lib/query-job-active.ts +140 -0
  18. package/server/lib/query-job-coordinator.ts +8 -0
  19. package/server/lib/query-job-store.ts +25 -0
  20. package/server/lib/token-audit.ts +7 -0
  21. package/server/lib/transcript-cleanup-context.ts +2 -2
  22. package/server/lib/transcript-cleanup-model.ts +1 -1
  23. package/server/lib/transcript-cleanup-spans.ts +1 -1
  24. package/server/lib/transcript-cleanup.ts +1 -1
  25. package/server/lib/whisper-local.ts +2 -1
  26. package/server/routes/dictation-polish-settings.ts +91 -0
  27. package/server/routes/health.ts +21 -4
  28. package/server/routes/live-cues.ts +8 -2
  29. package/server/routes/prompt-drafts.ts +33 -10
  30. package/server/routes/query-jobs.ts +22 -0
  31. package/server/routes/transcribe-stream.ts +53 -11
  32. package/server/routes/voice.ts +1 -1
  33. package/server/routes/welcome-context.ts +29 -6
  34. package/shared/live-cues-v2.ts +19 -3
@@ -0,0 +1,86 @@
1
+ // 6.86.0: the newest provisional (grey) line of a live meeting, for COS Control's live meeting view.
2
+ //
3
+ // The preview route (POST /transcribe-stream/preview) already turns the still-open phrase into fast provisional text and
4
+ // hands it to the client that posted the audio. Control reads the meeting from the active-session file and does not post
5
+ // the glasses audio, so it never saw that text. This keeps ONE entry per live meeting, the newest preview, in memory only,
6
+ // and GET /transcribe-stream/provisional serves it. The canonical chunk that settles the same index removes it.
7
+ //
8
+ // Why memory and a route, not a field in active-sessions/<id>.json:
9
+ // - that file is the 4-hour crash-recovery record. Anything written there outlives a crash; this text must not outlive
10
+ // the live session, so it never touches disk;
11
+ // - the file is written with fsync before each chunk is acknowledged. Writing it again per preview would add a durable
12
+ // write on the recording path, and churn the stamp Control uses to skip unchanged reads.
13
+ //
14
+ // Never logged: this module has no console call, and the route answers only to an authenticated client. Bounded: at most
15
+ // LIVE_PROVISIONAL_LIMITS.maxSessions meetings, the last maxChars characters, and an entry older than ttlMs reads as none.
16
+
17
+ export const LIVE_PROVISIONAL_LIMITS = {
18
+ /** The last characters (code points) of the preview are kept: the newest words are the ones still changing. */
19
+ maxChars: 600,
20
+ maxSessions: 8,
21
+ /** A preview not replaced or settled within this long reads as none (the phone stopped posting, a stuck chunk). */
22
+ ttlMs: 30_000,
23
+ } as const
24
+
25
+ export interface LiveProvisional {
26
+ chunkIndex: number
27
+ previewGen: number
28
+ text: string
29
+ model: string
30
+ /** Epoch ms when the server stored it. */
31
+ at: number
32
+ }
33
+
34
+ export class LiveProvisionalStore {
35
+ private readonly entries = new Map<string, LiveProvisional>()
36
+ constructor(private readonly now: () => number = Date.now) {}
37
+
38
+ /** Keeps `entry` when it is newer than the one held: a later chunk, or the same chunk with a later previewGen. A
39
+ * preview that arrives after its chunk settled is refused by the caller (settledThrough), never stored here. */
40
+ record(sessionId: string, entry: Omit<LiveProvisional, 'at'>): boolean {
41
+ const text = Array.from(entry.text.replace(/\s+/g, ' ').trim()).slice(-LIVE_PROVISIONAL_LIMITS.maxChars).join('')
42
+ if (!text) return false
43
+ const held = this.entries.get(sessionId)
44
+ if (held && !this.expired(held) && (entry.chunkIndex < held.chunkIndex || (entry.chunkIndex === held.chunkIndex && entry.previewGen <= held.previewGen))) return false
45
+ this.entries.delete(sessionId) // re-insert, so Map order is oldest first for eviction
46
+ this.entries.set(sessionId, { chunkIndex: entry.chunkIndex, previewGen: entry.previewGen, text, model: entry.model, at: this.now() })
47
+ for (const [id, e] of this.entries) if (this.expired(e)) this.entries.delete(id)
48
+ while (this.entries.size > LIVE_PROVISIONAL_LIMITS.maxSessions) {
49
+ const oldest = this.entries.keys().next().value
50
+ if (oldest === undefined) break
51
+ this.entries.delete(oldest)
52
+ }
53
+ return true
54
+ }
55
+
56
+ /** The canonical answer for `chunkIndex` landed (text, silence or a drop): a preview for it or an earlier chunk goes. */
57
+ settle(sessionId: string, chunkIndex: number): void {
58
+ const held = this.entries.get(sessionId)
59
+ if (held && held.chunkIndex <= chunkIndex) this.entries.delete(sessionId)
60
+ }
61
+
62
+ /** The meeting closed (saved, discarded, expired, quarantined). */
63
+ clear(sessionId: string): void { this.entries.delete(sessionId) }
64
+
65
+ read(sessionId: string): LiveProvisional | null {
66
+ const held = this.entries.get(sessionId)
67
+ if (!held) return null
68
+ if (this.expired(held)) { this.entries.delete(sessionId); return null }
69
+ return { ...held }
70
+ }
71
+
72
+ size(): number { return this.entries.size }
73
+
74
+ private expired(e: LiveProvisional): boolean { return this.now() - e.at > LIVE_PROVISIONAL_LIMITS.ttlMs }
75
+ }
76
+
77
+ let store: LiveProvisionalStore | null = null
78
+ export function liveProvisional(): LiveProvisionalStore { return (store ??= new LiveProvisionalStore()) }
79
+
80
+ /** 6.86.0: what /api/health and /api/models publish as `capabilities.liveMeeting`, so COS Control gates the live meeting
81
+ * view by capability, never by a status code. Constants only (health is public). An older server omits the key.
82
+ * - macLiveCues: POST /api/mac-recording/start takes `liveCues: true` and answers `liveCues: {armed, ...}`.
83
+ * - provisional: GET <route>?sessionId= serves the newest preview of a live meeting. */
84
+ export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number } } {
85
+ return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs } }
86
+ }
@@ -83,7 +83,7 @@ export interface OwnerMatch { heard: string; owner: string }
83
83
  /**
84
84
  * Whether the question names the owner, allowing ASR spellings. Full name: first and last words, in
85
85
  * order and adjacent. Surname alone: only after a lead-in word ("who is Ukoma"), never after another
86
- * first name ("Isaiah Ukaoma" is someone else). First name alone: only when it is the whole subject of
86
+ * first name ("Rafael Ukaoma" is someone else). First name alone: only when it is the whole subject of
87
87
  * a who question, compared exactly.
88
88
  */
89
89
  export function matchOwnerName(question: string, ownerName: string | null | undefined): OwnerMatch | null {
@@ -151,7 +151,7 @@ const LEAD_WORDS = new Set('i im am we were are it its is starting leaving going
151
151
  export const US_STATE_CODES = new Set('AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR GU VI AS MP'.split(' '))
152
152
  /**
153
153
  * Larger US cities and the owner's Austin metro: a typed "City, ST" origin must name one of these (static, no
154
- * private data), so "Amira TX" or "Silas PM" are never places.
154
+ * private data), so "Talia TX" or "Silas PM" are never places.
155
155
  */
156
156
  const US_CITIES = new Set(`new york|los angeles|chicago|houston|phoenix|philadelphia|san antonio|san diego|dallas|san jose|austin|jacksonville|fort worth|columbus|charlotte|san francisco|indianapolis|seattle|denver|washington|boston|el paso|nashville|detroit|oklahoma city|portland|las vegas|memphis|louisville|baltimore|milwaukee|albuquerque|tucson|fresno|mesa|sacramento|atlanta|kansas city|colorado springs|omaha|raleigh|miami|long beach|virginia beach|oakland|minneapolis|tulsa|tampa|arlington|new orleans|wichita|cleveland|bakersfield|aurora|anaheim|honolulu|santa ana|riverside|corpus christi|lexington|henderson|stockton|saint paul|st paul|st louis|saint louis|cincinnati|pittsburgh|greensboro|anchorage|plano|lincoln|orlando|irvine|newark|durham|chula vista|toledo|fort wayne|st petersburg|laredo|jersey city|chandler|madison|lubbock|scottsdale|reno|buffalo|gilbert|glendale|north las vegas|winston salem|chesapeake|norfolk|fremont|garland|irving|hialeah|richmond|boise|spokane|baton rouge|tacoma|san bernardino|modesto|fontana|des moines|santa clarita|fayetteville|birmingham|oxnard|rochester|grand rapids|huntsville|salt lake city|frisco|yonkers|amarillo|huntington beach|mckinney|montgomery|augusta|akron|little rock|tempe|overland park|grand prairie|tallahassee|cape coral|mobile|knoxville|shreveport|worcester|sioux falls|chattanooga|brownsville|fort lauderdale|providence|santa rosa|peoria|salem|eugene|cary|fort collins|springfield|jackson|alexandria|lancaster|pasadena|naperville|bellevue|killeen|midland|odessa|savannah|syracuse|denton|waco|charleston|hartford|new haven|stamford|columbia|santa clara|ann arbor|cambridge|lansing|evansville|college station|beaumont|provo|boulder|tyler|las cruces|green bay|san mateo|burbank|south bend|davenport|tuscaloosa|san angelo|round rock|cedar park|georgetown|leander|pflugerville|san marcos|new braunfels|liberty hill|hutto|kyle|buda|lakeway|bee cave|temple|bryan|abilene|wichita falls|galveston|mcallen|edinburg|pearland|sugar land|the woodlands|conroe|katy|allen|richardson|carrollton|lewisville|flower mound|mesquite|harlingen|key west|palm springs|santa fe|flagstaff|billings|fargo|bismarck|cheyenne|casper|missoula|juneau|burlington|portsmouth|manchester|wilmington|dover|annapolis|trenton|albany|montpelier|concord|augusta|bangor|duluth|des plaines|evanston|topeka|lawrence|olathe|independence|st joseph|joplin|branson|springdale|bentonville|rogers|fayetteville|hot springs|jonesboro|norman|edmond|lafayette|lake charles|monroe|gulfport|biloxi|hattiesburg|pensacola|gainesville|daytona beach|sarasota|naples|fort myers|clearwater|lakeland|ocala|asheville|greenville|myrtle beach|columbia|athens|macon|dothan|chattanooga|knoxville|murfreesboro|clarksville|bowling green|owensboro|dayton|youngstown|canton|erie|scranton|allentown|reading|harrisburg|lancaster|altoona|morgantown|roanoke|lynchburg|charlottesville|norfolk|hampton|newport news`.split('|'))
157
157
  /** A full city label the user typed: a listed US city, then a real state code ("Dallas, TX", "San Antonio TX"). */
@@ -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
 
@@ -10,7 +10,7 @@
10
10
  // SCREEN AWARENESS. On-screen text lines COS Control recorded during the meeting
11
11
  // (transcript-cleanup-screen.ts), shown beside each passage by time.
12
12
  //
13
- // TERMS. Every source contributes terms: capitalised words and runs of them ("Queen
13
+ // TERMS. Every source contributes terms: capitalised words and runs of them ("Grace
14
14
  // Ukaoma", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
15
15
  // may write a term from this meeting's screen or files, the model may write any term,
16
16
  // and a term the model writes that came from the meeting (not the dictionary) becomes a
@@ -86,7 +86,7 @@ export function extractTerms(text: string, max: number = CONTEXT_LIMITS.termsPer
86
86
  const w = words[i].w
87
87
  const capital = /^\p{Lu}/u.test(w)
88
88
  if (looksLikeName(w)) out.add(w)
89
- // "Queen Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
89
+ // "Grace Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
90
90
  if (capital && i + 1 < words.length && text.slice(words[i].end, words[i + 1].at) === ' ' && looksLikeName(words[i + 1].w)) {
91
91
  out.add(`${w} ${words[i + 1].w}`)
92
92
  }
@@ -5,7 +5,7 @@
5
5
  // servers, no hooks, an empty scratch workspace, a pinned model id, low effort, a
6
6
  // bounded output budget. Never a direct SDK. The doubtful spans of a meeting go in ONE
7
7
  // batched prompt; the answer is strict JSON, one entry per span id:
8
- // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Queen Ukaoma"}]}
8
+ // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Grace Ukaoma"}]}
9
9
  //
10
10
  // THE GUARDS. Every replacement is checked before anything is applied. It is rejected if
11
11
  // unknown_span its id was not sent
@@ -19,7 +19,7 @@
19
19
  // adjacent pieces joined ("U coma"), none a function word; or the
20
20
  // leading-y rule at any confidence ("Yukoma" for Ukaoma)
21
21
  // oov_capitalized a capitalised word mid-sentence that is no known name and no common
22
- // word, within two words of a known name ("Queen Eucoma")
22
+ // word, within two words of a known name ("Grace Eucoma")
23
23
  // repeat a phrase of 2 to 4 words (or one long word) said three or more
24
24
  // times in a row: the classic decoder loop
25
25
  // Flagged words one word apart merge; a span holds at most 6 words (12 for a repeat)
@@ -405,7 +405,7 @@ export function recordSuggestions(applied: readonly AppliedEdit[], pack: Context
405
405
  for (const edit of applied) {
406
406
  const heardKeys = new Set([...edit.original.matchAll(WORDS)].map(m => fold(m[0])))
407
407
  const ws = [...edit.replacement.matchAll(WORDS)].map(m => m[0])
408
- // Longest first: "Queen Ukaoma" is one suggestion, not that plus "Ukaoma".
408
+ // Longest first: "Grace Ukaoma" is one suggestion, not that plus "Ukaoma".
409
409
  const candidates = [...new Set<string>([edit.replacement, ...ws.slice(0, -1).map((w, i) => `${w} ${ws[i + 1]}`), ...ws])]
410
410
  .sort((a, b) => b.split(' ').length - a.split(' ').length)
411
411
  const covered = new Set<string>()
@@ -1500,7 +1500,8 @@ export async function transcribeLocal(
1500
1500
  console.log(`[whisper-local] Server recovered after ${serverConsecutiveFailures} consecutive failure(s)`)
1501
1501
  serverConsecutiveFailures = 0
1502
1502
  }
1503
- 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)`)
1504
1505
  return { text, backend: 'server', words }
1505
1506
  } catch (err: any) {
1506
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
+ })
@@ -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'
@@ -71,6 +73,14 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
71
73
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
72
74
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
73
75
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
76
+ import { liveMeetingCapability } from '../lib/live-provisional.js'
77
+ import { CUE_KINDS } from '../../shared/live-cues-v2.js'
78
+
79
+ /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
80
+ * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
81
+ function withCueKinds<T extends object>(capability: T): T & { kinds: string[] } {
82
+ return { ...capability, kinds: [...CUE_KINDS] }
83
+ }
74
84
  import { transcriptCleanupAdvertised } from './transcript-cleanup.js'
75
85
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
76
86
  import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
@@ -296,6 +306,11 @@ healthRouter.get('/health', async (_req, res) => {
296
306
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
297
307
  durableQueryJobs: durableJobs.enabled,
298
308
  durableQueryJobsProtocol: durableJobs.protocolVersion,
309
+ // 6.84.1: GET /api/query-jobs/active (docs/active-query-jobs.md), the in-flight jobs for
310
+ // COS Control's Running panel. Static: this build has the route; it lists nothing while
311
+ // durableQueryJobs is false. Absent on older servers, which answer that path 400.
312
+ activeQueryJobs: true,
313
+ activeQueryJobsProtocol: ACTIVE_QUERY_JOBS_PROTOCOL,
299
314
  // Static: this build carries the scheduler. The live schedule is under
300
315
  // capabilities.morningBrief and may be absent while the runtime boots.
301
316
  morningBrief: true,
@@ -473,7 +488,7 @@ healthRouter.get('/health', async (_req, res) => {
473
488
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
474
489
  capabilities: {
475
490
  meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
476
- dictationCleanup: { models: DICTATION_CLEAN_MODELS },
491
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability() },
477
492
  // 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
478
493
  // reads it before it holds a token, and an older server omits it, which is how
479
494
  // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
@@ -499,8 +514,9 @@ healthRouter.get('/health', async (_req, res) => {
499
514
  carriedAcrossBoot: maintenance.operation?.carriedAcrossBoot ?? false,
500
515
  },
501
516
  cliDebug: CLI_DEBUG_CAPABILITY,
502
- liveCues,
517
+ liveCues: withCueKinds(liveCues),
503
518
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
519
+ liveMeeting: liveMeetingCapability(),
504
520
  // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
505
521
  transcriptCleanup: transcriptCleanupAdvertised(),
506
522
  richMedia: {
@@ -660,7 +676,7 @@ healthRouter.get('/models', async (req, res) => {
660
676
  ...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
661
677
  capabilities: {
662
678
  meetingAsk: { ...meetingAskCapabilities(), webRetrieval: meetingWebCapability().available },
663
- dictationCleanup: { models: DICTATION_CLEAN_MODELS },
679
+ dictationCleanup: { models: DICTATION_CLEAN_MODELS, customInstructions: customInstructionsCapability() },
664
680
  durableQueryJobs: {
665
681
  enabled: durableJobs.enabled,
666
682
  protocolVersion: durableJobs.protocolVersion,
@@ -696,8 +712,9 @@ healthRouter.get('/models', async (req, res) => {
696
712
  // Same helper as /api/health — the companion's 15s liveness poll reads
697
713
  // THIS surface, so a value present only on /api/health leaves the
698
714
  // live-cues indicator blind.
699
- liveCues: liveCuesCapability(),
715
+ liveCues: withCueKinds(liveCuesCapability()),
700
716
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
717
+ liveMeeting: liveMeetingCapability(),
701
718
  richMedia: {
702
719
  video: richMedia.video,
703
720
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -1,7 +1,9 @@
1
1
  // Live Cues routes. Authenticated by the global /api X-Cos-Token middleware
2
2
  // (index.ts:148) like every other /api route — no route-local auth needed.
3
3
  //
4
- // POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
4
+ // POST /live-cues/start { sessionId, kinds? } -> { armed, sessionId, pipelinesUsed } (v1) or the v2 arm answer
5
+ // kinds (6.86.0, v2 only): 'legacy' (reinforce, challenge) or 'extended' (plus decision, promise, followUp). Absent: a
6
+ // Mac recording (mac_ id) gets extended, every other meeting legacy, so an older companion never sees a kind it drops.
5
7
  // POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
6
8
  // GET /live-cues/status -> engine snapshot + capability
7
9
  // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
@@ -45,8 +47,12 @@ liveCuesRouter.post('/live-cues/start', async (req, res) => {
45
47
  if (!sessionId || !SESSION_ID.test(sessionId)) {
46
48
  return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
47
49
  }
50
+ const kinds = (req.body as { kinds?: unknown } | undefined)?.kinds
51
+ if (kinds !== undefined && kinds !== 'legacy' && kinds !== 'extended') {
52
+ return res.status(400).json({ error: "kinds must be 'legacy' or 'extended'", reason: 'invalid_kinds' })
53
+ }
48
54
  try {
49
- if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId))
55
+ if (liveCuesV2Enabled()) return res.json(liveCuesV2().arm(sessionId, kinds ? { kinds } : {}))
50
56
  const result = await armLiveCues(sessionId)
51
57
  return res.json(result)
52
58
  } catch (error) {