@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.
Files changed (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -0,0 +1,237 @@
1
+ // Transcript cleanup routes (6.77.0). Token-authenticated by the global /api X-Cos-Token
2
+ // middleware (index.ts) like every other /api route; nothing here is public.
3
+ //
4
+ // Settings (COS Control's toggle, later):
5
+ // GET /transcript-cleanup/settings { enabled, source, model, modelSource, limits, today }
6
+ // POST /transcript-cleanup/settings { enabled?, model? } same shape; 409 env_forced when env pins the value
7
+ // Screen context (COS Control supplies on-screen text during a meeting, later):
8
+ // POST /meeting/:sessionId/screen-context { lines: [{ at, text, app? }] } counts of what was stored
9
+ // GET /meeting/:sessionId/screen-context counts only, never the text
10
+ // DELETE /meeting/:sessionId/screen-context { deleted }
11
+ // The cleanup ledger of one meeting:
12
+ // GET /meeting/:sessionId/transcript-cleanup { runs, edits } (edits carry original and replacement words)
13
+ // POST /meeting/:sessionId/transcript-cleanup/undo { editId } | { runId }
14
+ // Dictionary suggestions (COS Control's Dictionary tab, later):
15
+ // GET /transcript-cleanup/suggestions?status=pending|all
16
+ // POST /transcript-cleanup/suggestions/:id/accept { correction? }
17
+ // POST /transcript-cleanup/suggestions/:id/dismiss
18
+
19
+ import { Router } from 'express'
20
+ import { appendScreenContext, deleteScreenContext, ScreenContextError, SESSION_ID, screenContextStats } from '../lib/transcript-cleanup-screen.js'
21
+ import { alreadyCleaned, ledgerView, readLedger, readTranscriptSection, sha256, undoCleanupEdits } from '../lib/transcript-cleanup-ledger.js'
22
+ import { cleanupLimits, resolveCleanupModel, resolveCleanupSwitch, transcriptCleanupBudget, transcriptCleanupSettings, type TranscriptCleanupBudget, type TranscriptCleanupSettings } from '../lib/transcript-cleanup-state.js'
23
+ import { SUGGESTION_ID, suggestionStore, type DictionarySuggestion, type SuggestionStore } from '../lib/transcript-cleanup-suggestions.js'
24
+ import { getVocabulary } from '../lib/profile.js'
25
+ import { readFileSync } from 'node:fs'
26
+
27
+ export const TRANSCRIPT_CLEANUP_PROTOCOL = 1
28
+
29
+ type GlossaryOutcome = { status: number; body: unknown }
30
+
31
+ export interface TranscriptCleanupRouteDeps {
32
+ env?: NodeJS.ProcessEnv
33
+ now?: () => Date
34
+ settings?: TranscriptCleanupSettings
35
+ budget?: TranscriptCleanupBudget
36
+ suggestions?: SuggestionStore
37
+ /** 'active' while the meeting is being recorded. */
38
+ sessionState?: (sessionId: string) => 'active' | 'closed' | 'missing'
39
+ sessionStartedAt?: (sessionId: string) => number | null
40
+ /** The saved meeting's files, or null. */
41
+ findMeeting?: (sessionId: string) => { meetingPath: string; sidecarPath: string } | null
42
+ addVocabulary?: (term: string) => Promise<GlossaryOutcome>
43
+ learnCorrection?: (body: { heard: string; written: string; source: 'meeting' }) => Promise<GlossaryOutcome>
44
+ vocabulary?: () => string[]
45
+ }
46
+
47
+ /** Health and capability shape. Codes only: /api/health is public. */
48
+ export function transcriptCleanupAdvertised(env: NodeJS.ProcessEnv = process.env, settings: TranscriptCleanupSettings = transcriptCleanupSettings()): { protocol: number; enabled: boolean } {
49
+ let enabled = false
50
+ try { enabled = resolveCleanupSwitch(env, settings).enabled } catch { enabled = false }
51
+ return { protocol: TRANSCRIPT_CLEANUP_PROTOCOL, enabled }
52
+ }
53
+
54
+ function suggestionView(s: DictionarySuggestion, vocabulary: string[]): DictionarySuggestion & { inVocabulary: boolean } {
55
+ const lower = s.written.trim().toLowerCase()
56
+ return { ...s, inVocabulary: vocabulary.some(v => v.trim().toLowerCase() === lower) }
57
+ }
58
+
59
+ export function createTranscriptCleanupRouter(deps: TranscriptCleanupRouteDeps = {}): Router {
60
+ const env = deps.env ?? process.env
61
+ const now = deps.now ?? (() => new Date())
62
+ const settings = deps.settings ?? transcriptCleanupSettings()
63
+ const budget = deps.budget ?? transcriptCleanupBudget()
64
+ const suggestions = deps.suggestions ?? suggestionStore()
65
+ const sessionState = deps.sessionState ?? (() => 'missing' as const)
66
+ const sessionStartedAt = deps.sessionStartedAt ?? (() => null)
67
+ const findMeeting = deps.findMeeting ?? (() => null)
68
+ const vocabulary = deps.vocabulary ?? (() => { try { return getVocabulary() } catch { return [] } })
69
+ const addVocabulary = deps.addVocabulary ?? (async term => (await import('./glossary.js')).glossaryAddVocabulary(term))
70
+ const learnCorrection = deps.learnCorrection ?? (async body => (await import('./glossary.js')).glossaryLearnCorrection(body))
71
+ const router = Router()
72
+
73
+ const settingsView = () => {
74
+ const sw = resolveCleanupSwitch(env, settings)
75
+ const model = resolveCleanupModel(env, settings)
76
+ const limits = cleanupLimits(env)
77
+ const state = budget.read()
78
+ return {
79
+ protocol: TRANSCRIPT_CLEANUP_PROTOCOL,
80
+ enabled: sw.enabled,
81
+ source: sw.source,
82
+ model: model.choice,
83
+ modelId: model.id,
84
+ modelSource: model.source,
85
+ limits: { dailyCalls: limits.dailyCalls, dailyTokens: limits.dailyTokens, maxSpansPerMeeting: limits.maxSpans },
86
+ today: {
87
+ calls: Math.min(state.calls, Number.MAX_SAFE_INTEGER),
88
+ tokens: Math.min(state.tokens, Number.MAX_SAFE_INTEGER),
89
+ breakerOpenUntil: state.breakerOpenUntil !== null && state.breakerOpenUntil > now().getTime() ? new Date(state.breakerOpenUntil).toISOString() : null,
90
+ },
91
+ }
92
+ }
93
+
94
+ router.get('/transcript-cleanup/settings', (_req, res) => {
95
+ res.set('Cache-Control', 'private, no-store')
96
+ try { return res.json(settingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
97
+ })
98
+
99
+ router.post('/transcript-cleanup/settings', (req, res) => {
100
+ res.set('Cache-Control', 'private, no-store')
101
+ const body = req.body as { enabled?: unknown; model?: unknown } | undefined
102
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return res.status(400).json({ error: 'invalid_request' })
103
+ const hasEnabled = body.enabled !== undefined, hasModel = body.model !== undefined
104
+ if ((!hasEnabled && !hasModel) || (hasEnabled && typeof body.enabled !== 'boolean') || (hasModel && body.model !== 'haiku' && body.model !== 'sonnet')) {
105
+ return res.status(400).json({ error: 'invalid_request' })
106
+ }
107
+ if (hasEnabled && (env.COS_TRANSCRIPT_CLEANUP === '1' || env.COS_TRANSCRIPT_CLEANUP === '0') && body.enabled !== (env.COS_TRANSCRIPT_CLEANUP === '1')) {
108
+ return res.status(409).json({ error: 'env_forced' })
109
+ }
110
+ if (hasModel && (env.COS_TRANSCRIPT_CLEANUP_MODEL === 'haiku' || env.COS_TRANSCRIPT_CLEANUP_MODEL === 'sonnet') && body.model !== env.COS_TRANSCRIPT_CLEANUP_MODEL) {
111
+ return res.status(409).json({ error: 'env_forced' })
112
+ }
113
+ try {
114
+ settings.save({ ...(hasEnabled ? { enabled: body.enabled as boolean } : {}), ...(hasModel ? { model: body.model as 'haiku' | 'sonnet' } : {}) }, now())
115
+ return res.json(settingsView())
116
+ } catch {
117
+ return res.status(500).json({ error: 'settings_write_failed' })
118
+ }
119
+ })
120
+
121
+ const sessionParam = (raw: string | undefined): string | null => (raw && SESSION_ID.test(raw) ? raw : null)
122
+
123
+ /** A late flush right after stop is welcome until the cleanup has run on the saved meeting. */
124
+ function screenAdmission(sessionId: string): { ok: true } | { ok: false; status: number; error: string } {
125
+ if (sessionState(sessionId) === 'active') return { ok: true }
126
+ const meeting = findMeeting(sessionId)
127
+ if (!meeting) return { ok: false, status: 404, error: 'unknown_session' }
128
+ const done = readLedger(sessionId).rows.some(r => r.kind === 'run' && (r.phase === 'done' || r.phase === 'skipped'))
129
+ return done ? { ok: false, status: 409, error: 'cleanup_done' } : { ok: true }
130
+ }
131
+
132
+ router.post('/meeting/:sessionId/screen-context', (req, res) => {
133
+ res.set('Cache-Control', 'private, no-store')
134
+ const sessionId = sessionParam(req.params.sessionId)
135
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
136
+ const admission = screenAdmission(sessionId)
137
+ if (!admission.ok) return res.status(admission.status).json({ error: admission.error })
138
+ try {
139
+ const result = appendScreenContext(sessionId, req.body, { now: now().getTime(), sessionStartedAt: sessionStartedAt(sessionId) })
140
+ return res.json({ ok: true, protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...result })
141
+ } catch (error) {
142
+ if (error instanceof ScreenContextError) return res.status(error.status).json({ error: error.code })
143
+ return res.status(503).json({ error: 'screen_context_unavailable' })
144
+ }
145
+ })
146
+
147
+ router.get('/meeting/:sessionId/screen-context', (req, res) => {
148
+ res.set('Cache-Control', 'private, no-store')
149
+ const sessionId = sessionParam(req.params.sessionId)
150
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
151
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...screenContextStats(sessionId) })
152
+ })
153
+
154
+ router.delete('/meeting/:sessionId/screen-context', (req, res) => {
155
+ res.set('Cache-Control', 'private, no-store')
156
+ const sessionId = sessionParam(req.params.sessionId)
157
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
158
+ try { return res.json({ deleted: deleteScreenContext(sessionId) }) } catch { return res.status(500).json({ error: 'delete_failed' }) }
159
+ })
160
+
161
+ router.get('/meeting/:sessionId/transcript-cleanup', (req, res) => {
162
+ res.set('Cache-Control', 'private, no-store')
163
+ const sessionId = sessionParam(req.params.sessionId)
164
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
165
+ const view = ledgerView(sessionId)
166
+ let current: string | null = null
167
+ const meeting = findMeeting(sessionId)
168
+ if (meeting) {
169
+ try { const section = readTranscriptSection(readFileSync(meeting.meetingPath, 'utf8')); current = section === null ? null : sha256(section) } catch { current = null }
170
+ }
171
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, ...view, cleanedCurrent: current !== null && alreadyCleaned(sessionId, current) })
172
+ })
173
+
174
+ router.post('/meeting/:sessionId/transcript-cleanup/undo', (req, res) => {
175
+ res.set('Cache-Control', 'private, no-store')
176
+ const sessionId = sessionParam(req.params.sessionId)
177
+ if (!sessionId) return res.status(400).json({ error: 'invalid_session_id' })
178
+ const body = (req.body ?? {}) as { editId?: unknown; runId?: unknown }
179
+ const editId = typeof body.editId === 'string' && /^te_[0-9a-f]{16}$/.test(body.editId) ? body.editId : undefined
180
+ const runId = typeof body.runId === 'string' && /^tc_[0-9a-f]{16}$/.test(body.runId) ? body.runId : undefined
181
+ if (!editId === !runId) return res.status(400).json({ error: 'editId_or_runId_required' })
182
+ const meeting = findMeeting(sessionId)
183
+ if (!meeting) return res.status(404).json({ error: 'meeting_missing' })
184
+ const result = undoCleanupEdits({ sessionId, meetingPath: meeting.meetingPath, sidecarPath: meeting.sidecarPath, editId, runId, now })
185
+ if (!result.ok) return res.status(result.status).json({ error: result.error })
186
+ return res.json({ reverted: result.reverted })
187
+ })
188
+
189
+ router.get('/transcript-cleanup/suggestions', (req, res) => {
190
+ res.set('Cache-Control', 'private, no-store')
191
+ const status = typeof req.query.status === 'string' ? req.query.status : 'pending'
192
+ if (status !== 'pending' && status !== 'all') return res.status(400).json({ error: 'invalid_status' })
193
+ const vocab = vocabulary()
194
+ const list = suggestions.list().filter(s => status === 'all' || s.status === 'pending')
195
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).map(s => suggestionView(s, vocab))
196
+ return res.json({ protocol: TRANSCRIPT_CLEANUP_PROTOCOL, suggestions: list })
197
+ })
198
+
199
+ router.post('/transcript-cleanup/suggestions/:id/accept', async (req, res) => {
200
+ res.set('Cache-Control', 'private, no-store')
201
+ const id = req.params.id
202
+ if (!SUGGESTION_ID.test(id)) return res.status(400).json({ error: 'invalid_suggestion_id' })
203
+ const body = (req.body ?? {}) as { correction?: unknown }
204
+ if (body.correction !== undefined && typeof body.correction !== 'boolean') return res.status(400).json({ error: 'invalid_request' })
205
+ const suggestion = suggestions.get(id)
206
+ if (!suggestion) return res.status(404).json({ error: 'suggestion_not_found' })
207
+ if (suggestion.status === 'dismissed') return res.status(409).json({ error: 'suggestion_dismissed' })
208
+ try {
209
+ const heard = suggestion.heard.find(h => h.trim().toLowerCase() !== suggestion.written.trim().toLowerCase())
210
+ const outcome = body.correction === true && heard
211
+ ? await learnCorrection({ heard, written: suggestion.written, source: 'meeting' })
212
+ : await addVocabulary(suggestion.written)
213
+ if (outcome.status !== 200) return res.status(outcome.status).json(outcome.body)
214
+ const accepted = suggestions.setStatus(id, 'accepted', now().toISOString()) ?? suggestion
215
+ const out = outcome.body as { unchanged?: boolean; glossary?: unknown; entry?: unknown }
216
+ return res.json({ suggestion: suggestionView(accepted, vocabulary()), unchanged: out.unchanged === true, glossary: out.glossary ?? null, entry: out.entry ?? null })
217
+ } catch {
218
+ return res.status(500).json({ error: 'accept_failed' })
219
+ }
220
+ })
221
+
222
+ router.post('/transcript-cleanup/suggestions/:id/dismiss', (req, res) => {
223
+ res.set('Cache-Control', 'private, no-store')
224
+ const id = req.params.id
225
+ if (!SUGGESTION_ID.test(id)) return res.status(400).json({ error: 'invalid_suggestion_id' })
226
+ try {
227
+ const dismissed = suggestions.setStatus(id, 'dismissed', now().toISOString())
228
+ if (!dismissed) return res.status(404).json({ error: 'suggestion_not_found' })
229
+ return res.json({ suggestion: suggestionView(dismissed, vocabulary()) })
230
+ } catch {
231
+ return res.status(500).json({ error: 'dismiss_failed' })
232
+ }
233
+ })
234
+
235
+ return router
236
+ }
237
+
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate).
2
+ * Live cues v2 wire contract (server 6.75 candidate, companion 6.10.641 candidate; optional capability fields since 6.76.2).
3
3
  * Additive to v1: old clients keep receiving `coaching_nudge` and ignore the new named event.
4
4
  * The companion keeps a byte-identical copy at src/lib/live-cues-v2-contract.ts.
5
5
  */
@@ -33,13 +33,18 @@ export interface LiveCueV2 {
33
33
  /** v1 `coaching_nudge` as the v2 engine emits it for old clients (headline only). */
34
34
  export interface LiveCueV1Compat { nudge: string; type: CueKind; priority: 2; cueId: string; sessionId: string; protocol: 2 }
35
35
 
36
- /** `reason` when unavailable: master_flag_off, api_key_in_env, jev_not_configured, claude_not_found, budget_exhausted,
37
- * jev_cap_reached, jev_breaker_open. */
38
- export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string }
36
+ /** `reason` when unavailable: master_flag_off, api_key_in_env, decision_invalid_provider, decision_not_configured (no key
37
+ * for the selected decision provider; jev_not_configured when Jev was chosen and only a SubQ key exists), claude_not_found,
38
+ * budget_exhausted, jev_cap_reached, jev_breaker_open, decision_cap_reached, decision_breaker_open (the last two for SubQ).
39
+ * Server 6.76.2 adds two optional fields; older servers omit them. `memory`: where past notes come from (cue_memory.py,
40
+ * the owner's own COS notes and meetings, or nothing). `decisionProvider`: which classifier runs the gate and the judge. */
41
+ export type LiveCuesV2MemorySource = 'cue_memory' | 'notes' | 'none'
42
+ export interface LiveCuesV2Capability { available: boolean; reason?: string; protocol: 2; model: string; memory?: LiveCuesV2MemorySource; decisionProvider?: 'jev' | 'subq' }
39
43
 
40
44
  /** Why cues are not coming for this meeting. `active` means evaluations are running normally (silence is a choice). */
41
45
  export type LiveCuesV2SessionState = 'active' | 'paused' | 'ended' | 'stopped'
42
46
  export type LiveCuesV2StopReason = 'breaker_open' | 'meeting_cap' | 'budget_exhausted' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_not_configured'
47
+ | 'decision_cap_reached' | 'decision_breaker_open' | 'decision_not_configured' | 'decision_invalid_provider'
43
48
 
44
49
  export interface LiveCuesV2SessionStatus {
45
50
  sessionId: string