@gotcos/glasses-server 6.95.0 → 6.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,151 @@
1
+ // An honest daily count of every public web lookup COS makes, by feature, and of every lookup it held back and why.
2
+ //
3
+ // WHY. The settings line "Today: N topic checks, N web searches" counts only the general cues. The meeting assistant and the
4
+ // fact check also search and read pages, and nothing counted them, so the number read as the whole story when it was a part.
5
+ // This ledger is written where each lookup actually happens (searchMeetingWeb for a search, the page reader for a page, the
6
+ // gate that refuses for a withheld one), never inferred from a result or a log.
7
+ //
8
+ // It is a LEDGER, not a gate. No code path waits on it or is refused by it, and recording never throws: a lookup that cannot
9
+ // be counted still runs. The file is local-day keyed like the general cue state (same zone and rollover rule): a new local day
10
+ // starts from zero, a day in the future (a clock set back) is not trusted. A file that cannot be read says so in the view
11
+ // (`readable: false`) instead of reading as zero, and a corrupt one is moved aside so counting resumes the next time.
12
+ import { readFileSync, mkdirSync, renameSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
14
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+ import { dataPath } from './data-dir.js'
16
+
17
+ export type WebFeature = 'cues' | 'ask' | 'factCheck'
18
+ export const WEB_FEATURES: readonly WebFeature[] = ['cues', 'ask', 'factCheck']
19
+ /** Distinct withheld reasons kept per feature; the rest count under 'other' so a bad caller cannot grow the file. */
20
+ export const WEB_LEDGER_MAX_REASONS = 40
21
+
22
+ /** `pagesRead` is pages whose text was used; `pagesFailed` is page fetches that were attempted and returned nothing (blocked, too big, not an article, too slow). */
23
+ export interface WebFeatureToday { searches: number; pagesRead: number; pagesFailed: number }
24
+ export interface WebTodayView {
25
+ day: string
26
+ /** False when the file could not be read: the counts below are then zero and do not describe today. */
27
+ readable: boolean
28
+ cues: WebFeatureToday
29
+ ask: WebFeatureToday
30
+ factCheck: WebFeatureToday
31
+ withheld: { total: number; byReason: Record<string, number>; byFeature: Record<WebFeature, number> }
32
+ }
33
+
34
+ interface FeatureState { searches: number; pagesRead: number; pagesFailed: number; withheld: Record<string, number> }
35
+ interface LedgerState { v: 1; zone: string; day: string; features: Record<WebFeature, FeatureState> }
36
+ type LedgerRead = { ok: true; state: LedgerState } | { ok: false; future: boolean }
37
+
38
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
39
+ const whole = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
40
+ function validZone(zone: unknown): string | null {
41
+ if (typeof zone !== 'string' || !zone) return null
42
+ try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
43
+ }
44
+ /** Local calendar day in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central (the rule live-cues-general.ts follows). */
45
+ function localDay(ms: number, zone: string): string {
46
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(ms))
47
+ }
48
+ const emptyFeature = (): FeatureState => ({ searches: 0, pagesRead: 0, pagesFailed: 0, withheld: {} })
49
+ const REASON = /^[a-z0-9][a-z0-9_:.-]{0,47}$/
50
+
51
+ export class WebLookupLedger {
52
+ constructor(private readonly file: string, private readonly now: () => number = Date.now) {}
53
+ private systemZone(): string { return validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC' }
54
+ private fresh(nowMs: number): LedgerState {
55
+ const zone = this.systemZone()
56
+ return { v: 1, zone, day: localDay(nowMs, zone), features: { cues: emptyFeature(), ask: emptyFeature(), factCheck: emptyFeature() } }
57
+ }
58
+ private read(): LedgerRead {
59
+ const nowMs = this.now()
60
+ let raw: unknown
61
+ try { raw = JSON.parse(readFileSync(this.file, 'utf8')) } catch (e) {
62
+ if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: true, state: this.fresh(nowMs) }
63
+ return { ok: false, future: false }
64
+ }
65
+ if (!isRecord(raw) || raw.v !== 1 || !isRecord(raw.features)) return { ok: false, future: false }
66
+ const zone = validZone(raw.zone), today = zone ? localDay(nowMs, zone) : ''
67
+ if (!zone || typeof raw.day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day)) return { ok: false, future: false }
68
+ if (raw.day > today) return { ok: false, future: true }
69
+ const rolled = raw.day < today
70
+ const features = {} as Record<WebFeature, FeatureState>
71
+ for (const f of WEB_FEATURES) {
72
+ const s = raw.features[f]
73
+ // pagesFailed arrived after the first files were written: a file without it reads as zero.
74
+ if (!isRecord(s) || !whole(s.searches) || !whole(s.pagesRead) || !(s.pagesFailed === undefined || whole(s.pagesFailed)) || !isRecord(s.withheld)) return { ok: false, future: false }
75
+ const withheld: Record<string, number> = {}
76
+ for (const [reason, n] of Object.entries(s.withheld)) {
77
+ if (!whole(n) || !REASON.test(reason)) return { ok: false, future: false }
78
+ withheld[reason] = n
79
+ }
80
+ features[f] = rolled ? emptyFeature() : { searches: s.searches, pagesRead: s.pagesRead, pagesFailed: (s.pagesFailed as number | undefined) ?? 0, withheld }
81
+ }
82
+ return { ok: true, state: { v: 1, zone, day: today, features } }
83
+ }
84
+ private write(s: LedgerState): boolean {
85
+ try {
86
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
87
+ durableAtomicWriteFileSync(this.file, JSON.stringify(s) + '\n', { mode: 0o600 })
88
+ return true
89
+ } catch { return false }
90
+ }
91
+ /** One read-modify-write. A file that cannot be parsed is moved aside once so counting resumes; a future day is left alone
92
+ * (the day catches up) and nothing is counted until then. Never throws. */
93
+ private update(change: (s: LedgerState) => void): boolean {
94
+ try {
95
+ let r = this.read()
96
+ if (!r.ok && !r.future) {
97
+ try { renameSync(this.file, `${this.file}.unreadable`) } catch { return false }
98
+ r = this.read()
99
+ }
100
+ if (!r.ok) return false
101
+ change(r.state)
102
+ return this.write(r.state)
103
+ } catch { return false }
104
+ }
105
+ /** One web search is starting now. */
106
+ search(feature: WebFeature): boolean { return this.update(s => { s.features[feature].searches += 1 }) }
107
+ /** `count` result or pasted pages were read. */
108
+ pagesRead(feature: WebFeature, count = 1): boolean {
109
+ if (!whole(count) || count === 0) return false
110
+ return this.update(s => { s.features[feature].pagesRead += count })
111
+ }
112
+ /** `count` page fetches were attempted and returned nothing. */
113
+ pagesFailed(feature: WebFeature, count = 1): boolean {
114
+ if (!whole(count) || count === 0) return false
115
+ return this.update(s => { s.features[feature].pagesFailed += count })
116
+ }
117
+ /** One lookup was proposed and refused, for `reason` (a gate reason code such as no_public_intent). */
118
+ withheld(feature: WebFeature, reason: string): boolean {
119
+ const key = REASON.test(reason) ? reason : 'other'
120
+ return this.update(s => {
121
+ const w = s.features[feature].withheld
122
+ const name = key in w || Object.keys(w).length < WEB_LEDGER_MAX_REASONS ? key : 'other'
123
+ w[name] = (w[name] ?? 0) + 1
124
+ })
125
+ }
126
+ today(): WebTodayView {
127
+ const r = this.read()
128
+ const zeros = (): WebTodayView => ({ day: localDay(this.now(), this.systemZone()), readable: false, cues: { searches: 0, pagesRead: 0, pagesFailed: 0 }, ask: { searches: 0, pagesRead: 0, pagesFailed: 0 }, factCheck: { searches: 0, pagesRead: 0, pagesFailed: 0 },
129
+ withheld: { total: 0, byReason: {}, byFeature: { cues: 0, ask: 0, factCheck: 0 } } })
130
+ if (!r.ok) return zeros()
131
+ const f = r.state.features
132
+ const byReason: Record<string, number> = {}
133
+ const byFeature = {} as Record<WebFeature, number>
134
+ for (const name of WEB_FEATURES) {
135
+ byFeature[name] = Object.values(f[name].withheld).reduce((a, b) => a + b, 0)
136
+ for (const [reason, n] of Object.entries(f[name].withheld)) byReason[reason] = (byReason[reason] ?? 0) + n
137
+ }
138
+ const view = (s: FeatureState): WebFeatureToday => ({ searches: s.searches, pagesRead: s.pagesRead, pagesFailed: s.pagesFailed })
139
+ return { day: r.state.day, readable: true, cues: view(f.cues), ask: view(f.ask), factCheck: view(f.factCheck),
140
+ withheld: { total: WEB_FEATURES.reduce((n, name) => n + byFeature[name], 0), byReason, byFeature } }
141
+ }
142
+ }
143
+
144
+ let shared: WebLookupLedger | null = null
145
+ /** The server's one ledger, in the data dir. Created on first use so importing this file touches nothing. */
146
+ export function webLedger(): WebLookupLedger {
147
+ if (!shared) shared = new WebLookupLedger(dataPath('web-lookup-ledger.json'))
148
+ return shared
149
+ }
150
+ /** Tests only: replace (or clear, with null) the shared ledger. */
151
+ export function setWebLedgerForTests(ledger: WebLookupLedger | null): void { shared = ledger }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import { noteWorkReviewJob } from './job-audit-caller.js'
2
3
  import type { MeetingDetail } from './meeting-store.js'
3
4
  import type { QueryJobSnapshot } from './query-job-types.js'
4
5
  import { WorkReviewError, WorkReviewStore, parseMeetingDescriptor, publicReview, reviewHash,
@@ -143,6 +144,7 @@ export class WorkReviewRuntime {
143
144
  const request = { clientJobId: row.clientJobId, generation: 1, query: row.prompt, model: row.model,
144
145
  messageEra: row.messageEra, activityToolMode: 'status', cursorExecutionMode: 'ask',
145
146
  ...(row.provider === 'claude' ? { dispatch: { restricted: true, tools: ['Read','Grep','Glob'] } } : {}) }
147
+ noteWorkReviewJob(row.clientJobId)
146
148
  job = (await this.deps.jobs.submit(request)).job
147
149
  }
148
150
  if (!job) {
@@ -131,9 +131,11 @@ export function searchOptionText(text: string): string {
131
131
  * drain for an update or restart would wait up to 20 s (the Jev timeout) for a search, and a drain would answer every
132
132
  * search 503. Outside it, a restart mid-search loses at most that search's token count.
133
133
  * Live COS Ask is also read-only and cancellable, with no durable tasks or conversation writes.
134
+ * Voice suggestions (6.96.3) write nothing, answer at once and run detached. The route checks admissions itself so a
135
+ * drain answers its own `refused` state rather than a 503, and a running job stops at its next unit when a drain starts.
134
136
  */
135
137
  export function isReadOnlyApiPost(method: string, path: string): boolean {
136
- return method === 'POST' && (path === '/work/search' || path === '/meetings/live-ask')
138
+ return method === 'POST' && (path === '/work/search' || path === '/meetings/live-ask' || /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-suggestions$/.test(path))
137
139
  }
138
140
 
139
141
  /** A query's length as people count it: code points, not UTF-16 units, so 150 emoji are 150 (Control counts characters). */
@@ -0,0 +1,277 @@
1
+ // Plain text from an .xlsx workbook, for meeting context. No dependency: an .xlsx is a zip of XML parts, read here with
2
+ // node:zlib and a small bounded zip reader.
3
+ //
4
+ // What comes out. For each sheet, in workbook order: a "Sheet: <name>" line, then each non-empty row as tab-separated cell
5
+ // values. Shared strings are resolved. NO FORMULA IS EVALUATED: a formula cell shows the value Excel last saved with it, and
6
+ // numbers and dates are shown exactly as stored (a date is its serial number). Charts, images, comments and macros are not read.
7
+ //
8
+ // Limits, all enforced while reading, never after:
9
+ // XLSX_LIMITS.maxBytes 10 MB workbook
10
+ // XLSX_LIMITS.maxSheets 12 sheets (later sheets are named in a notice, not read)
11
+ // XLSX_LIMITS.maxRowsPerSheet 200 non-empty rows per sheet
12
+ // XLSX_LIMITS.maxColumns 50 columns per row
13
+ // XLSX_LIMITS.maxCellChars 200 characters per cell
14
+ // XLSX_LIMITS.maxChars 100,000 characters of text in all (the same cap as the other document types)
15
+ // inflate caps 8 MB for shared strings, 16 MB per sheet, 4 MB for the other parts: a zip bomb stops at the cap
16
+ // total inflated 48 MB across all parts of one workbook
17
+ // work bound 2 s of scanning in all (checked as the scanners advance); then XlsxError 'too_complex'
18
+ // Every scanner below is a linear indexOf pass that stops at a missing close tag. Nothing here uses a lazy [\s\S]*? regex over
19
+ // untrusted XML: with unclosed tags those go quadratic and an 8 to 16 MB part would hold the server for hours.
20
+ // Anything past a cap is cut and said so in a bracketed line in the text.
21
+ import { inflateRawSync } from 'node:zlib';
22
+
23
+ export const XLSX_LIMITS = {
24
+ maxBytes: 10_000_000,
25
+ maxSheets: 12,
26
+ maxRowsPerSheet: 200,
27
+ maxColumns: 50,
28
+ maxCellChars: 200,
29
+ maxChars: 100_000,
30
+ maxSharedStringsBytes: 8_000_000,
31
+ maxSheetBytes: 16_000_000,
32
+ maxPartBytes: 4_000_000,
33
+ maxTotalInflatedBytes: 48_000_000,
34
+ maxWorkMs: 2_000,
35
+ } as const
36
+
37
+ export type XlsxErrorCode = 'too_large' | 'not_a_workbook' | 'encrypted' | 'unsupported_zip' | 'corrupt' | 'too_complex'
38
+ export class XlsxError extends Error {
39
+ constructor(readonly code: XlsxErrorCode, message: string) {
40
+ super(message)
41
+ this.name = 'XlsxError'
42
+ }
43
+ }
44
+
45
+ interface ZipEntry { name: string; method: number; compressedSize: number; size: number; offset: number }
46
+
47
+ function readEntries(zip: Buffer): Map<string, ZipEntry> {
48
+ if (zip.length < 22 || zip.readUInt32LE(0) !== 0x04034b50) throw new XlsxError('not_a_workbook', 'not a zip file')
49
+ let eocd = -1
50
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 65_535); i--) {
51
+ if (zip.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
52
+ }
53
+ if (eocd < 0) throw new XlsxError('corrupt', 'no end of central directory')
54
+ const count = zip.readUInt16LE(eocd + 10)
55
+ const cdSize = zip.readUInt32LE(eocd + 12)
56
+ const cdOffset = zip.readUInt32LE(eocd + 16)
57
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) throw new XlsxError('unsupported_zip', 'zip64 is not supported')
58
+ if (cdOffset + cdSize > zip.length || count > 5_000) throw new XlsxError('corrupt', 'central directory out of range')
59
+ const entries = new Map<string, ZipEntry>()
60
+ let p = cdOffset
61
+ for (let n = 0; n < count; n++) {
62
+ if (p + 46 > zip.length || zip.readUInt32LE(p) !== 0x02014b50) throw new XlsxError('corrupt', 'bad central directory entry')
63
+ const flags = zip.readUInt16LE(p + 8)
64
+ if (flags & 1) throw new XlsxError('encrypted', 'the workbook is encrypted')
65
+ const nameLength = zip.readUInt16LE(p + 28), extraLength = zip.readUInt16LE(p + 30), commentLength = zip.readUInt16LE(p + 32)
66
+ if (p + 46 + nameLength > zip.length) throw new XlsxError('corrupt', 'entry name out of range')
67
+ const name = zip.toString('utf8', p + 46, p + 46 + nameLength)
68
+ entries.set(name, { name, method: zip.readUInt16LE(p + 10), compressedSize: zip.readUInt32LE(p + 20), size: zip.readUInt32LE(p + 24), offset: zip.readUInt32LE(p + 42) })
69
+ p += 46 + nameLength + extraLength + commentLength
70
+ }
71
+ return entries
72
+ }
73
+
74
+ /** One part, inflated with a hard output cap. Returns null past the cap or when the part is damaged. */
75
+ function readPart(zip: Buffer, entry: ZipEntry, cap: number): string | null {
76
+ const at = entry.offset
77
+ if (at + 30 > zip.length || zip.readUInt32LE(at) !== 0x04034b50) return null
78
+ const start = at + 30 + zip.readUInt16LE(at + 26) + zip.readUInt16LE(at + 28)
79
+ if (start + entry.compressedSize > zip.length || entry.size > cap) return null
80
+ const raw = zip.subarray(start, start + entry.compressedSize)
81
+ try {
82
+ const bytes = entry.method === 0 ? raw : entry.method === 8 ? inflateRawSync(raw, { maxOutputLength: cap }) : null
83
+ return bytes ? bytes.toString('utf8') : null
84
+ } catch { return null }
85
+ }
86
+
87
+ /** The work bound: scanners call it as they advance; it reads the clock every 4,096 calls and throws once the deadline has passed. */
88
+ class Work {
89
+ private n = 0
90
+ private readonly deadline: number
91
+ constructor(private readonly now: () => number) { this.deadline = now() + XLSX_LIMITS.maxWorkMs }
92
+ tick(): void {
93
+ if ((++this.n & 4095) === 0 && this.now() > this.deadline) throw new XlsxError('too_complex', `workbook took longer than ${XLSX_LIMITS.maxWorkMs} ms to scan`)
94
+ }
95
+ }
96
+ interface Block { attrs: string; body: string | null }
97
+ /**
98
+ * The <tag ...>body</tag> and <tag .../> elements of `xml`, in order, found with indexOf only. A tag without its closing tag ends the
99
+ * scan (nothing after it is trusted), so every position is passed over at most a constant number of times. Nested elements of the same
100
+ * name are not supported (none of the parts read here nest them).
101
+ */
102
+ function* blocks(xml: string, tag: string, work: Work): Generator<Block> {
103
+ const open = `<${tag}`, close = `</${tag}>`
104
+ let at = 0
105
+ for (;;) {
106
+ const start = xml.indexOf(open, at)
107
+ if (start < 0) return
108
+ const after = start + open.length
109
+ const c = xml.charCodeAt(after)
110
+ // <row but not <rowBreaks: the name must end here.
111
+ if (!(c === 62 || c === 47 || c === 32 || c === 9 || c === 10 || c === 13)) { at = after; continue }
112
+ const gt = xml.indexOf('>', after)
113
+ if (gt < 0) return
114
+ work.tick()
115
+ if (xml.charCodeAt(gt - 1) === 47) {
116
+ yield { attrs: xml.slice(after, gt - 1), body: null }
117
+ at = gt + 1
118
+ continue
119
+ }
120
+ const end = xml.indexOf(close, gt + 1)
121
+ if (end < 0) return
122
+ yield { attrs: xml.slice(after, gt), body: xml.slice(gt + 1, end) }
123
+ at = end + close.length
124
+ }
125
+ }
126
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
127
+ function unescapeXml(s: string): string {
128
+ return s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot|apos));/gi, (_, hex: string | undefined, dec: string | undefined, name: string | undefined) => {
129
+ if (name) return ENTITIES[name.toLowerCase()]
130
+ const code = hex ? parseInt(hex, 16) : Number(dec)
131
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : ''
132
+ })
133
+ }
134
+ /** `fragment` without its <rPh>...</rPh> phonetic guides; an unclosed <rPh> drops the rest. */
135
+ function withoutGuides(fragment: string): string {
136
+ let out = '', at = 0
137
+ for (;;) {
138
+ const start = fragment.indexOf('<rPh', at)
139
+ if (start < 0) return out + fragment.slice(at)
140
+ out += fragment.slice(at, start)
141
+ const end = fragment.indexOf('</rPh>', start)
142
+ if (end < 0) return out
143
+ at = end + 6
144
+ }
145
+ }
146
+ /** The text of every <t> in a fragment, phonetic guides left out. */
147
+ function textOf(fragment: string, work: Work): string {
148
+ let out = ''
149
+ for (const t of blocks(withoutGuides(fragment), 't', work)) if (t.body) out += unescapeXml(t.body)
150
+ return out
151
+ }
152
+ const ATTR_PATTERNS = new Map<string, RegExp>()
153
+ function attr(tag: string, name: string): string | undefined {
154
+ let pattern = ATTR_PATTERNS.get(name)
155
+ if (!pattern) ATTR_PATTERNS.set(name, pattern = new RegExp(`\\b${name}="([^"]*)"`))
156
+ const m = pattern.exec(tag)
157
+ return m ? unescapeXml(m[1]) : undefined
158
+ }
159
+ /** Column letters to a zero-based index: A is 0, AA is 26. */
160
+ function columnIndex(ref: string): number {
161
+ const letters = /^[A-Z]+/i.exec(ref)?.[0].toUpperCase() ?? ''
162
+ let n = 0
163
+ for (const ch of letters.slice(0, 4)) n = n * 26 + (ch.charCodeAt(0) - 64)
164
+ return n - 1
165
+ }
166
+ const clean = (value: string): string => value.replace(/[\t\r\n]+/g, ' ').trim().slice(0, XLSX_LIMITS.maxCellChars)
167
+
168
+ function sharedStrings(xml: string | null, work: Work): string[] {
169
+ if (!xml) return []
170
+ const out: string[] = []
171
+ for (const si of blocks(xml, 'si', work)) out.push(si.body ? textOf(si.body, work) : '')
172
+ return out
173
+ }
174
+
175
+ function firstBody(xml: string, tag: string, work: Work): string | undefined {
176
+ for (const b of blocks(xml, tag, work)) return b.body ?? undefined
177
+ return undefined
178
+ }
179
+ function cellValue(attrs: string, body: string, strings: readonly string[], work: Work): string {
180
+ const type = attr(attrs, 't')
181
+ if (type === 'inlineStr') return textOf(firstBody(body, 'is', work) ?? '', work)
182
+ const v = firstBody(body, 'v', work)
183
+ if (v === undefined) return ''
184
+ const value = unescapeXml(v)
185
+ if (type === 's') return strings[Number(value)] ?? ''
186
+ if (type === 'b') return value === '1' ? 'TRUE' : 'FALSE'
187
+ return value
188
+ }
189
+
190
+ /** Rows of one sheet as tab-separated text, at most maxRowsPerSheet non-empty rows. */
191
+ function sheetRows(xml: string, strings: readonly string[], work: Work): { lines: string[]; cut: boolean } {
192
+ const lines: string[] = []
193
+ for (const row of blocks(xml, 'row', work)) {
194
+ if (!row.body) continue
195
+ const cells: string[] = []
196
+ let next = 0
197
+ for (const c of blocks(row.body, 'c', work)) {
198
+ const ref = attr(c.attrs, 'r')
199
+ const at = ref ? columnIndex(ref) : next
200
+ if (at >= XLSX_LIMITS.maxColumns) break
201
+ const value = clean(cellValue(c.attrs, c.body ?? '', strings, work))
202
+ while (cells.length < at) cells.push('')
203
+ cells[at] = value
204
+ next = at + 1
205
+ }
206
+ while (cells.length && !cells[cells.length - 1]) cells.pop()
207
+ if (!cells.length) continue
208
+ if (lines.length >= XLSX_LIMITS.maxRowsPerSheet) return { lines, cut: true }
209
+ lines.push(cells.join('\t'))
210
+ }
211
+ return { lines, cut: false }
212
+ }
213
+
214
+ const CUT_NOTE = `[Text cut at ${XLSX_LIMITS.maxChars} characters.]`
215
+ export interface XlsxText { text: string; truncated: boolean; sheets: number }
216
+
217
+ /** The text of an .xlsx workbook. Throws XlsxError for anything that is not a readable, unencrypted workbook within the limits. */
218
+ export function extractXlsxText(bytes: Buffer, options: { now?: () => number } = {}): XlsxText {
219
+ if (bytes.length > XLSX_LIMITS.maxBytes) throw new XlsxError('too_large', `workbook exceeds ${XLSX_LIMITS.maxBytes} bytes`)
220
+ const work = new Work(options.now ?? Date.now)
221
+ const entries = readEntries(bytes)
222
+ let inflated = 0
223
+ const part = (name: string, cap: number): string | null => {
224
+ const entry = entries.get(name)
225
+ const text = entry ? readPart(bytes, entry, cap) : null
226
+ inflated += text?.length ?? 0
227
+ if (inflated > XLSX_LIMITS.maxTotalInflatedBytes) throw new XlsxError('too_complex', `workbook inflates past ${XLSX_LIMITS.maxTotalInflatedBytes} bytes`)
228
+ return text
229
+ }
230
+ const workbook = part('xl/workbook.xml', XLSX_LIMITS.maxPartBytes)
231
+ if (!workbook) throw new XlsxError('not_a_workbook', 'no workbook part')
232
+ const rels = new Map<string, string>()
233
+ for (const m of blocks(part('xl/_rels/workbook.xml.rels', XLSX_LIMITS.maxPartBytes) ?? '', 'Relationship', work)) {
234
+ const id = attr(m.attrs, 'Id'), target = attr(m.attrs, 'Target')
235
+ if (id && target) rels.set(id, target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`)
236
+ }
237
+ const sheets: Array<{ name: string; path: string }> = []
238
+ let ordinal = 0
239
+ for (const m of blocks(workbook, 'sheet', work)) {
240
+ ordinal += 1
241
+ const rid = /\br:id="([^"]*)"/.exec(m.attrs)?.[1]
242
+ sheets.push({ name: clean(attr(m.attrs, 'name') ?? `Sheet${ordinal}`) || `Sheet${ordinal}`, path: (rid && rels.get(rid)) || `xl/worksheets/sheet${ordinal}.xml` })
243
+ }
244
+ if (!sheets.length) throw new XlsxError('not_a_workbook', 'no sheets')
245
+ const strings = sharedStrings(part('xl/sharedStrings.xml', XLSX_LIMITS.maxSharedStringsBytes), work)
246
+ const texts: string[] = []
247
+ let truncated = false, used = 0
248
+ // The cut marker is added after the cap is reached, so the budget leaves room for it and the whole text stays within maxChars.
249
+ const budget = XLSX_LIMITS.maxChars - CUT_NOTE.length - 2
250
+ let capped = false
251
+ const push = (block: string): boolean => {
252
+ if (used + block.length + 2 > budget) {
253
+ capped = true
254
+ const room = budget - used - 2
255
+ if (room > 0) texts.push(block.slice(0, room))
256
+ truncated = true
257
+ return false
258
+ }
259
+ texts.push(block)
260
+ used += block.length + 2
261
+ return true
262
+ }
263
+ for (const [i, sheet] of sheets.entries()) {
264
+ if (i >= XLSX_LIMITS.maxSheets) {
265
+ push(`[${sheets.length - XLSX_LIMITS.maxSheets} more sheet${sheets.length - XLSX_LIMITS.maxSheets === 1 ? '' : 's'} not read: ${sheets.slice(XLSX_LIMITS.maxSheets).map(s => s.name).join(', ')}.]`)
266
+ truncated = true
267
+ break
268
+ }
269
+ const xml = part(sheet.path, XLSX_LIMITS.maxSheetBytes)
270
+ if (xml === null) { if (!push(`Sheet: ${sheet.name}\n[This sheet could not be read.]`)) break; continue }
271
+ const { lines, cut } = sheetRows(xml, strings, work)
272
+ if (cut) truncated = true
273
+ if (!push([`Sheet: ${sheet.name}`, ...lines, ...(cut ? [`[Only the first ${XLSX_LIMITS.maxRowsPerSheet} rows of this sheet are read.]`] : [])].join('\n'))) break
274
+ }
275
+ const text = texts.join('\n\n').trim()
276
+ return { text: capped ? `${text}\n\n${CUT_NOTE}` : text, truncated, sheets: sheets.length }
277
+ }
@@ -3,6 +3,7 @@ import { Router } from 'express'
3
3
  import { JEV_MODEL, resolveJevKey } from '../lib/jev.js'
4
4
  import { resolveSubqKey, subqKeyStatus } from '../lib/subq-key.js'
5
5
  import { SUBQ_MODEL } from '../lib/decision-provider.js'
6
+ import { sessionFindAvailable } from '../lib/session-find.js'
6
7
  import { decisionSettingsView, DecisionSettingsError, patchDecisionSettings, readDecisionSettings, writeDecisionSettings, type DecisionProvider, type DecisionSettings } from '../lib/decision-settings.js'
7
8
  export interface DecisionProviderRouteDeps {
8
9
  read: () => DecisionSettings
@@ -15,7 +16,9 @@ export function createDecisionProviderRouter(overrides: Partial<DecisionProvider
15
16
  const deps: DecisionProviderRouteDeps = { read: readDecisionSettings, write: writeDecisionSettings, configured: () => ({ jev: !!resolveJevKey(), subq: !!resolveSubqKey() }), env: process.env, keyStatus: () => { const jev = resolveJevKey(); return { jev: { configured:!!jev, source:jev?.source ?? 'none' }, subq:subqKeyStatus() } }, ...overrides }
16
17
  const router = Router()
17
18
  const view = (settings: DecisionSettings) => ({ ...decisionSettingsView(settings, deps.configured(), deps.env), models: { jev: JEV_MODEL, subq: SUBQ_MODEL }, keyStatus: deps.keyStatus(),
18
- capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env' } })
19
+ capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env',
20
+ // Present only while POST /api/agent-sessions/find can answer. Absent means no meaning search here, and Control does not ask (never keyed on version).
21
+ ...(sessionFindAvailable(deps.env) ? { sessionFind: true } : {}) } })
19
22
  router.use('/decision-provider', (_req,res,next) => { res.set('Cache-Control','private, no-store'); next() })
20
23
  router.get('/decision-provider', (_req,res) => {
21
24
  try { res.json(view(deps.read())) } catch { res.status(503).json({ error: { code: 'decision_settings_unavailable', message: 'Decision provider settings could not be read.' } }) }
@@ -14,6 +14,7 @@ import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
14
14
  import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
15
15
  import { listBoard } from '../lib/task-store.js'
16
16
  import { sharedWorkSearchJevClient } from '../lib/work-search.js'
17
+ import { sharedSessionFindJevClient } from '../lib/session-find.js'
17
18
  import { isSafeDomainName } from '../lib/domains.js'
18
19
 
19
20
  /** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
@@ -34,6 +35,8 @@ export interface JevRouteDependencies {
34
35
  replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
35
36
  /** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
36
37
  searchJev: Pick<JevClient, 'resetForNewKey'>
38
+ /** Session find (Control 0.5.363) keeps its own client and breaker too. */
39
+ findJev: Pick<JevClient, 'resetForNewKey'>
37
40
  }
38
41
 
39
42
  /** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
@@ -45,7 +48,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
45
48
  const jev = overrides.jev ?? sharedJevClient()
46
49
  const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(createDecisionClient('session', jev)), list: listBoard, validate: validateJevKey, save: saveJevKey,
47
50
  completion: new CompletionChecker(createDecisionClient('completion', jev)), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
48
- searchJev: sharedWorkSearchJevClient(), ...overrides }
51
+ searchJev: sharedWorkSearchJevClient(), findJev: sharedSessionFindJevClient(), ...overrides }
49
52
  const router = Router()
50
53
  router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
51
54
 
@@ -67,6 +70,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
67
70
  }
68
71
  deps.jev.resetForNewKey()
69
72
  deps.searchJev.resetForNewKey()
73
+ deps.findJev.resetForNewKey()
70
74
  return res.json({ ok: true, ...deps.jev.status() })
71
75
  })
72
76
 
@@ -3,11 +3,13 @@
3
3
  //
4
4
  // POST /live-cues/start { sessionId, kinds? } -> { armed, sessionId, pipelinesUsed } (v1) or the v2 arm answer
5
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.
6
+ // Mac recording (mac_ id) gets extended, every other meeting legacy, so an older companion never sees a kind it drops. A G2 meeting armed 'extended' (6.97.9) gets the five kinds and
7
+ // nothing else of the Mac's: no general (web) cues, no cue mode `general` switching its focused cues off, no open-items fold.
7
8
  // POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
8
9
  // GET /live-cues/status -> engine snapshot + capability
9
10
  // GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
10
11
  // POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
12
+ // ?v=2 / {v: 2}: the spend limits (6.79.2). ?v=3 / {v: 3}: the cue mode, grounded cues and the general-cue limits (live cues v3).
11
13
  // POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (6.79.0)
12
14
  // GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 -> { v: 1, cues, counts, truncated, skipped, maxCuesPerMeeting, meetings? } (6.79.0)
13
15
 
@@ -20,8 +22,11 @@ import {
20
22
  LiveCuesArmError,
21
23
  } from '../lib/live-cues-engine.js'
22
24
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
23
- import { applyLiveCuesV2Settings, liveCuesV2SettingsViewV2 } from '../lib/live-cues-v2.js'
24
- import { LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, limitInBounds, type LiveCuesV2StoredLimits } from '../lib/live-cues-v2-settings.js'
25
+ import { applyLiveCuesV2Settings, liveCuesV2SettingsViewV2, liveCuesV2SettingsViewV3 } from '../lib/live-cues-v2.js'
26
+ import {
27
+ LIVE_CUES_GENERAL_LIMIT_BOUNDS, LIVE_CUES_GENERAL_LIMIT_NAMES, LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, generalLimitInBounds, isCueMode, isGrounding, limitInBounds,
28
+ type LiveCuesGeneralStoredLimits, type LiveCuesV2StoredLimits,
29
+ } from '../lib/live-cues-v2-settings.js'
25
30
  import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
26
31
  import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
27
32
 
@@ -202,15 +207,15 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
202
207
  liveCuesRouter.get('/live-cues/v2/settings', (req, res) => {
203
208
  res.set('Cache-Control', 'private, no-store')
204
209
  const v = req.query.v
205
- if (v !== undefined && v !== '2') return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
206
- try { return res.json(v === '2' ? liveCuesV2SettingsViewV2() : liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
210
+ if (v !== undefined && v !== '2' && v !== '3') return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
211
+ try { return res.json(v === '3' ? liveCuesV2SettingsViewV3() : v === '2' ? liveCuesV2SettingsViewV2() : liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
207
212
  })
208
213
 
209
214
  const V2_SETTINGS_KEYS = new Set<string>(['v', 'enabled', ...LIVE_CUES_V2_LIMIT_NAMES])
210
215
  liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
211
216
  res.set('Cache-Control', 'private, no-store')
212
217
  const body = req.body as { enabled?: unknown; v?: unknown } | undefined
213
- if (body && typeof body === 'object' && !Array.isArray(body) && body.v !== undefined) return postSettingsV2(body as Record<string, unknown>, res)
218
+ if (body && typeof body === 'object' && !Array.isArray(body) && body.v !== undefined) return postSettingsVersioned(body as Record<string, unknown>, res)
214
219
  if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
215
220
  return res.status(400).json({ error: 'invalid_request' })
216
221
  }
@@ -223,12 +228,19 @@ liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
223
228
  }
224
229
  })
225
230
 
226
- /** POST {v: 2, ...}: every field is checked before anything is saved, so a refused request changes nothing. */
227
- function postSettingsV2(body: Record<string, unknown>, res: import('express').Response) {
228
- if (body.v !== 2) return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
229
- const unknown = Object.keys(body).find(k => !V2_SETTINGS_KEYS.has(k))
231
+ const V3_SETTINGS_KEYS = new Set<string>([...V2_SETTINGS_KEYS, 'cueMode', 'grounding', ...LIVE_CUES_GENERAL_LIMIT_NAMES.map(name => LIVE_CUES_GENERAL_LIMIT_BOUNDS[name].field)])
232
+
233
+ /** POST {v: 2, ...} (the spend limits, 6.79.2) and POST {v: 3, ...} (live cues v3: the same plus cueMode, grounding and the six
234
+ * general limits). Every field is checked before anything is saved, so a refused request changes nothing. v=2 takes none of
235
+ * the v3 fields (unknown_field), exactly as before. A bad enum is invalid_value; a number out of bounds names the wire field. */
236
+ function postSettingsVersioned(body: Record<string, unknown>, res: import('express').Response) {
237
+ if (body.v !== 2 && body.v !== 3) return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
238
+ const v3 = body.v === 3
239
+ const unknown = Object.keys(body).find(k => !(v3 ? V3_SETTINGS_KEYS : V2_SETTINGS_KEYS).has(k))
230
240
  if (unknown) return res.status(400).json({ error: 'invalid_request', reason: 'unknown_field', field: unknown })
231
241
  if (body.enabled !== undefined && typeof body.enabled !== 'boolean') return res.status(400).json({ error: 'invalid_request', reason: 'invalid_enabled', field: 'enabled' })
242
+ if (v3 && body.cueMode !== undefined && !isCueMode(body.cueMode)) return res.status(400).json({ error: 'invalid_request', reason: 'invalid_value', field: 'cueMode' })
243
+ if (v3 && body.grounding !== undefined && !isGrounding(body.grounding)) return res.status(400).json({ error: 'invalid_request', reason: 'invalid_value', field: 'grounding' })
232
244
  const limits: LiveCuesV2StoredLimits = {}
233
245
  for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
234
246
  if (body[name] === undefined) continue
@@ -238,11 +250,21 @@ function postSettingsV2(body: Record<string, unknown>, res: import('express').Re
238
250
  }
239
251
  limits[name] = body[name] as number
240
252
  }
241
- if (body.enabled === undefined && Object.keys(limits).length === 0) return res.status(400).json({ error: 'invalid_request', reason: 'empty' })
253
+ const general: LiveCuesGeneralStoredLimits = {}
254
+ for (const name of LIVE_CUES_GENERAL_LIMIT_NAMES) {
255
+ const b = LIVE_CUES_GENERAL_LIMIT_BOUNDS[name]
256
+ if (body[b.field] === undefined) continue
257
+ if (!generalLimitInBounds(name, body[b.field])) return res.status(400).json({ error: 'invalid_request', reason: 'out_of_bounds', field: b.field, min: b.min, max: b.max, integer: b.integer })
258
+ general[name] = body[b.field] as number
259
+ }
260
+ const v3Fields = body.cueMode !== undefined || body.grounding !== undefined || Object.keys(general).length > 0
261
+ if (body.enabled === undefined && Object.keys(limits).length === 0 && !v3Fields) return res.status(400).json({ error: 'invalid_request', reason: 'empty' })
242
262
  try {
243
- const result = applyLiveCuesV2Settings({ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}), limits })
244
- if (!result.ok) return res.status(409).json(result.error === 'env_locked' ? { error: 'env_locked', fields: result.fields } : { error: result.error })
245
- return res.json(liveCuesV2SettingsViewV2())
263
+ const result = applyLiveCuesV2Settings({ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}), limits,
264
+ ...(isCueMode(body.cueMode) ? { cueMode: body.cueMode } : {}), ...(isGrounding(body.grounding) ? { grounding: body.grounding } : {}),
265
+ ...(Object.keys(general).length ? { general } : {}) })
266
+ if (!result.ok) return res.status(409).json(result.error === 'env_locked' ? { error: 'env_locked', fields: result.fields } : result.fields ? { error: 'env_forced', fields: result.fields } : { error: result.error })
267
+ return res.json(v3 ? liveCuesV2SettingsViewV3() : liveCuesV2SettingsViewV2())
246
268
  } catch {
247
269
  return res.status(500).json({ error: 'settings_write_failed' })
248
270
  }