@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.
- package/CHANGELOG.md +314 -0
- package/README.md +23 -0
- package/docs/active-query-jobs.md +123 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +9 -1
- package/server/index.ts +3 -0
- package/server/lib/dictation-clean.ts +79 -11
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-instructions.ts +118 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/dictation-prewarm.ts +2 -2
- package/server/lib/glossary-learn.ts +88 -10
- package/server/lib/hallucination-filter.ts +5 -0
- package/server/lib/meeting-batch-transcribe.ts +12 -4
- package/server/lib/profile.ts +3 -1
- package/server/lib/query-job-active.ts +140 -0
- package/server/lib/query-job-coordinator.ts +8 -0
- package/server/lib/query-job-store.ts +25 -0
- package/server/lib/token-audit.ts +7 -0
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +33 -6
- package/server/routes/dictation-polish-settings.ts +91 -0
- package/server/routes/glossary.ts +14 -2
- package/server/routes/health.ts +9 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +46 -10
- package/server/routes/query-jobs.ts +22 -0
- package/server/routes/transcribe-stream.ts +130 -11
- package/server/routes/welcome-context.ts +29 -6
- package/server/scripts/work-lens-rounds-mutation-gate.mjs +0 -151
|
@@ -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
|
|
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
|
|
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
|
|
1054
|
+
const decoded = await transcribeHighQuality(enhanced, decoderWindow(pending.previousText), {
|
|
1047
1055
|
priority: 'batch',
|
|
1048
1056
|
forceCpu: true,
|
|
1049
1057
|
forceCpuReason: 'progressive_checkpoint',
|
package/server/lib/profile.ts
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/server/routes/health.ts
CHANGED
|
@@ -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,
|