@gotcos/glasses-server 6.76.1 → 6.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// cue, a 45 s wall per evaluation, and no evaluation while a user's own query runs. Each evaluation writes one trace row
|
|
10
10
|
// without transcript text; a skip (paused, breaker, meeting cap, weekly budget, busy) writes one `skip` row per reason
|
|
11
11
|
// per meeting at most every 5 minutes, so the trace can tell those apart from silence.
|
|
12
|
+
//
|
|
13
|
+
// 6.76.2: the switch is also a runtime setting (live-cues-v2-settings.ts; env COS_LIVE_CUES_V2=1 still forces it on), the
|
|
14
|
+
// gate and the judge run on Jev, or on SubQ when COS_DECISION_PROVIDER_CUES=subq or SubQ is the only key (each with its
|
|
15
|
+
// own daily cap and breaker; resolveCuesDecisionProvider), and
|
|
16
|
+
// when cue_memory.py is unavailable, fails or times out the memory arm searches the owner's own COS notes and meetings
|
|
17
|
+
// instead (live-cues-v2-notes.ts; local keyword search, no model call). Each evaluation traces `cueMemorySource`.
|
|
12
18
|
|
|
13
19
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
14
20
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
@@ -17,16 +23,23 @@ import { dirname, resolve } from 'node:path'
|
|
|
17
23
|
import { emitDisplay, onDisplay } from './display-bus.js'
|
|
18
24
|
import { acquireMaintenanceWork, MaintenanceLifecycleError, maintenanceLifecycle, type MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
19
25
|
import { JevClient, JevError, estimateJevTokens, resolveJevKey, type JevAnswers } from './jev.js'
|
|
26
|
+
import { DecisionProviderError, SubqDecisionClient, trackSubqDecisionClient, type DecisionClient } from './decision-provider.js'
|
|
27
|
+
import type { DecisionProvider } from './decision-settings.js'
|
|
28
|
+
import { resolveSubqKey } from './subq-key.js'
|
|
29
|
+
import { liveCuesV2EnvForced, liveCuesV2Settings, resolveLiveCuesV2, type LiveCuesV2SettingReader, type LiveCuesV2SettingSource } from './live-cues-v2-settings.js'
|
|
30
|
+
import { notesMemory } from './live-cues-v2-notes.js'
|
|
31
|
+
import { meetingLibraryConfigured } from './cos-operations-meetings.js'
|
|
32
|
+
import { getMeetingStore } from './meeting-store.js'
|
|
20
33
|
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
21
34
|
import { resolveProviderBinary } from './provider-binary.js'
|
|
22
|
-
import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
|
|
35
|
+
import { COS_SCRIPTS_DIR, PYTHON_BIN, contextSourceAvailable } from './python-bridge.js'
|
|
23
36
|
import { dataPath } from './data-dir.js'
|
|
24
37
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
25
38
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
26
39
|
import { CUE_GATE_QUESTIONS, CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE, CUE_WRITER_SYSTEM_V2, CUE_WRITER_SYSTEM_V3 } from './live-cues-v2-prompts.js'
|
|
27
40
|
import {
|
|
28
41
|
CUE_DETAIL_MAX, CUE_FEEDBACK_REASON_MAX, CUE_HEADLINE_MAX, LIVE_CUES_V2_PROTOCOL, isLiveCueV2,
|
|
29
|
-
type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability,
|
|
42
|
+
type CueKind, type CueSourceRef, type LiveCueV1Compat, type LiveCueV2, type LiveCuesV2Capability, type LiveCuesV2MemorySource,
|
|
30
43
|
type LiveCuesV2SessionStatus, type LiveCuesV2StatusResponse, type LiveCuesV2StopReason,
|
|
31
44
|
} from '../../shared/live-cues-v2.js'
|
|
32
45
|
|
|
@@ -60,10 +73,14 @@ export const LIVE_CUES_V2_LIMITS = {
|
|
|
60
73
|
counterTtlMs: 24 * 60 * 60_000,
|
|
61
74
|
/** ~1.2k gate tokens per evaluation, at most 3 a minute: ~216k an hour, so 1.5M covers ~7 meeting hours (~$0.06). */
|
|
62
75
|
defaultJevDailyTokens: 1_500_000,
|
|
76
|
+
/** The same ceiling for SubQ, on its own ledger (subq-cues-usage.json). SubQ reserves a byte-based estimate before each
|
|
77
|
+
* call and records the input tokens it reports. */
|
|
78
|
+
defaultSubqDailyTokens: 1_500_000,
|
|
63
79
|
} as const
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
/** On when env COS_LIVE_CUES_V2=1 forces it, else when the saved runtime setting says so (6.76.2). Off by default. */
|
|
82
|
+
export function liveCuesV2Enabled(env: NodeJS.ProcessEnv = process.env, settings: LiveCuesV2SettingReader | null = liveCuesV2Settings()): boolean {
|
|
83
|
+
return resolveLiveCuesV2(env, settings).enabled
|
|
67
84
|
}
|
|
68
85
|
export function liveCuesV2Gate(env: NodeJS.ProcessEnv = process.env): number {
|
|
69
86
|
const raw = Number(env.COS_LIVE_CUES_V2_GATE)
|
|
@@ -86,6 +103,29 @@ function jevDailyCap(): number {
|
|
|
86
103
|
const raw = Number(process.env.COS_LIVE_CUES_JEV_DAILY_TOKENS)
|
|
87
104
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultJevDailyTokens
|
|
88
105
|
}
|
|
106
|
+
function subqDailyCap(): number {
|
|
107
|
+
const raw = Number(process.env.COS_LIVE_CUES_SUBQ_DAILY_TOKENS)
|
|
108
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : LIVE_CUES_V2_LIMITS.defaultSubqDailyTokens
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Which classifier runs the gate and the judge. Cues do NOT follow COS Control's saved decision provider or
|
|
113
|
+
* COS_DECISION_PROVIDER (Miles 2026-10-17): the 0.8 gate and judge thresholds were calibrated on Jev, there is no SubQ
|
|
114
|
+
* replay, and SubQ would receive the live transcript window. SubQ runs cues only when env COS_DECISION_PROVIDER_CUES=subq
|
|
115
|
+
* says so, or when SubQ is the only decision key on this Mac; otherwise Jev. Any other value of that env throws
|
|
116
|
+
* decision_invalid_provider: never silently Jev.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveCuesDecisionProvider(env: NodeJS.ProcessEnv = process.env,
|
|
119
|
+
keys: { jev: () => boolean; subq: () => boolean } = { jev: () => !!resolveJevKey(), subq: () => !!resolveSubqKey() }): { provider: DecisionProvider; source: 'env' | 'only_key' | 'default' } {
|
|
120
|
+
const raw = env.COS_DECISION_PROVIDER_CUES
|
|
121
|
+
if (raw !== undefined) {
|
|
122
|
+
const v = raw.trim().toLowerCase()
|
|
123
|
+
if (v !== 'jev' && v !== 'subq') throw new DecisionProviderError('decision_invalid_provider')
|
|
124
|
+
return { provider: v, source: 'env' }
|
|
125
|
+
}
|
|
126
|
+
if (!keys.jev() && keys.subq()) return { provider: 'subq', source: 'only_key' }
|
|
127
|
+
return { provider: 'jev', source: 'default' }
|
|
128
|
+
}
|
|
89
129
|
|
|
90
130
|
// ── Python-style JSON (the replay built every prompt with json.dumps; keep byte parity) ──
|
|
91
131
|
export function pyJson(value: unknown): string {
|
|
@@ -251,17 +291,31 @@ export class LiveCuesV2Store {
|
|
|
251
291
|
// ── Dependencies (real by default, faked in tests) ──────────────────────────
|
|
252
292
|
export interface GateResult { pCue: number; kind: CueKind; lookup: Record<string, number> | null; tokens: number }
|
|
253
293
|
export interface MemoryResult { notes: MemoryNote[]; denseError?: string; indexAgeH?: number }
|
|
294
|
+
/** cueMemory() codes that mean the script did not answer (missing, failed or timed out), so the notes fallback runs. A dense
|
|
295
|
+
* error the script itself reports (its keyword arm still answered) is not one, nor is an empty answer: on Miles's Mac an
|
|
296
|
+
* empty cue_memory answer means "no related meeting", and searching his notes instead would change that path. */
|
|
297
|
+
export const CUE_MEMORY_FAILURES: ReadonlySet<string> = new Set(['no_cue_memory_script', 'memory_timeout', 'spawn_failed', 'output_limit', 'bad_output', 'memory_threw'])
|
|
298
|
+
export function cueMemoryFailed(mem: MemoryResult): boolean { return !!mem.denseError && CUE_MEMORY_FAILURES.has(mem.denseError) }
|
|
254
299
|
export interface LiveCuesV2Deps {
|
|
255
|
-
gate(title: string, window: string, signal: AbortSignal): Promise<GateResult>
|
|
300
|
+
gate(title: string, window: string, signal: AbortSignal, provider: DecisionProvider): Promise<GateResult>
|
|
301
|
+
/** cue_memory.py (Miles's COS scripts). */
|
|
256
302
|
memory(window: string, before: string, signal: AbortSignal): Promise<MemoryResult>
|
|
257
|
-
|
|
303
|
+
/** The owner's own COS notes and meetings, used only when `memory` did not answer. Local search, no model call. */
|
|
304
|
+
notes(window: string, before: string, signal: AbortSignal, sessionId: string): Promise<MemoryResult>
|
|
305
|
+
judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal, provider: DecisionProvider): Promise<{ probs: Record<string, number>; tokens: number }>
|
|
258
306
|
writer(model: string, system: string, prompt: string, signal: AbortSignal): Promise<{ text: string; costUsd?: number; inputTokens?: number; outputTokens?: number; model?: string }>
|
|
259
307
|
emit(type: 'coaching_nudge' | 'coaching_nudge_v2', data: Record<string, unknown>): void
|
|
260
308
|
lease(): MaintenanceWorkLease | null
|
|
261
309
|
/** True while the user's own query runs (Ask, a durable query): cues wait rather than compete for the provider. */
|
|
262
310
|
busy(): boolean
|
|
263
|
-
/** The
|
|
264
|
-
|
|
311
|
+
/** The configured decision provider for cues. Throws DecisionProviderError('decision_invalid_provider'). */
|
|
312
|
+
decisionProvider(): DecisionProvider
|
|
313
|
+
/** That provider's cue client: key present, daily cap, breaker. For an honest capability. */
|
|
314
|
+
decisionState(provider: DecisionProvider): { configured: boolean; breakerOpen: boolean; capReached: boolean }
|
|
315
|
+
/** Where past notes would come from now, for the capability. */
|
|
316
|
+
memorySource(): LiveCuesV2MemorySource
|
|
317
|
+
/** The saved runtime switch (null when none). */
|
|
318
|
+
settings: LiveCuesV2SettingReader
|
|
265
319
|
warmMemory(): void
|
|
266
320
|
now(): Date
|
|
267
321
|
store: LiveCuesV2Store
|
|
@@ -273,26 +327,69 @@ function cleanWindow(text: string): string {
|
|
|
273
327
|
return text.replace(/^\[\d+\]\s*/gm, '').replace(/\*\*?Speaker \d+:?\*\*?:?|Speaker \d+:/g, '')
|
|
274
328
|
}
|
|
275
329
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
330
|
+
/** The gate and the judge over any decision client. Jev and SubQ take the same request (state plus `choice` questions with
|
|
331
|
+
* keyed criteria) and answer the same shape (answers.<name>.probabilities and .choice); SubQ also validates every answer
|
|
332
|
+
* against the questions before it returns. Prompts are byte-identical on both. */
|
|
333
|
+
export function cueDecisionCalls(client: Pick<DecisionClient, 'ask'>) {
|
|
279
334
|
return {
|
|
280
|
-
async gate(title, window, signal) {
|
|
335
|
+
async gate(title: string, window: string, signal: AbortSignal): Promise<GateResult> {
|
|
281
336
|
const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
|
|
282
|
-
const res: JevAnswers = await
|
|
337
|
+
const res: JevAnswers = await client.ask(state, CUE_GATE_QUESTIONS as unknown as Record<string, unknown>, estimateJevTokens({ state, CUE_GATE_QUESTIONS }), { signal })
|
|
283
338
|
const a = res.answers ?? {}
|
|
284
339
|
const kind = a.kind?.choice === 'reinforce' ? 'reinforce' : 'challenge'
|
|
285
340
|
return { pCue: Number(a.act?.probabilities?.cue ?? 0), kind, lookup: a.lookup?.probabilities ?? null, tokens: res.inputTokens }
|
|
286
341
|
},
|
|
287
|
-
|
|
288
|
-
async judge(title, window, notes, signal) {
|
|
342
|
+
async judge(title: string, window: string, notes: MemoryNote[], signal: AbortSignal): Promise<{ probs: Record<string, number>; tokens: number }> {
|
|
289
343
|
const criteria: Record<string, string> = Object.fromEntries(notes.map(m => [m.id, `${m.date} ${m.title}: ${m.notes.slice(0, 300)}`]))
|
|
290
344
|
criteria.none = CUE_JUDGE_NONE
|
|
291
345
|
const state = { meetingTitle: title, window: window.slice(-LIVE_CUES_V2_LIMITS.windowChars) }
|
|
292
346
|
const questions = { same: { type: 'choice', instructions: CUE_JUDGE_INSTRUCTIONS, criteria } }
|
|
293
|
-
const res = await
|
|
347
|
+
const res = await client.ask(state, questions, estimateJevTokens({ state, questions }), { signal })
|
|
294
348
|
return { probs: res.answers?.same?.probabilities ?? {}, tokens: res.inputTokens }
|
|
295
349
|
},
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Is there anything of the owner's own to search when cue_memory.py is absent: the memory bridge or memory files, a
|
|
354
|
+
* configured meeting library, or this server's own saved recordings. */
|
|
355
|
+
function notesAvailable(): boolean {
|
|
356
|
+
try { if (contextSourceAvailable() !== null) return true } catch { /* next */ }
|
|
357
|
+
try { if (meetingLibraryConfigured()) return true } catch { /* next */ }
|
|
358
|
+
try { return getMeetingStore().listMonths().length > 0 } catch { return false }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function realDeps(): LiveCuesV2Deps {
|
|
362
|
+
const jev = new JevClient(fetch, () => new Date(), dataPath('jev-cues-usage.json'), jevDailyCap)
|
|
363
|
+
const subq = trackSubqDecisionClient(new SubqDecisionClient(fetch, () => new Date(), dataPath('subq-cues-usage.json'), () => resolveSubqKey()?.key, subqDailyCap))
|
|
364
|
+
const calls = (provider: DecisionProvider) => cueDecisionCalls(provider === 'subq' ? subq : jev)
|
|
365
|
+
const traceFile = dataPath('live-cues-trace.jsonl')
|
|
366
|
+
let memorySourceCache: { at: number; value: LiveCuesV2MemorySource } | null = null
|
|
367
|
+
return {
|
|
368
|
+
gate: (title, window, signal, provider) => calls(provider).gate(title, window, signal),
|
|
369
|
+
memory: (window, before, signal) => cueMemory(window, before, signal),
|
|
370
|
+
async notes(window, before, signal, sessionId) {
|
|
371
|
+
const r = await notesMemory(window, before, signal, { sessionId })
|
|
372
|
+
return { notes: r.notes, ...(r.error ? { denseError: r.error } : {}) }
|
|
373
|
+
},
|
|
374
|
+
judge: (title, window, notes, signal, provider) => calls(provider).judge(title, window, notes, signal),
|
|
375
|
+
decisionProvider: () => resolveCuesDecisionProvider(process.env).provider,
|
|
376
|
+
decisionState(provider) {
|
|
377
|
+
if (provider === 'subq') {
|
|
378
|
+
const st = subq.status()
|
|
379
|
+
return { configured: st.configured, breakerOpen: st.breakerOpenUntil !== null, capReached: st.usedToday >= st.dailyCap }
|
|
380
|
+
}
|
|
381
|
+
const st = jev.status()
|
|
382
|
+
return { configured: !!resolveJevKey(), breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
|
|
383
|
+
},
|
|
384
|
+
memorySource() {
|
|
385
|
+
// /api/health polls the capability: probe the disk at most once a minute.
|
|
386
|
+
const nowMs = Date.now()
|
|
387
|
+
if (memorySourceCache && nowMs - memorySourceCache.at < 60_000) return memorySourceCache.value
|
|
388
|
+
const value: LiveCuesV2MemorySource = cueMemoryPaths() ? 'cue_memory' : notesAvailable() ? 'notes' : 'none'
|
|
389
|
+
memorySourceCache = { at: nowMs, value }
|
|
390
|
+
return value
|
|
391
|
+
},
|
|
392
|
+
settings: liveCuesV2Settings(),
|
|
296
393
|
async writer(model, system, prompt, signal) {
|
|
297
394
|
const run = await runLensGistEngine('claude', model, prompt, { system, timeoutMs: LIVE_CUES_V2_LIMITS.writerTimeoutMs, source: 'live-cues-v2', maxOutputTokens: LIVE_CUES_V2_LIMITS.writerMaxOutputTokens, signal })
|
|
298
395
|
return { text: run.text, costUsd: run.usage?.costUsd, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
|
|
@@ -303,10 +400,6 @@ function realDeps(): LiveCuesV2Deps {
|
|
|
303
400
|
const active = maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
|
|
304
401
|
return ['durable_query', 'legacy_query', 'openai_query', 'meeting_ask'].some(kind => (active[kind] ?? 0) > 0)
|
|
305
402
|
},
|
|
306
|
-
jevState() {
|
|
307
|
-
const st = jev.status()
|
|
308
|
-
return { breakerOpen: !!st.breakerOpenUntil, capReached: st.usedToday >= st.dailyCap }
|
|
309
|
-
},
|
|
310
403
|
warmMemory: () => warmCueMemory(),
|
|
311
404
|
now: () => new Date(),
|
|
312
405
|
store: new LiveCuesV2Store(dataPath('live-cues-v2-state.json'), () => new Date()),
|
|
@@ -392,23 +485,40 @@ function isCancel(error: unknown, signal: AbortSignal): boolean {
|
|
|
392
485
|
return signal.aborted || (error instanceof Error && error.name === 'AbortError')
|
|
393
486
|
}
|
|
394
487
|
|
|
488
|
+
/** Decision-provider codes that stop cues quietly with a reason (not a failure, so not toward the meeting breaker). The
|
|
489
|
+
* provider's own breaker and cap already bound these; counting them would also end the meeting's cues. */
|
|
490
|
+
export const QUIET_DECISION_STOPS: ReadonlySet<string> = new Set(['jev_cap_reached', 'jev_breaker_open', 'jev_not_configured',
|
|
491
|
+
'decision_cap_reached', 'decision_breaker_open', 'decision_not_configured', 'decision_invalid_provider'])
|
|
492
|
+
/** Abort reason when v2 is switched off at runtime. */
|
|
493
|
+
export const V2_OFF = 'v2_off'
|
|
494
|
+
|
|
395
495
|
export class LiveCuesV2Engine {
|
|
396
496
|
private readonly sessions = new Map<string, Session>()
|
|
397
497
|
constructor(private readonly deps: LiveCuesV2Deps) {}
|
|
398
498
|
|
|
499
|
+
private enabled(): boolean { return liveCuesV2Enabled(this.deps.env, this.deps.settings) }
|
|
500
|
+
|
|
399
501
|
capability(): LiveCuesV2Capability {
|
|
400
502
|
const model = liveCuesV2Model(this.deps.env)
|
|
401
|
-
|
|
503
|
+
let memory: LiveCuesV2MemorySource = 'none'
|
|
504
|
+
try { memory = this.deps.memorySource() } catch { memory = 'none' }
|
|
505
|
+
let provider: DecisionProvider | null = null
|
|
506
|
+
try { provider = this.deps.decisionProvider() } catch { provider = null }
|
|
507
|
+
const tail = { protocol: LIVE_CUES_V2_PROTOCOL, model, memory, ...(provider ? { decisionProvider: provider } : {}) }
|
|
508
|
+
const off = (reason: string): LiveCuesV2Capability => ({ available: false, reason, ...tail })
|
|
402
509
|
try {
|
|
403
|
-
if (!
|
|
510
|
+
if (!this.enabled()) return off('master_flag_off')
|
|
404
511
|
if (this.deps.env.ANTHROPIC_API_KEY && this.deps.env.COS_ALLOW_API_FALLBACK !== '1') return off('api_key_in_env')
|
|
405
|
-
if (!
|
|
512
|
+
if (!provider) return off('decision_invalid_provider')
|
|
513
|
+
const st = this.deps.decisionState(provider)
|
|
514
|
+
// jev_not_configured keeps its 6.75 meaning (Jev chosen, no Jev key) only when a SubQ key exists; with no key for
|
|
515
|
+
// the chosen provider otherwise, the reason is decision_not_configured.
|
|
516
|
+
if (!st.configured) return off(provider === 'jev' && this.deps.decisionState('subq').configured ? 'jev_not_configured' : 'decision_not_configured')
|
|
406
517
|
if (!resolveProviderBinary('claude').ok) return off('claude_not_found')
|
|
407
518
|
if (this.deps.store.weeklyUsed() >= liveCuesV2WeeklyCap(this.deps.env)) return off('budget_exhausted')
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
return { available: true, protocol: LIVE_CUES_V2_PROTOCOL, model }
|
|
519
|
+
if (st.capReached) return off(provider === 'jev' ? 'jev_cap_reached' : 'decision_cap_reached')
|
|
520
|
+
if (st.breakerOpen) return off(provider === 'jev' ? 'jev_breaker_open' : 'decision_breaker_open')
|
|
521
|
+
return { available: true, ...tail }
|
|
412
522
|
} catch {
|
|
413
523
|
return off('unavailable')
|
|
414
524
|
}
|
|
@@ -452,14 +562,14 @@ export class LiveCuesV2Engine {
|
|
|
452
562
|
const s = this.sessions.get(sessionId)
|
|
453
563
|
const ended = this.deps.store.isEnded(sessionId)
|
|
454
564
|
const paused = this.deps.store.isPaused(sessionId)
|
|
455
|
-
if (!s && !ended) return { protocol: 2, enabled:
|
|
565
|
+
if (!s && !ended) return { protocol: 2, enabled: this.enabled(), session: null }
|
|
456
566
|
const counters = this.deps.store.counters(sessionId)
|
|
457
567
|
const reason = s ? this.stopReasonOf(s, counters) : null
|
|
458
568
|
const session: LiveCuesV2SessionStatus = {
|
|
459
569
|
sessionId, state: ended ? 'ended' : paused ? 'paused' : reason ? 'stopped' : 'active', ...(reason && !ended && !paused ? { reason } : {}),
|
|
460
570
|
paused, ended, pauseSeq: this.deps.store.pauseSeq(sessionId), cues: s ? [...s.cues] : [], evaluations: s?.evaluations ?? 0, writerCalls: counters.writerCalls,
|
|
461
571
|
}
|
|
462
|
-
return { protocol: 2, enabled:
|
|
572
|
+
return { protocol: 2, enabled: this.enabled(), session }
|
|
463
573
|
}
|
|
464
574
|
|
|
465
575
|
private stopReasonOf(s: Session, counters: { cues: number; writerCalls: number }): LiveCuesV2StopReason | null {
|
|
@@ -479,7 +589,7 @@ export class LiveCuesV2Engine {
|
|
|
479
589
|
|
|
480
590
|
/** From the ASR path: fire-and-forget, never awaited. All gates live here. */
|
|
481
591
|
async feed(sessionId: string, text: string, asrDegraded = false): Promise<void> {
|
|
482
|
-
if (!
|
|
592
|
+
if (!this.enabled()) return
|
|
483
593
|
if (this.deps.store.isEnded(sessionId)) return // tombstone before any auto-arm
|
|
484
594
|
let s = this.sessions.get(sessionId)
|
|
485
595
|
if (!s) {
|
|
@@ -532,8 +642,11 @@ export class LiveCuesV2Engine {
|
|
|
532
642
|
wall.unref?.()
|
|
533
643
|
try {
|
|
534
644
|
try { lease = this.deps.lease() } catch (e) { if (e instanceof MaintenanceLifecycleError) { trace.outcome = 'maintenance'; return } throw e }
|
|
645
|
+
// One provider per evaluation: a settings change applies from the next one, never between the gate and the judge.
|
|
646
|
+
const provider = this.deps.decisionProvider()
|
|
647
|
+
trace.decisionProvider = provider
|
|
535
648
|
const t0 = Date.now()
|
|
536
|
-
const gate = await this.deps.gate(s.title, window, abort.signal)
|
|
649
|
+
const gate = await this.deps.gate(s.title, window, abort.signal, provider)
|
|
537
650
|
const pCue = Number.isFinite(gate.pCue) ? gate.pCue : 0
|
|
538
651
|
Object.assign(trace, { gateMs: Date.now() - t0, pCue: round(pCue), cueKind: gate.kind, lookup: gate.lookup, gateTokens: gate.tokens })
|
|
539
652
|
if (pCue < liveCuesV2Gate(this.deps.env)) { trace.outcome = 'gate_silent'; s.stopReason = null; return }
|
|
@@ -541,12 +654,22 @@ export class LiveCuesV2Engine {
|
|
|
541
654
|
|
|
542
655
|
const t1 = Date.now()
|
|
543
656
|
const before = new Date(started + 86_400_000).toLocaleDateString('en-CA')
|
|
544
|
-
|
|
545
|
-
Object.assign(trace, { memoryMs: Date.now() - t1,
|
|
657
|
+
let mem: MemoryResult = await this.deps.memory(window, before, abort.signal).catch((): MemoryResult => ({ notes: [], denseError: 'memory_threw' }))
|
|
658
|
+
Object.assign(trace, { memoryMs: Date.now() - t1, ...(mem.denseError ? { memoryError: mem.denseError } : {}), ...(mem.indexAgeH !== undefined ? { indexAgeH: mem.indexAgeH } : {}) })
|
|
659
|
+
let source: LiveCuesV2MemorySource = 'cue_memory'
|
|
660
|
+
if (cueMemoryFailed(mem) && !abort.signal.aborted) {
|
|
661
|
+
// cue_memory.py did not answer: search the owner's own COS (no model call), inside its own 3 s budget.
|
|
662
|
+
const tn = Date.now()
|
|
663
|
+
const found: MemoryResult = await this.deps.notes(window, before, abort.signal, s.sessionId).catch((): MemoryResult => ({ notes: [], denseError: 'notes_threw' }))
|
|
664
|
+
Object.assign(trace, { notesMs: Date.now() - tn, ...(found.denseError ? { notesError: found.denseError } : {}) })
|
|
665
|
+
mem = { notes: found.notes }
|
|
666
|
+
source = 'notes'
|
|
667
|
+
}
|
|
668
|
+
Object.assign(trace, { candidates: mem.notes.length, cueMemorySource: mem.notes.length ? source : 'none' })
|
|
546
669
|
let kept: MemoryNote[] = []
|
|
547
670
|
if (mem.notes.length && !abort.signal.aborted) {
|
|
548
671
|
const t2 = Date.now()
|
|
549
|
-
const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal)
|
|
672
|
+
const judged = await this.deps.judge(s.title, window, mem.notes, abort.signal, provider)
|
|
550
673
|
Object.assign(trace, { judgeMs: Date.now() - t2, judgeTokens: judged.tokens })
|
|
551
674
|
kept = mem.notes.filter(m => (judged.probs[m.id] ?? 0) >= L.judgeKeep).slice(0, L.judgeMaxNotes).map((m, i) => ({ ...m, id: `M${i + 1}` }))
|
|
552
675
|
}
|
|
@@ -570,7 +693,7 @@ export class LiveCuesV2Engine {
|
|
|
570
693
|
if (typeof parsed === 'string') { trace.outcome = parsed === 'null' ? 'writer_null' : `withheld_${parsed}`; return }
|
|
571
694
|
const elapsed = this.deps.now().getTime() - started
|
|
572
695
|
if (s.wordsSinceEval > L.staleWords || elapsed > L.staleMs) { trace.outcome = 'stale'; return }
|
|
573
|
-
if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId)) { trace.outcome = 'cancelled'; return }
|
|
696
|
+
if (this.deps.store.isEnded(s.sessionId) || this.deps.store.isPaused(s.sessionId) || !this.enabled()) { trace.outcome = 'cancelled'; return }
|
|
574
697
|
|
|
575
698
|
const sources: CueSourceRef[] = parsed.memoryIds.map(id => kept.find(m => m.id === id)).filter((m): m is MemoryNote => !!m)
|
|
576
699
|
.map(m => ({ id: sourceId(m), date: m.date, title: m.title }))
|
|
@@ -587,18 +710,19 @@ export class LiveCuesV2Engine {
|
|
|
587
710
|
} catch (error) {
|
|
588
711
|
if (wallHit) { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true; return }
|
|
589
712
|
if (isCancel(error, abort.signal)) { trace.outcome = 'cancelled'; return }
|
|
590
|
-
if (error instanceof JevError
|
|
713
|
+
if ((error instanceof JevError || error instanceof DecisionProviderError) && QUIET_DECISION_STOPS.has(error.code)) {
|
|
591
714
|
trace.outcome = error.code
|
|
592
715
|
s.stopReason = error.code as LiveCuesV2StopReason
|
|
593
716
|
return
|
|
594
717
|
}
|
|
595
718
|
trace.outcome = 'failure'
|
|
596
|
-
trace.error = error instanceof JevError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
|
|
719
|
+
trace.error = error instanceof JevError || error instanceof DecisionProviderError ? error.code : error instanceof LensGistLimitError ? 'writer_quota' : 'writer_error'
|
|
597
720
|
s.failures += 1
|
|
598
721
|
if (error instanceof LensGistLimitError || s.failures >= L.breakerFailures) s.breaker = true
|
|
599
722
|
} finally {
|
|
600
723
|
clearTimeout(wall)
|
|
601
724
|
if (wallHit && trace.outcome === 'cancelled') { trace.outcome = 'failure'; trace.error = 'wall_timeout'; s.failures += 1; if (s.failures >= L.breakerFailures) s.breaker = true }
|
|
725
|
+
if (trace.outcome === 'cancelled' && abort.signal.reason === V2_OFF) trace.cancelReason = V2_OFF
|
|
602
726
|
s.inFlight = null
|
|
603
727
|
try { lease?.release() } catch { /* released */ }
|
|
604
728
|
trace.totalMs = this.deps.now().getTime() - started
|
|
@@ -612,6 +736,18 @@ export class LiveCuesV2Engine {
|
|
|
612
736
|
}
|
|
613
737
|
|
|
614
738
|
shutdown(): void { for (const s of this.sessions.values()) s.inFlight?.abort(); this.sessions.clear() }
|
|
739
|
+
|
|
740
|
+
/** v2 switched off at runtime (6.76.2): abort every evaluation in flight, so no half-written cue is emitted and every
|
|
741
|
+
* lease is released. Sessions are kept (no new evaluation starts while off), so switching back on resumes a running
|
|
742
|
+
* meeting from its next evaluation. */
|
|
743
|
+
suspend(): number {
|
|
744
|
+
let aborted = 0
|
|
745
|
+
for (const s of this.sessions.values()) if (s.inFlight) { s.inFlight.abort(V2_OFF); aborted += 1 }
|
|
746
|
+
return aborted
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Was this meeting armed under v2 in this process? Its stop and status stay v2 even after v2 is switched off. */
|
|
750
|
+
knows(sessionId: string): boolean { return this.sessions.has(sessionId) }
|
|
615
751
|
}
|
|
616
752
|
|
|
617
753
|
function round(n: number): number { return Math.round(n * 1000) / 1000 }
|
|
@@ -625,12 +761,36 @@ export function liveCuesV2(): LiveCuesV2Engine {
|
|
|
625
761
|
// A saved meeting ends its cues even when the phone never sent Stop (orphan recovery, Control save).
|
|
626
762
|
unsubscribe = onDisplay(event => {
|
|
627
763
|
const sid = event.type === 'recording_stop' ? event.data?.sessionId : null
|
|
628
|
-
|
|
764
|
+
// Also when v2 was switched off mid-meeting: a meeting it armed still ends, so it never lingers as active.
|
|
765
|
+
if (typeof sid === 'string' && sid && (liveCuesV2Enabled() || engine?.knows(sid))) engine?.end(sid)
|
|
629
766
|
})
|
|
630
767
|
}
|
|
631
768
|
return engine
|
|
632
769
|
}
|
|
770
|
+
/** The engine if it exists, without creating one (a v1-only server never builds the v2 clients). */
|
|
771
|
+
export function peekLiveCuesV2(): LiveCuesV2Engine | null { return engine }
|
|
633
772
|
export function liveCuesV2Capability(): LiveCuesV2Capability { return liveCuesV2().capability() }
|
|
773
|
+
|
|
774
|
+
/** GET/POST /api/live-cues/v2/settings (COS Control's toggle). */
|
|
775
|
+
export interface LiveCuesV2SettingsResponse { enabled: boolean; source: LiveCuesV2SettingSource; capability: LiveCuesV2Capability }
|
|
776
|
+
export function liveCuesV2SettingsView(env: NodeJS.ProcessEnv = process.env): LiveCuesV2SettingsResponse {
|
|
777
|
+
const r = resolveLiveCuesV2(env, liveCuesV2Settings())
|
|
778
|
+
return { enabled: r.enabled, source: r.source, capability: liveCuesV2Capability() }
|
|
779
|
+
}
|
|
780
|
+
/** Save the switch. Off while env forces it on is refused and changes nothing. Turning it off ends cue evaluation for running
|
|
781
|
+
* meetings at once; turning it on applies from each meeting's next evaluation. */
|
|
782
|
+
export function setLiveCuesV2Enabled(enabled: boolean, env: NodeJS.ProcessEnv = process.env): { ok: true; view: LiveCuesV2SettingsResponse } | { ok: false; error: 'env_forced' } {
|
|
783
|
+
if (!enabled && liveCuesV2EnvForced(env)) return { ok: false, error: 'env_forced' }
|
|
784
|
+
const wasOn = liveCuesV2Enabled(env)
|
|
785
|
+
liveCuesV2Settings().save(enabled)
|
|
786
|
+
if (wasOn && !liveCuesV2Enabled(env)) {
|
|
787
|
+
const aborted = engine?.suspend() ?? 0
|
|
788
|
+
console.log(`[live-cues-v2] switched off by setting; ${aborted} evaluation(s) stopped`)
|
|
789
|
+
} else if (!wasOn && liveCuesV2Enabled(env)) {
|
|
790
|
+
console.log('[live-cues-v2] switched on by setting')
|
|
791
|
+
}
|
|
792
|
+
return { ok: true, view: liveCuesV2SettingsView(env) }
|
|
793
|
+
}
|
|
634
794
|
/** What /api/health, /api/models and the status route advertise: NOTHING while v2 is switched off, so a 6.10.642
|
|
635
795
|
* companion treats the server exactly like a v1 server (v1 cues keep their own capability and indicator). */
|
|
636
796
|
export function liveCuesV2Advertised(env: NodeJS.ProcessEnv = process.env): LiveCuesV2Capability | undefined {
|
|
@@ -15,6 +15,13 @@ export function scheduleManagedServerRestart(options?: {
|
|
|
15
15
|
kickstart?: (args: string[]) => void
|
|
16
16
|
exitProcess?: (code: number) => void
|
|
17
17
|
onError?: (error: unknown) => void
|
|
18
|
+
/**
|
|
19
|
+
* 6.78.0 (P7 S4): runs right before the kickstart, after the self-restart switch is read.
|
|
20
|
+
* The phone's Restart marks a CLEAN exit here: if kickstart -k does not land a SIGTERM (so
|
|
21
|
+
* gracefulShutdown never runs) the 2 s `exit(1)` below would otherwise read as a crashed
|
|
22
|
+
* boot and count toward crash-loop parking. Never throws upward.
|
|
23
|
+
*/
|
|
24
|
+
beforeRestart?: () => void
|
|
18
25
|
}): void {
|
|
19
26
|
const delayMs = options?.delayMs ?? 350
|
|
20
27
|
const label = (options?.label ?? process.env.COS_LAUNCHD_LABEL?.trim()) || DEFAULT_LAUNCHD_LABEL
|
|
@@ -29,6 +36,7 @@ export function scheduleManagedServerRestart(options?: {
|
|
|
29
36
|
|
|
30
37
|
const timer = setTimeout(() => {
|
|
31
38
|
if (process.env.COS_DISABLE_SELF_RESTART === '1') return
|
|
39
|
+
try { options?.beforeRestart?.() } catch (error) { onError(error) }
|
|
32
40
|
try {
|
|
33
41
|
const uid = userInfo().uid
|
|
34
42
|
kickstart(['kickstart', '-k', `gui/${uid}/${label}`])
|
|
@@ -13,6 +13,7 @@ import { continueFullPermissionsEnabled } from './continue-plan.js'
|
|
|
13
13
|
import { permissionBrokerHealthFields } from './permission-broker.js'
|
|
14
14
|
import { continueLiveEnabled } from './session-peer-inbox-deps.js'
|
|
15
15
|
import { codexLiveQueueEnabled } from './codex-live-queue.js'
|
|
16
|
+
import { sessionSteerEnabled } from './thread-turn-queue.js'
|
|
16
17
|
|
|
17
18
|
export type ProviderName = 'claude' | 'codex' | 'cursor'
|
|
18
19
|
|
|
@@ -33,6 +34,11 @@ export interface ProviderAct {
|
|
|
33
34
|
nativeQueue: ProviderCapability
|
|
34
35
|
keepModel: ProviderCapability
|
|
35
36
|
permissions: ProviderCapability
|
|
37
|
+
/**
|
|
38
|
+
* 6.78.0: can a turn go INTO a busy session's running turn (Sessions steer)? OPTIONAL and
|
|
39
|
+
* ABSENT while `COS_SESSION_STEER` is off, so the eight 6.62 keys stay the whole object.
|
|
40
|
+
*/
|
|
41
|
+
steer?: ProviderCapability
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface ProviderActInput {
|
|
@@ -48,6 +54,8 @@ export interface ProviderActInput {
|
|
|
48
54
|
continueLive: boolean
|
|
49
55
|
/** `COS_CODEX_LIVE_QUEUE` (Codex's app-queue hop, idle and busy). */
|
|
50
56
|
codexLiveQueue: boolean
|
|
57
|
+
/** 6.78.0: `COS_SESSION_STEER`. Absent or false: no `steer` entry at all. */
|
|
58
|
+
sessionSteer?: boolean
|
|
51
59
|
env?: NodeJS.ProcessEnv
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -63,6 +71,10 @@ export function providerActs(input: ProviderActInput): Record<ProviderName, Prov
|
|
|
63
71
|
const fork = (provider: ProviderName, mode?: string) =>
|
|
64
72
|
(input.forkProviders.includes(provider) ? yes(mode) : no('fork_unsupported'))
|
|
65
73
|
const desk = (key: string) => (cancel[key] === true ? yes('next_command') : no('hooks_not_ready'))
|
|
74
|
+
// 6.78.0: Sessions steer. Claude through the peer inbox (it needs the live hop); Codex has
|
|
75
|
+
// `turn/steer` only in an app-server COS does not hold; Cursor's only door is the Stop hook.
|
|
76
|
+
const steer = input.sessionSteer === true
|
|
77
|
+
const claudeSteer = !attach ? no('attach_disabled') : input.continueLive ? yes('peer_inbox') : no('no_outside_channel')
|
|
66
78
|
return {
|
|
67
79
|
claude: {
|
|
68
80
|
continueIdle: continueIdle(input.continueLive ? 'live' : 'resume'),
|
|
@@ -74,6 +86,7 @@ export function providerActs(input: ProviderActInput): Record<ProviderName, Prov
|
|
|
74
86
|
nativeQueue: no('engine_limit'),
|
|
75
87
|
keepModel: yes(),
|
|
76
88
|
permissions: yes('session'),
|
|
89
|
+
...(steer ? { steer: claudeSteer } : {}),
|
|
77
90
|
},
|
|
78
91
|
codex: {
|
|
79
92
|
continueIdle: continueIdle(input.codexLiveQueue ? 'app_queue' : 'resume'),
|
|
@@ -85,6 +98,7 @@ export function providerActs(input: ProviderActInput): Record<ProviderName, Prov
|
|
|
85
98
|
nativeQueue: yes(),
|
|
86
99
|
keepModel: full ? yes() : no('full_permissions_off'),
|
|
87
100
|
permissions: full ? yes('session_posture') : no('full_permissions_off'),
|
|
101
|
+
...(steer ? { steer: no('no_outside_channel') } : {}),
|
|
88
102
|
},
|
|
89
103
|
cursor: {
|
|
90
104
|
// An idle IDE composer cannot take a message from outside; a CLI chat can.
|
|
@@ -97,6 +111,7 @@ export function providerActs(input: ProviderActInput): Record<ProviderName, Prov
|
|
|
97
111
|
nativeQueue: no('engine_limit'),
|
|
98
112
|
keepModel: full ? yes() : no('full_permissions_off'),
|
|
99
113
|
permissions: full ? yes('run_everything') : no('full_permissions_off'),
|
|
114
|
+
...(steer ? { steer: no('engine_limit') } : {}),
|
|
100
115
|
},
|
|
101
116
|
}
|
|
102
117
|
}
|
|
@@ -145,5 +160,8 @@ export function liveProviderActInput(base: {
|
|
|
145
160
|
brokerEnabled,
|
|
146
161
|
continueLive: continueLiveEnabled(),
|
|
147
162
|
codexLiveQueue: codexLiveQueueEnabled(),
|
|
163
|
+
// 6.78.0: present (and true) only while COS_SESSION_STEER is on, so the input object the
|
|
164
|
+
// 6.62 tests build is unchanged with the flag off.
|
|
165
|
+
...(sessionSteerEnabled() ? { sessionSteer: true } : {}),
|
|
148
166
|
}
|
|
149
167
|
}
|