@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.
- package/CHANGELOG.md +133 -0
- package/README.md +29 -0
- package/managed-runtime-contract.json +11 -1
- package/package.json +3 -1
- package/server/lib/dictation-clean.ts +76 -16
- package/server/lib/dictation-fillers.ts +116 -0
- package/server/lib/dictation-polish-budget.ts +87 -0
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- package/server/lib/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- package/server/routes/live-cues.ts +39 -3
- package/server/routes/prompt-drafts.ts +95 -34
|
@@ -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
|
-
/**
|
|
17
|
-
export
|
|
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
|
|
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
|
-
|
|
25
|
-
if (this.cache) return this.cache
|
|
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)
|
|
31
|
-
|
|
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
|
|
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(
|
|
43
|
-
this.cache = { value
|
|
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 {
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
710
|
+
const lim = this.limits()
|
|
624
711
|
if (s.breaker) return 'breaker_open'
|
|
625
|
-
if (counters.cues >=
|
|
626
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 <
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|