@gotcos/glasses-server 6.79.0 → 6.79.2

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.
@@ -0,0 +1,209 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { resolveProviderBinary } from './provider-binary.js'
7
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
8
+ import {
9
+ buildDictationPolishPrompt, claudePolishArgs, dictationChildEnv, dictationPolishPromptVersion,
10
+ type DictationPolishPromptVersion,
11
+ } from './dictation-clean.js'
12
+
13
+ /**
14
+ * 6.79.2: start the polish Claude process when dictation starts, not when it ends.
15
+ *
16
+ * About 70% of a polish is the CLI's own start (measured on Miles's Mac: cold polish 1.8 to
17
+ * 1.9 s; the same process started 3 s earlier answered 1.0 to 1.3 s after the text arrived).
18
+ * A warm process is the exact polish process (same args, no tools, MCP, hooks or session
19
+ * file, its own empty workspace) in stream-json input mode, waiting for ONE user message.
20
+ * It gets that message, answers, and its stdin is closed at once, so it exits: no history
21
+ * ever carries into another dictation.
22
+ *
23
+ * Bounded: at most PREWARM_MAX live (a new one evicts the oldest), each killed after
24
+ * PREWARM_TTL_MS unused, killed when finalize decides not to call the model, and killed
25
+ * with the server. A warm process costs no model call until a message is written, so the
26
+ * daily polish budget is spent only when it is used.
27
+ */
28
+ export const PREWARM_TTL_MS = 150_000
29
+ export const PREWARM_MAX = 2
30
+ export const PREWARM_MODELS = ['haiku', 'sonnet'] as const
31
+ export type PrewarmModel = typeof PREWARM_MODELS[number]
32
+
33
+ export class PrewarmUnavailableError extends Error {}
34
+
35
+ interface WarmPolish {
36
+ token: string
37
+ model: PrewarmModel
38
+ version: DictationPolishPromptVersion
39
+ proc: ChildProcess
40
+ scratch: string
41
+ exited: boolean
42
+ timer: NodeJS.Timeout
43
+ onResult: ((event: any) => void) | null
44
+ onExit: (() => void) | null
45
+ }
46
+
47
+ /** Unclaimed warm processes, by token. */
48
+ const warm = new Map<string, WarmPolish>()
49
+ /** Every process not yet exited, claimed or not: what the server kills on its way out. */
50
+ const alive = new Set<WarmPolish>()
51
+
52
+ export function prewarmEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
53
+ return !['0', 'false', 'off'].includes((env.COS_DICTATION_PREWARM ?? '').trim().toLowerCase())
54
+ }
55
+
56
+ export function isPrewarmModel(model: unknown): model is PrewarmModel {
57
+ return typeof model === 'string' && (PREWARM_MODELS as readonly string[]).includes(model)
58
+ }
59
+
60
+ export function livePrewarmCount(): number {
61
+ return warm.size
62
+ }
63
+
64
+ function retire(entry: WarmPolish): void {
65
+ warm.delete(entry.token)
66
+ clearTimeout(entry.timer)
67
+ if (!entry.exited) void terminateProviderProcess(entry.proc).catch(() => {})
68
+ }
69
+
70
+ export function startPolishPrewarm(model: PrewarmModel): { token: string; expiresMs: number } {
71
+ const binary = resolveProviderBinary('claude')
72
+ if (!binary.ok) throw new PrewarmUnavailableError('Dictation cleanup Claude CLI unavailable')
73
+ while (warm.size >= PREWARM_MAX) retire(warm.values().next().value!)
74
+ const version = dictationPolishPromptVersion()
75
+ const scratch = mkdtempSync(join(tmpdir(), 'cos-dictation-prewarm-'))
76
+ const workspace = join(scratch, 'workspace')
77
+ mkdirSync(workspace, { mode: 0o700 })
78
+ const proc = spawn(binary.path, claudePolishArgs(model, version, 'stream'), {
79
+ stdio: ['pipe', 'pipe', 'pipe'], env: dictationChildEnv(binary.path), cwd: workspace, detached: true,
80
+ })
81
+ const token = randomUUID()
82
+ const entry: WarmPolish = {
83
+ token, model, version, proc, scratch, exited: false, onResult: null, onExit: null,
84
+ timer: setTimeout(() => retire(entry), PREWARM_TTL_MS),
85
+ }
86
+ entry.timer.unref?.()
87
+ let buffer = ''
88
+ proc.stdout?.on('data', (chunk: Buffer) => {
89
+ buffer += chunk.toString()
90
+ if (buffer.length > 256_000) { retire(entry); return }
91
+ let newline: number
92
+ while ((newline = buffer.indexOf('\n')) >= 0) {
93
+ const line = buffer.slice(0, newline).trim()
94
+ buffer = buffer.slice(newline + 1)
95
+ if (!line) continue
96
+ let event: any
97
+ try { event = JSON.parse(line) } catch { continue }
98
+ if (event?.type === 'result') entry.onResult?.(event)
99
+ }
100
+ })
101
+ // Do not log provider stderr: it can contain login or dictation data.
102
+ proc.stderr?.resume()
103
+ proc.stdin?.on('error', () => {})
104
+ const exited = () => {
105
+ if (entry.exited) return
106
+ entry.exited = true
107
+ warm.delete(token)
108
+ alive.delete(entry)
109
+ clearTimeout(entry.timer)
110
+ rmSync(scratch, { recursive: true, force: true })
111
+ entry.onExit?.()
112
+ }
113
+ proc.on('error', exited)
114
+ proc.on('close', exited)
115
+ warm.set(token, entry)
116
+ alive.add(entry)
117
+ return { token, expiresMs: PREWARM_TTL_MS }
118
+ }
119
+
120
+ /** Kill a warm process nobody will use (finalize skipped the model, or the press was cancelled). */
121
+ export function discardPolishPrewarm(token: string | undefined): boolean {
122
+ const entry = token ? warm.get(token) : undefined
123
+ if (!entry) return false
124
+ retire(entry)
125
+ return true
126
+ }
127
+
128
+ /** One use: the entry leaves the pool whether or not it matches. A model mismatch kills it. */
129
+ export function takePolishPrewarm(token: string | undefined, model: string): WarmPolish | null {
130
+ const entry = token ? warm.get(token) : undefined
131
+ if (!entry) return null
132
+ warm.delete(entry.token)
133
+ if (entry.model !== model || entry.exited) { retire(entry); return null }
134
+ return entry
135
+ }
136
+
137
+ /**
138
+ * Write the one message and read the one result. PrewarmUnavailableError means nothing was
139
+ * sent (the process had already gone), so the caller may spawn cold. Any later failure means
140
+ * the model may have run, so it is a real polish failure.
141
+ */
142
+ export async function runWarmPolish(
143
+ entry: WarmPolish,
144
+ text: string,
145
+ terms: string[],
146
+ opts: { signal?: AbortSignal } = {},
147
+ ): Promise<string> {
148
+ if (entry.exited || !entry.proc.stdin || entry.proc.stdin.destroyed) {
149
+ retire(entry)
150
+ throw new PrewarmUnavailableError('Warm polish process already exited')
151
+ }
152
+ if (opts.signal?.aborted) { retire(entry); throw new Error('Auto-clean aborted') }
153
+ clearTimeout(entry.timer)
154
+ const configuredTimeout = Number.parseInt(process.env.COS_DICTATION_AUTOCLEAN_TIMEOUT_MS || '20000', 10)
155
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 20_000
156
+ return await new Promise<string>((resolve, reject) => {
157
+ let settled = false
158
+ const finish = (error?: Error, output?: string) => {
159
+ if (settled) return
160
+ settled = true
161
+ clearTimeout(timer)
162
+ opts.signal?.removeEventListener('abort', abort)
163
+ entry.onResult = null
164
+ entry.onExit = null
165
+ // Exactly one message: once it is answered (or abandoned) the process must go.
166
+ retire(entry)
167
+ if (error) reject(error); else resolve(output!)
168
+ }
169
+ const abort = () => finish(new Error('Auto-clean aborted'))
170
+ const timer = setTimeout(() => finish(new Error(`Auto-clean timed out (${timeoutMs}ms)`)), timeoutMs)
171
+ entry.onResult = (event) => {
172
+ if (event.subtype !== 'success' || event.is_error || typeof event.result !== 'string') {
173
+ return finish(new Error('Warm polish returned an unsuccessful result'))
174
+ }
175
+ const output = event.result.trim()
176
+ if (!output) return finish(new Error('Auto-clean returned empty text'))
177
+ finish(undefined, output)
178
+ }
179
+ entry.onExit = () => finish(new Error('Warm polish process exited without a result'))
180
+ opts.signal?.addEventListener('abort', abort, { once: true })
181
+ const message = JSON.stringify({
182
+ type: 'user',
183
+ message: { role: 'user', content: buildDictationPolishPrompt(text, terms, entry.version) },
184
+ })
185
+ try {
186
+ entry.proc.stdin!.write(message + '\n')
187
+ entry.proc.stdin!.end()
188
+ } catch {
189
+ finish(new Error('Auto-clean input failed'))
190
+ }
191
+ })
192
+ }
193
+
194
+ /** The server is going away: a detached warm process would outlive it. */
195
+ export function killAllPrewarmsSync(): void {
196
+ for (const entry of alive) {
197
+ try { if (entry.proc.pid) process.kill(-entry.proc.pid, 'SIGTERM') } catch {}
198
+ // No close event will run before the server is gone, so its workspace goes now.
199
+ try { rmSync(entry.scratch, { recursive: true, force: true }) } catch {}
200
+ }
201
+ warm.clear()
202
+ alive.clear()
203
+ }
204
+ process.once('exit', killAllPrewarmsSync)
205
+
206
+ export function _resetPrewarmsForTest(): void {
207
+ for (const entry of [...alive]) retire(entry)
208
+ warm.clear()
209
+ }
@@ -5,6 +5,12 @@
5
5
  // 3. off (source 'default').
6
6
  // Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
7
7
  // An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
8
+ //
9
+ // 6.79.2: the same file holds COS Control's spend limits (gate, cues per meeting, cooldown, writer calls per local day).
10
+ // Each saved value is validated on read; one out of bounds reads as unset (the default), never as a looser limit. An env
11
+ // var for a limit wins over the saved value and locks it. A file holding only `enabled` is still written as schema 1, so
12
+ // a 6.79.1 server reads it exactly as before; any limit makes it schema 2, which 6.79.1 also reads (it looks only at
13
+ // `enabled`).
8
14
 
9
15
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
10
16
  import { dirname } from 'node:path'
@@ -13,34 +19,66 @@ import { dataPath } from './data-dir.js'
13
19
 
14
20
  export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
15
21
 
16
- /** What the engine and the routes need from the store: the saved choice, or null when there is none. */
17
- export interface LiveCuesV2SettingReader { stored(): boolean | null }
22
+ /** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
23
+ export const LIVE_CUES_V2_LIMIT_BOUNDS = {
24
+ gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
25
+ maxCuesPerMeeting: { min: 1, max: 25, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
26
+ cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
27
+ dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
28
+ } as const
29
+ export type LiveCuesV2LimitName = keyof typeof LIVE_CUES_V2_LIMIT_BOUNDS
30
+ export const LIVE_CUES_V2_LIMIT_NAMES = Object.keys(LIVE_CUES_V2_LIMIT_BOUNDS) as LiveCuesV2LimitName[]
31
+ export type LiveCuesV2StoredLimits = Partial<Record<LiveCuesV2LimitName, number>>
32
+
33
+ /** Inside the bounds, and whole where the field is whole. */
34
+ export function limitInBounds(name: LiveCuesV2LimitName, value: unknown): value is number {
35
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
36
+ return typeof value === 'number' && Number.isFinite(value) && value >= b.min && value <= b.max && (!b.integer || Number.isInteger(value))
37
+ }
38
+
39
+ /** What the engine and the routes need from the store: the saved choice, or null when there is none, and (6.79.2) the
40
+ * saved limits. `limits` is optional so a reader without it means "no saved limits". */
41
+ export interface LiveCuesV2SettingReader { stored(): boolean | null; limits?(): LiveCuesV2StoredLimits }
18
42
 
19
43
  export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
20
- /** Loaded once; only this server writes the file, and every write goes through save(). */
21
- private cache: { value: boolean | null } | null = null
44
+ /** Loaded once; only this server writes the file, and every write goes through update(). */
45
+ private cache: { value: boolean | null; limits: LiveCuesV2StoredLimits } | null = null
22
46
  constructor(readonly file: string) {}
23
47
 
24
- stored(): boolean | null {
25
- if (this.cache) return this.cache.value
48
+ private load(): { value: boolean | null; limits: LiveCuesV2StoredLimits } {
49
+ if (this.cache) return this.cache
26
50
  let value: boolean | null = null
51
+ const limits: LiveCuesV2StoredLimits = {}
27
52
  try {
28
53
  if (existsSync(this.file)) {
29
54
  const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
30
- if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
31
- value = (raw as { enabled: boolean }).enabled
55
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
56
+ const r = raw as Record<string, unknown>
57
+ if (typeof r.enabled === 'boolean') value = r.enabled
58
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) if (limitInBounds(name, r[name])) limits[name] = r[name] as number
32
59
  }
33
60
  }
34
61
  } catch { value = null }
35
- this.cache = { value }
36
- return value
62
+ this.cache = { value, limits }
63
+ return this.cache
37
64
  }
38
65
 
66
+ stored(): boolean | null { return this.load().value }
67
+ limits(): LiveCuesV2StoredLimits { return { ...this.load().limits } }
68
+
39
69
  /** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
40
- save(enabled: boolean, now: Date = new Date()): void {
70
+ save(enabled: boolean, now: Date = new Date()): void { this.update({ enabled }, now) }
71
+
72
+ /** One write for any mix of the switch and the limits; unnamed fields keep their saved values. */
73
+ update(patch: { enabled?: boolean; limits?: LiveCuesV2StoredLimits }, now: Date = new Date()): void {
74
+ const current = this.load()
75
+ const value = patch.enabled ?? current.value
76
+ const limits = { ...current.limits, ...patch.limits }
77
+ const hasLimits = Object.keys(limits).length > 0
78
+ const body = { schema: hasLimits ? 2 : 1, ...(value === null ? {} : { enabled: value }), ...limits, updatedAt: now.toISOString() }
41
79
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
42
- durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
43
- this.cache = { value: enabled }
80
+ durableAtomicWriteFileSync(this.file, JSON.stringify(body) + '\n', { mode: 0o600 })
81
+ this.cache = { value, limits }
44
82
  }
45
83
  }
46
84
 
@@ -26,7 +26,10 @@ import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers
26
26
  import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
27
27
  import type { DecisionProvider } from './decision-settings.js'
28
28
  import { resolveSubqKey } from './subq-key.js'
29
- import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
29
+ import {
30
+ LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2,
31
+ type LiveCuesV2LimitName, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource, type LiveCuesV2StoredLimits,
32
+ } from './live-cues-v2-settings.js'
30
33
  import { notesMemory } from './live-cues-v2-notes.js'
31
34
  import { meetingLibraryConfigured } from './cos-operations-meetings.js'
32
35
  import { getMeetingStore } from './meeting-store.js'
@@ -87,9 +90,13 @@ export const LIVE_CUES_V2_LIMITS = {
87
90
  export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
88
91
  return resolveLiveCuesV2(env, settings).enabled
89
92
  }
90
- export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
93
+ /** The gate from env alone, or null when env does not set a usable one (unchanged acceptance: 0.3 to 0.99). */
94
+ function envGate(env: NodeJS.ProcessEnv): number | null {
91
95
  const raw = Number(env.COS_LIVE_CUES_V2_GATE)
92
- return Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : LIVE_CUES_V2_LIMITS.defaultGate
96
+ return env.COS_LIVE_CUES_V2_GATE !== undefined && env.COS_LIVE_CUES_V2_GATE.trim() !== '' && Number.isFinite(raw) && raw >= 0.3 && raw <= 0.99 ? raw : null
97
+ }
98
+ export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
99
+ return envGate(env) ?? LIVE_CUES_V2_LIMITS.defaultGate
93
100
  }
94
101
  /** 6.79.0 tuning knobs for Friday's rated-cue review. Unset (or empty, which a managed env may pass for an unset optional
95
102
  * variable) is the calibrated default with no log. Anything else outside the accepted form falls back to the default with
@@ -102,12 +109,49 @@ function knobFallback<T>(name: string, raw: string, fallback: T, accepted: strin
102
109
  }
103
110
  return fallback
104
111
  }
105
- export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
112
+ /** A whole-number env knob (6.79.2 limits): null when unset or empty, the value when inside [min, max], else null with
113
+ * one log line per value (the saved setting or the default then applies). */
114
+ function envWhole(env: NodeJS.ProcessEnv, name: string, min: number, max: number): number | null {
115
+ const raw = env[name]
116
+ if (raw === undefined || raw.trim() === '') return null
117
+ const n = /^\d{1,4}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN
118
+ return n >= min && n <= max ? n : knobFallback<number | null>(name, raw, null, `a whole number from ${min} to ${max}`)
119
+ }
120
+ /** The cooldown from env alone (whole seconds 20..900), or null when unset, empty or invalid (one log line per value). */
121
+ function envCooldownSeconds(env: NodeJS.ProcessEnv): number | null {
106
122
  const raw = env.COS_LIVE_CUES_V2_COOLDOWN_S
107
- if (raw === undefined || raw.trim() === '') return LIVE_CUES_V2_LIMITS.cooldownAfterCueMs
123
+ if (raw === undefined || raw.trim() === '') return null
108
124
  const seconds = /^\d{1,3}$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN // whole seconds only
109
- return seconds >= 20 && seconds <= 900 ? seconds * 1000
110
- : knobFallback('COS_LIVE_CUES_V2_COOLDOWN_S', raw, LIVE_CUES_V2_LIMITS.cooldownAfterCueMs, 'whole seconds from 20 to 900')
125
+ return seconds >= 20 && seconds <= 900 ? seconds : knobFallback<number | null>('COS_LIVE_CUES_V2_COOLDOWN_S', raw, null, 'whole seconds from 20 to 900')
126
+ }
127
+ export function liveCuesV2CooldownMs(env: NodeJS.ProcessEnv = process.env): number {
128
+ const seconds = envCooldownSeconds(env)
129
+ return seconds === null ? LIVE_CUES_V2_LIMITS.cooldownAfterCueMs : seconds * 1000
130
+ }
131
+
132
+ /** One resolved limit: where its value came from, and whether COS Control may change it (an env var locks it). */
133
+ export interface LiveCuesV2Limit { value: number; source: LiveCuesV2SettingSource; locked: boolean; min: number; max: number; default: number }
134
+ export type LiveCuesV2Limits = Record<LiveCuesV2LimitName, LiveCuesV2Limit>
135
+ /** 6.79.2: env wins and locks; else the saved setting; else the default. Read on every evaluation, so a saved change
136
+ * applies from the next one with no restart. */
137
+ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): LiveCuesV2Limits {
138
+ let saved: LiveCuesV2StoredLimits = {}
139
+ try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
140
+ const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
141
+ gate: envGate(env),
142
+ maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, 1, 25),
143
+ cooldownSeconds: envCooldownSeconds(env),
144
+ dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, 1, 500),
145
+ }
146
+ const out = {} as LiveCuesV2Limits
147
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
148
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
149
+ const e = fromEnv[name], v = saved[name]
150
+ out[name] = e !== null ? { value: e, source: 'env', locked: true, min: b.min, max: b.max, default: b.default }
151
+ : v !== undefined ? { value: v, source: 'setting', locked: false, min: b.min, max: b.max, default: b.default }
152
+ : { value: b.default, source: 'default', locked: false, min: b.min, max: b.max, default: b.default }
153
+ }
154
+ return out
111
155
  }
112
156
  export function liveCuesV2JudgeKeep(env: NodeJS.ProcessEnv = process.env): number {
113
157
  const raw = env.COS_LIVE_CUES_V2_JUDGE_KEEP
@@ -244,11 +288,22 @@ export function localWeekStart(now: Date, zone: string): string {
244
288
 
245
289
  // ── Durable state: weekly ceiling, tombstones, pauses, per-meeting counters ─────
246
290
  interface MeetingCounters { cues: number; writerCalls: number; at: number }
291
+ /** 6.79.2: usage on the local calendar day (in the stored zone), for the daily writer ceiling and COS Control's readout. */
292
+ export interface LiveCuesV2DayUsage { writerCalls: number; writerTokensIn: number; writerTokensOut: number; cues: number; jevTokens: number }
293
+ const DAY_FIELDS = ['writerCalls', 'writerTokensIn', 'writerTokensOut', 'cues', 'jevTokens'] as const
247
294
  interface DurableState {
248
295
  zone: string; weekStart: string; writerCalls: number
249
296
  ended: Record<string, number>; paused: Record<string, boolean>; pauseSeq: Record<string, number>
250
297
  meetings: Record<string, MeetingCounters>
298
+ day: string; today: LiveCuesV2DayUsage
251
299
  }
300
+ /** Local calendar day (YYYY-MM-DD) in `zone`. Intl, never toISOString: a UTC key flips the day at 19:00 Central. */
301
+ export function localDay(now: Date, zone: string): string {
302
+ return new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
303
+ }
304
+ const zeroDay = (): LiveCuesV2DayUsage => ({ writerCalls: 0, writerTokensIn: 0, writerTokensOut: 0, cues: 0, jevTokens: 0 })
305
+ /** Saturated writer calls: an unreadable, future-dated or malformed day fails toward NOT spending, like the week. */
306
+ const spentDay = (): LiveCuesV2DayUsage => ({ ...zeroDay(), writerCalls: Number.MAX_SAFE_INTEGER })
252
307
  function validZone(zone: unknown): string | null {
253
308
  if (typeof zone !== 'string' || !zone) return null
254
309
  try { new Intl.DateTimeFormat('en-CA', { timeZone: zone }); return zone } catch { return null }
@@ -278,11 +333,20 @@ export class LiveCuesV2Store {
278
333
  const calls = Number(s.writerCalls ?? 0)
279
334
  // A future week key or a negative/non-numeric count fails toward NOT spending: the week is saturated.
280
335
  const saturated = futureWeek || (sameWeek && !(calls >= 0))
281
- return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings }
336
+ const day = localDay(this.now(), zone)
337
+ const t = (s.today ?? {}) as Partial<Record<string, unknown>>
338
+ let today: LiveCuesV2DayUsage
339
+ if (s.day === undefined || (typeof s.day === 'string' && s.day < day)) today = zeroDay()
340
+ else if (s.day !== day) today = spentDay() // a future or malformed day
341
+ else today = DAY_FIELDS.every(f => t[f] === undefined || Number(t[f]) >= 0)
342
+ ? Object.fromEntries(DAY_FIELDS.map(f => [f, Number(t[f] ?? 0)])) as unknown as LiveCuesV2DayUsage
343
+ : spentDay()
344
+ return { zone, weekStart: week, writerCalls: saturated ? Number.MAX_SAFE_INTEGER : sameWeek ? calls : 0, ended, paused, pauseSeq, meetings, day, today }
282
345
  } catch {
283
- // An unreadable file fails closed for this week (no spending) but keeps cues off nothing else.
346
+ // An unreadable file fails closed for this week and this day (no spending) but keeps cues off nothing else.
284
347
  const zone = validZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? 'UTC'
285
- return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {} }
348
+ return { zone, weekStart: localWeekStart(this.now(), zone), writerCalls: Number.MAX_SAFE_INTEGER, ended: {}, paused: {}, pauseSeq: {}, meetings: {},
349
+ day: localDay(this.now(), zone), today: spentDay() }
286
350
  }
287
351
  }
288
352
  private write(s: DurableState): void {
@@ -294,6 +358,7 @@ export class LiveCuesV2Store {
294
358
  const s = this.read()
295
359
  if (s.writerCalls >= cap) return false
296
360
  s.writerCalls += 1
361
+ s.today.writerCalls += 1
297
362
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
298
363
  s.meetings[sessionId] = { ...m, writerCalls: m.writerCalls + 1, at: this.now().getTime() }
299
364
  this.write(s)
@@ -303,8 +368,21 @@ export class LiveCuesV2Store {
303
368
  const s = this.read()
304
369
  const m = s.meetings[sessionId] ?? { cues: 0, writerCalls: 0, at: 0 }
305
370
  s.meetings[sessionId] = { ...m, cues: m.cues + 1, at: this.now().getTime() }
371
+ s.today.cues += 1
372
+ this.write(s)
373
+ }
374
+ /** Tokens one evaluation spent (writer in and out, the decision provider's gate and judge). One write; nothing when zero. */
375
+ recordUsage(u: { writerTokensIn?: number; writerTokensOut?: number; jevTokens?: number }): void {
376
+ const add = (n: number | undefined) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.round(n) : 0)
377
+ const inc = { writerTokensIn: add(u.writerTokensIn), writerTokensOut: add(u.writerTokensOut), jevTokens: add(u.jevTokens) }
378
+ if (!inc.writerTokensIn && !inc.writerTokensOut && !inc.jevTokens) return
379
+ const s = this.read()
380
+ s.today.writerTokensIn += inc.writerTokensIn; s.today.writerTokensOut += inc.writerTokensOut; s.today.jevTokens += inc.jevTokens
306
381
  this.write(s)
307
382
  }
383
+ /** Today's usage (local day in the stored zone). A saturated (fail-closed) day reads its writer calls as the sentinel. */
384
+ today(): { day: string } & LiveCuesV2DayUsage { const s = this.read(); return { day: s.day, ...s.today } }
385
+ dailyWriterCalls(): number { return this.read().today.writerCalls }
308
386
  counters(sessionId: string): { cues: number; writerCalls: number } {
309
387
  const m = this.read().meetings[sessionId]
310
388
  return { cues: m?.cues ?? 0, writerCalls: m?.writerCalls ?? 0 }
@@ -544,6 +622,15 @@ export class LiveCuesV2Engine {
544
622
  constructor(private readonly deps: LiveCuesV2Deps) {}
545
623
 
546
624
  private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
625
+ /** 6.79.2: the spend limits as they stand now (env, else COS Control's saved values, else the defaults). The writer
626
+ * cap per meeting stays twice the cue cap. */
627
+ private limits(): { gate: number; maxCues: number; writerCap: number; cooldownMs: number; dailyWriterCalls: number } {
628
+ const r = resolveLiveCuesV2Limits(this.deps.env, this.deps.settings)
629
+ return { gate: r.gate.value, maxCues: r.maxCuesPerMeeting.value, writerCap: 2 * r.maxCuesPerMeeting.value,
630
+ cooldownMs: r.cooldownSeconds.value * 1000, dailyWriterCalls: r.dailyWriterCalls.value }
631
+ }
632
+ /** Today's usage for COS Control (local day in the stored zone). */
633
+ today(): { day: string } & LiveCuesV2DayUsage { return this.deps.store.today() }
547
634
 
548
635
  capability(): LiveCuesV2Capability {
549
636
  const model = liveCuesV2Model(this.deps.env)
@@ -620,10 +707,12 @@ export class LiveCuesV2Engine {
620
707
  }
621
708
 
622
709
  private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
623
- const L = LIVE_CUES_V2_LIMITS
710
+ const lim = this.limits()
624
711
  if (s.breaker) return 'breaker_open'
625
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return 'meeting_cap'
626
- if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return 'budget_exhausted'
712
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return 'meeting_cap'
713
+ // The daily ceiling reads as budget_exhausted on the wire: the shared stop-reason union (pinned with the companion's
714
+ // copy) has no daily_cap. The trace says daily_cap.
715
+ if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env) || this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return 'budget_exhausted'
627
716
  return s.stopReason
628
717
  }
629
718
 
@@ -665,10 +754,12 @@ export class LiveCuesV2Engine {
665
754
  if (s.breaker) return this.traceSkip(s, 'breaker_open', nowMs)
666
755
  if (this.deps.store.isPaused(sessionId)) return this.traceSkip(s, 'paused', nowMs)
667
756
  if (s.wordsSinceEval < L.minNewWords) return
668
- if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < liveCuesV2CooldownMs(this.deps.env)) return
757
+ const lim = this.limits()
758
+ if (nowMs - s.lastEvalAt < L.minIntervalMs || nowMs - s.lastCueAt < lim.cooldownMs) return
669
759
  const counters = this.deps.store.counters(sessionId)
670
- if (counters.cues >= L.maxCuesPerMeeting || counters.writerCalls >= L.maxWriterCallsPerMeeting) return this.traceSkip(s, 'meeting_cap', nowMs)
760
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) return this.traceSkip(s, 'meeting_cap', nowMs)
671
761
  if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return this.traceSkip(s, 'budget_exhausted', nowMs) // before the gate
762
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) return this.traceSkip(s, 'daily_cap', nowMs) // before the gate
672
763
  if (this.deps.busy()) return this.traceSkip(s, 'busy', nowMs)
673
764
  await this.evaluate(s)
674
765
  }
@@ -704,7 +795,7 @@ export class LiveCuesV2Engine {
704
795
  const gate = await this.deps.gate(s.title, window, abort.signal, provider)
705
796
  const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
706
797
  Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
707
- if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
798
+ if (pCue < this.limits().gate) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
708
799
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
709
800
 
710
801
  const t1 = Date.now()
@@ -731,6 +822,10 @@ export class LiveCuesV2Engine {
731
822
  trace.kept = kept.length
732
823
  if (abort.signal.aborted) { trace.outcome = 'cancelled'; return }
733
824
 
825
+ // 6.79.2: limits lowered while this evaluation ran apply before the writer is charged.
826
+ const lim = this.limits(), counters = this.deps.store.counters(s.sessionId)
827
+ if (counters.cues >= lim.maxCues || counters.writerCalls >= lim.writerCap) { trace.outcome = 'meeting_cap'; return }
828
+ if (this.deps.store.dailyWriterCalls() >= lim.dailyWriterCalls) { trace.outcome = 'daily_cap'; s.stopReason = 'budget_exhausted'; return }
734
829
  if (!this.deps.store.charge(liveCuesV2WeeklyCap(this.deps.env), s.sessionId)) { trace.outcome = 'budget_exhausted'; s.stopReason = 'budget_exhausted'; return }
735
830
  const { version, system } = liveCuesV2WriterSystem(this.deps.env)
736
831
  const model = liveCuesV2Model(this.deps.env)
@@ -791,6 +886,11 @@ export class LiveCuesV2Engine {
791
886
  if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
792
887
  } finally {
793
888
  clearTimeout(wall)
889
+ // 6.79.2: today's spend for COS Control, from the numbers this evaluation traced. One write; best effort.
890
+ try {
891
+ this.deps.store.recordUsage({ writerTokensIn: trace.writerIn as number | undefined, writerTokensOut: trace.writerOut as number | undefined,
892
+ jevTokens: (Number(trace.gateTokens) || 0) + (Number(trace.judgeTokens) || 0) })
893
+ } catch { /* the readout is best effort; the trace row still has the numbers */ }
794
894
  if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
795
895
  if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
796
896
  s.inFlight = null
@@ -850,16 +950,44 @@ export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): Li
850
950
  /** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
851
951
  * meetings at once; turning it on applies from each meeting's next evaluation. */
852
952
  export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
853
- if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
953
+ const r = applyLiveCuesV2Settings({ enabled, limits: {} }, env)
954
+ if (!r.ok) return { ok: false, error: 'env_forced' }
955
+ return { ok: true, view: liveCuesV2SettingsView(env) }
956
+ }
957
+
958
+ /** GET /api/live-cues/v2/settings?v=2 and POST {v: 2, ...} (6.79.2): the v1 body plus the spend limits and today's usage.
959
+ * Without v=2 the routes answer exactly the v1 body, so an older COS Control is unaffected. */
960
+ export interface LiveCuesV2SettingsResponseV2 extends LiveCuesV2SettingsResponse {
961
+ v: 2
962
+ limits: LiveCuesV2Limits
963
+ /** Always twice the cue cap. */
964
+ writerCallsPerMeeting: number
965
+ today: { day: string } & LiveCuesV2DayUsage
966
+ }
967
+ export function liveCuesV2SettingsViewV2(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponseV2 {
968
+ const limits = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
969
+ return { ...liveCuesV2SettingsView(env), v: 2, limits, writerCallsPerMeeting: 2 * limits.maxCuesPerMeeting.value, today: liveCuesV2().today() }
970
+ }
971
+
972
+ /** Save the switch and any limits in ONE write, after every check passed: nothing is saved when any part is refused.
973
+ * Turning v2 off stops evaluations in flight; limits apply from each meeting's next evaluation. */
974
+ export function applyLiveCuesV2Settings(patch: { enabled?: boolean; limits: LiveCuesV2StoredLimits }, env: NodeJS.ProcessEnv = process.env):
975
+ { ok: true } | { ok: false; error: 'env_forced' } | { ok: false; error: 'env_locked'; fields: LiveCuesV2LimitName[] } {
976
+ if (patch.enabled === false && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
977
+ const current = resolveLiveCuesV2Limits(env, liveCuesV2Settings())
978
+ const locked = (Object.keys(patch.limits) as LiveCuesV2LimitName[]).filter(name => current[name].locked)
979
+ if (locked.length) return { ok: false, error: 'env_locked', fields: locked }
854
980
  const wasOn = liveCuesV2Enabled(env)
855
- liveCuesV2Settings().save(enabled)
981
+ liveCuesV2Settings().update(patch)
856
982
  if (wasOn && !liveCuesV2Enabled(env)) {
857
983
  const aborted = engine?.suspend() ?? 0
858
984
  console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
859
985
  } else if (!wasOn && liveCuesV2Enabled(env)) {
860
986
  console.log('[live-cues-v2] switched on by setting')
861
987
  }
862
- return { ok: true, view: liveCuesV2SettingsView(env) }
988
+ const changed = Object.entries(patch.limits).map(([k, v]) => `${k}=${v}`).join(' ')
989
+ if (changed) console.log(`[live-cues-v2] limits saved: ${changed}`)
990
+ return { ok: true }
863
991
  }
864
992
  /** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
865
993
  * companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
@@ -18,6 +18,8 @@ import {
18
18
  LiveCuesArmError,
19
19
  } from '../lib/live-cues-engine.js'
20
20
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
21
+ import { applyLiveCuesV2Settings, liveCuesV2SettingsViewV2 } from '../lib/live-cues-v2.js'
22
+ import { LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, limitInBounds, type LiveCuesV2StoredLimits } from '../lib/live-cues-v2-settings.js'
21
23
  import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
22
24
  import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
23
25
 
@@ -186,14 +188,23 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
186
188
  // COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
187
189
  // data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
188
190
  // liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
189
- liveCuesRouter.get('/live-cues/v2/settings', (_req, res) => {
191
+ //
192
+ // 6.79.2: COS Control's spend limits ride the same routes, opt-in by version so an older Control is answered byte for byte as
193
+ // before. GET ?v=2 and POST {v: 2, ...any of enabled, gate, maxCuesPerMeeting, cooldownSeconds, dailyWriterCalls} answer the
194
+ // v1 body plus {v: 2, limits, writerCallsPerMeeting, today}. A limit an env var sets is locked (409 env_locked). An older
195
+ // server ignores ?v=2 and answers without `v`, which is Control's signal to hide the limits.
196
+ liveCuesRouter.get('/live-cues/v2/settings', (req, res) => {
190
197
  res.set('Cache-Control', 'private, no-store')
191
- try { return res.json(liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
198
+ const v = req.query.v
199
+ if (v !== undefined && v !== '2') return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
200
+ try { return res.json(v === '2' ? liveCuesV2SettingsViewV2() : liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
192
201
  })
193
202
 
203
+ const V2_SETTINGS_KEYS = new Set<string>(['v', 'enabled', ...LIVE_CUES_V2_LIMIT_NAMES])
194
204
  liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
195
205
  res.set('Cache-Control', 'private, no-store')
196
- const body = req.body as { enabled?: unknown } | undefined
206
+ const body = req.body as { enabled?: unknown; v?: unknown } | undefined
207
+ if (body && typeof body === 'object' && !Array.isArray(body) && body.v !== undefined) return postSettingsV2(body as Record<string, unknown>, res)
197
208
  if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
198
209
  return res.status(400).json({ error: 'invalid_request' })
199
210
  }
@@ -205,3 +216,28 @@ liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
205
216
  return res.status(500).json({ error: 'settings_write_failed' })
206
217
  }
207
218
  })
219
+
220
+ /** POST {v: 2, ...}: every field is checked before anything is saved, so a refused request changes nothing. */
221
+ function postSettingsV2(body: Record<string, unknown>, res: import('express').Response) {
222
+ if (body.v !== 2) return res.status(400).json({ error: 'invalid_request', reason: 'unsupported_version' })
223
+ const unknown = Object.keys(body).find(k => !V2_SETTINGS_KEYS.has(k))
224
+ if (unknown) return res.status(400).json({ error: 'invalid_request', reason: 'unknown_field', field: unknown })
225
+ if (body.enabled !== undefined && typeof body.enabled !== 'boolean') return res.status(400).json({ error: 'invalid_request', reason: 'invalid_enabled', field: 'enabled' })
226
+ const limits: LiveCuesV2StoredLimits = {}
227
+ for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
228
+ if (body[name] === undefined) continue
229
+ if (!limitInBounds(name, body[name])) {
230
+ const b = LIVE_CUES_V2_LIMIT_BOUNDS[name]
231
+ return res.status(400).json({ error: 'invalid_request', reason: 'out_of_bounds', field: name, min: b.min, max: b.max, integer: b.integer })
232
+ }
233
+ limits[name] = body[name] as number
234
+ }
235
+ if (body.enabled === undefined && Object.keys(limits).length === 0) return res.status(400).json({ error: 'invalid_request', reason: 'empty' })
236
+ try {
237
+ const result = applyLiveCuesV2Settings({ ...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}), limits })
238
+ if (!result.ok) return res.status(409).json(result.error === 'env_locked' ? { error: 'env_locked', fields: result.fields } : { error: result.error })
239
+ return res.json(liveCuesV2SettingsViewV2())
240
+ } catch {
241
+ return res.status(500).json({ error: 'settings_write_failed' })
242
+ }
243
+ }