@gotcos/glasses-server 6.16.1 → 6.16.3

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 CHANGED
@@ -95,6 +95,13 @@ BIND_HOST=0.0.0.0
95
95
  # Power users running the COS Starter Kit can point the glasses at their
96
96
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
97
97
  # COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
98
+ #
99
+ # G2 "Review Meetings" reads COS meeting markdown from an operations tree:
100
+ # {COS_OPERATIONS_DIR}/{quilt|personal|…}/meetings/YYYY-MM/*.md
101
+ # Prefer an explicit ops root (each COS layout can differ). If unset, the
102
+ # server falls back to COS_SCRIPTS_DIR/.. then to local G2 recordings only.
103
+ # COS_OPERATIONS_DIR=/path/to/your/cos/operations
104
+ # COS_MEETINGS_ROOT=/path/to/your/cos/operations # alias for COS_OPERATIONS_DIR
98
105
 
99
106
  # Telegram session/activity notifications remain OFF even if the COS scripts
100
107
  # directory contains .telegram_config.json. Enable export explicitly:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 6.16.3
2
+
3
+ - **Cursor Agent mode on durable jobs.** Glasses Settings → Agent now reaches
4
+ the CLI (`--force` / no `--mode ask`). Public 6.16.1–6.16.2 accepted Cursor
5
+ models but dropped `cursorExecutionMode` before query-job execution, so every
6
+ turn stayed Ask and Shell was Rejected. Parse + forward `agent`|`ask`; omit
7
+ still defaults to ask for old clients.
8
+
9
+ ## 6.16.2
10
+
11
+ - **COS operations meetings library.** G2 Review Meetings can list markdown from
12
+ a configurable COS `operations/` tree via `COS_OPERATIONS_DIR` /
13
+ `COS_MEETINGS_ROOT`, with fallback to `COS_SCRIPTS_DIR/..`, then standalone
14
+ recordings. Opt-in per install — no hardcoded COS layout.
15
+
1
16
  ## 6.16.1
2
17
 
3
18
  - **Cursor Agent models (Composer 2.5 / Grok 4.5).** Managed installs now ship
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.16.1",
3
+ "version": "6.16.3",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Optional COS operations meeting library.
3
+ *
4
+ * When configured, G2 "Review Meetings" reads markdown from a COS-style tree:
5
+ * {operationsDir}/{domain}/meetings/YYYY-MM/*.md
6
+ *
7
+ * Resolution order:
8
+ * 1. COS_OPERATIONS_DIR — explicit operations/ root (preferred)
9
+ * 2. COS_MEETINGS_ROOT — alias for the same path
10
+ * 3. COS_SCRIPTS_DIR/.. — classic Starter Kit layout (operations/scripts → operations)
11
+ *
12
+ * Standalone installs leave all of these unset and keep using MeetingStore
13
+ * (~/.cos-glasses/data/recordings).
14
+ */
15
+
16
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
17
+ import { basename, join, resolve } from 'node:path'
18
+ import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
19
+ import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
20
+
21
+ export const COS_MEETING_DOMAINS = ['quilt', 'sprocket_rocket', 'hermit_crabs', 'personal'] as const
22
+
23
+ const DOMAIN_ABBR: Record<string, string> = {
24
+ quilt: 'Q',
25
+ sprocket_rocket: 'SR',
26
+ hermit_crabs: 'HC',
27
+ personal: 'P',
28
+ }
29
+
30
+ const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
31
+
32
+ export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
33
+
34
+ function envPath(name: string): string | null {
35
+ const raw = process.env[name]?.trim()
36
+ if (!raw) return null
37
+ return resolve(raw)
38
+ }
39
+
40
+ /** Resolve the COS operations directory, or null in standalone mode.
41
+ * Reads process.env on each call so tests and Control env updates stay live. */
42
+ export function resolveCosOperationsDir(): string | null {
43
+ const explicit = envPath('COS_OPERATIONS_DIR') || envPath('COS_MEETINGS_ROOT')
44
+ if (explicit && existsSync(explicit)) return explicit
45
+ const scriptsDir = envPath('COS_SCRIPTS_DIR')
46
+ if (scriptsDir) {
47
+ const inferred = resolve(scriptsDir, '..')
48
+ if (existsSync(inferred)) return inferred
49
+ }
50
+ return null
51
+ }
52
+
53
+ export function cosOperationsMeetingsConfigured(): boolean {
54
+ return resolveCosOperationsDir() != null
55
+ }
56
+
57
+ function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
58
+ const bytes = Buffer.from(content, 'utf8')
59
+ if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
60
+ let end = MEETING_SOURCE_MAX_BYTES
61
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1
62
+ return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
63
+ }
64
+
65
+ function parseDurationMinutes(duration: string): number | undefined {
66
+ if (!duration) return undefined
67
+ const normalized = duration.toLowerCase()
68
+ const colonMatch = normalized.match(/\b(\d+):(\d{2})\b/)
69
+ if (colonMatch) return Number(colonMatch[1]) * 60 + Number(colonMatch[2])
70
+
71
+ let total = 0
72
+ const hourMatch = normalized.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
73
+ if (hourMatch) total += Math.round(Number(hourMatch[1]) * 60)
74
+
75
+ const minuteMatch = normalized.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
76
+ if (minuteMatch) total += Number(minuteMatch[1])
77
+
78
+ if (total > 0) return total
79
+
80
+ const bareNumber = normalized.match(/^\s*(\d+)\s*$/)
81
+ return bareNumber ? Number(bareNumber[1]) : undefined
82
+ }
83
+
84
+ function normalizeMeetingTime(hourText: string, minuteText: string, meridiemText = ''): string | undefined {
85
+ let hour = Number(hourText)
86
+ const minute = Number(minuteText)
87
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) || minute < 0 || minute > 59) return undefined
88
+ const meridiem = meridiemText.trim().toUpperCase()
89
+ if (meridiem) {
90
+ if (hour < 1 || hour > 12 || !['AM', 'PM'].includes(meridiem)) return undefined
91
+ if (meridiem === 'AM' && hour === 12) hour = 0
92
+ if (meridiem === 'PM' && hour !== 12) hour += 12
93
+ } else if (hour < 0 || hour > 23) {
94
+ return undefined
95
+ }
96
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
97
+ }
98
+
99
+ export function extractMeetingDateTime(content: string, filename: string): { date: string; time?: string } {
100
+ const metadata = content.match(/\*\*Date(?::)?\*\*\s*(?:\|\s*)?((?:19|20)\d{2}-\d{2}-\d{2})(?:[ T]+(\d{1,2}):(\d{2})(?:\s*([AP]M))?)?/i)
101
+ if (metadata) {
102
+ const time = metadata[2] && metadata[3]
103
+ ? normalizeMeetingTime(metadata[2], metadata[3], metadata[4])
104
+ : undefined
105
+ return { date: metadata[1], ...(time ? { time } : {}) }
106
+ }
107
+
108
+ const g2 = filename.match(/_((?:19|20)\d{2}-\d{2}-\d{2})_(\d{2})(\d{2})(?:\D|$)/)
109
+ if (g2) {
110
+ const time = normalizeMeetingTime(g2[2], g2[3])
111
+ return { date: g2[1], ...(time ? { time } : {}) }
112
+ }
113
+
114
+ const dateMatch = filename.match(/^((?:19|20)\d{2}-\d{2}-\d{2})/)
115
+ return { date: dateMatch ? dateMatch[1] : 'unknown' }
116
+ }
117
+
118
+ export function compareMeetingsNewestFirst(a: CosOperationsMeetingMeta, b: CosOperationsMeetingMeta): number {
119
+ const aKey = `${a.date || '0000-00-00'}T${a.time || '00:00'}`
120
+ const bKey = `${b.date || '0000-00-00'}T${b.time || '00:00'}`
121
+ return bKey.localeCompare(aKey)
122
+ || b.filename.localeCompare(a.filename)
123
+ || b.domain.localeCompare(a.domain)
124
+ }
125
+
126
+ export function matchRenamedMeetingFilename(
127
+ requestedFilename: string,
128
+ candidates: Array<{ filename: string; content: string }>,
129
+ ): string | undefined {
130
+ const requested = extractMeetingDateTime('', requestedFilename)
131
+ if (requested.date === 'unknown' || !requested.time) return undefined
132
+ const matches = candidates.filter(candidate => {
133
+ const timestamp = extractMeetingDateTime(candidate.content, candidate.filename)
134
+ return timestamp.date === requested.date && timestamp.time === requested.time
135
+ })
136
+ return matches.length === 1 ? matches[0].filename : undefined
137
+ }
138
+
139
+ function parseMeetingMeta(content: string, filename: string, domain: string): CosOperationsMeetingMeta {
140
+ const name = basename(filename, '.md')
141
+ const titleMatch = content.match(/^#\s+(.+)$/m)
142
+ let title: string
143
+ if (titleMatch) {
144
+ title = titleMatch[1].trim()
145
+ } else {
146
+ const parts = name.split('_').slice(1)
147
+ title = parts.join(' ').slice(0, 50)
148
+ }
149
+
150
+ const { date, time } = extractMeetingDateTime(content, filename)
151
+ const sourceMatch = content.match(/\*\*Source\*\*\s*\|\s*(.+)/i)
152
+ || content.match(/\*\*Source:\*\*\s*(.+)/i)
153
+ const source = sourceMatch ? sourceMatch[1].replace(/\s*\|?\s*$/, '').trim() : ''
154
+
155
+ const durationMatch = content.match(/\*\*Duration\*\*\s*\|\s*(.+)/i)
156
+ || content.match(/\*\*Duration:\*\*\s*(.+)/i)
157
+ const duration = durationMatch ? durationMatch[1].replace(/\s*\|?\s*$/, '').trim() : ''
158
+ const durationMinutes = parseDurationMinutes(duration)
159
+
160
+ return {
161
+ filename,
162
+ title: title || 'Untitled Meeting',
163
+ date,
164
+ ...(time ? { time } : {}),
165
+ domain,
166
+ domainAbbr: DOMAIN_ABBR[domain] || '?',
167
+ source,
168
+ duration,
169
+ durationMinutes,
170
+ month: '',
171
+ }
172
+ }
173
+
174
+ function extractSummary(content: string): string {
175
+ const summaryMatch = content.match(/## Summary\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
176
+ if (summaryMatch) return summaryMatch[1].trim().slice(0, 2000)
177
+ return ''
178
+ }
179
+
180
+ function extractTopics(content: string): string[] {
181
+ const topicsMatch = content.match(/## Topics Discussed\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
182
+ if (!topicsMatch) return []
183
+ return topicsMatch[1]
184
+ .split('\n')
185
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
186
+ .map(l => l.replace(/^[-*]\s*/, '').trim())
187
+ .filter(Boolean)
188
+ .slice(0, 10)
189
+ }
190
+
191
+ function extractActionItems(content: string): Array<{ task: string; owner: string }> {
192
+ const actionsMatch = content.match(/## (?:Action Items|Tasks|Next Steps)\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
193
+ if (!actionsMatch) return []
194
+ return actionsMatch[1]
195
+ .split('\n')
196
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || l.trim().match(/^\[/))
197
+ .map(l => {
198
+ const cleaned = l.replace(/^[-*]\s*/, '').replace(/^\[.\]\s*/, '').replace(/`\[REVIEW\]`\s*/i, '').trim()
199
+ const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
200
+ const owner = ownerMatch ? ownerMatch[1] : ''
201
+ const task = ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned
202
+ return { task, owner }
203
+ })
204
+ .filter(i => i.task.length > 0)
205
+ .slice(0, 15)
206
+ }
207
+
208
+ function extractDecisions(content: string): string[] {
209
+ const match = content.match(/## Decisions(?: Made)?\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
210
+ if (!match) return []
211
+ return match[1]
212
+ .split('\n')
213
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
214
+ .map(l => l.replace(/^[-*]\s*/, '').trim())
215
+ .filter(Boolean)
216
+ .slice(0, 10)
217
+ }
218
+
219
+ function extractAttendees(content: string): string[] {
220
+ const match = content.match(/## Attendees\s*\n([\s\S]*?)(?=\n##|\n$|$)/)
221
+ if (!match) return []
222
+ return match[1]
223
+ .split('\n')
224
+ .filter(l => l.trim().startsWith('-') || l.trim().startsWith('*'))
225
+ .map(l => {
226
+ const cleaned = l.replace(/^[-*]\s*/, '').trim()
227
+ const nameMatch = cleaned.match(/^\*\*(.+?)\*\*/) || cleaned.match(/^([^(]+)/)
228
+ return nameMatch ? nameMatch[1].trim() : cleaned
229
+ })
230
+ .filter(Boolean)
231
+ .slice(0, 20)
232
+ }
233
+
234
+ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string): CosOperationsMeetingMeta {
235
+ const summary = extractSummary(content)
236
+ const topics = extractTopics(content)
237
+ const decisions = extractDecisions(content)
238
+ const actionItems = extractActionItems(content)
239
+ const attendees = extractAttendees(content)
240
+ const header = `${meta.title}\n${meta.date}${meta.time ? ` ${meta.time}` : ''} • ${meta.domainAbbr} • ${meta.duration || meta.source}\n\n`
241
+ const detailCharEstimate = [
242
+ header,
243
+ summary,
244
+ topics.join('\n'),
245
+ decisions.join('\n'),
246
+ actionItems.map(a => `${a.owner ? `[${a.owner}] ` : ''}${a.task}`).join('\n'),
247
+ attendees.join(', '),
248
+ ].join('\n\n').trim().length
249
+
250
+ return {
251
+ ...meta,
252
+ detailCharEstimate,
253
+ estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
254
+ topicCount: topics.length,
255
+ decisionCount: decisions.length,
256
+ actionCount: actionItems.length,
257
+ attendeeCount: attendees.length,
258
+ }
259
+ }
260
+
261
+ export function listCosOperationsMeetings(options: {
262
+ limit?: number
263
+ domain?: string
264
+ } = {}): CosOperationsMeetingMeta[] {
265
+ const operationsDir = resolveCosOperationsDir()
266
+ if (!operationsDir) return []
267
+
268
+ const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
269
+ const domainFilter = options.domain || 'all'
270
+ const domains = domainFilter === 'all'
271
+ ? [...COS_MEETING_DOMAINS]
272
+ : COS_MEETING_DOMAINS.includes(domainFilter as typeof COS_MEETING_DOMAINS[number])
273
+ ? [domainFilter]
274
+ : []
275
+
276
+ const allMeetings: CosOperationsMeetingMeta[] = []
277
+
278
+ for (const domain of domains) {
279
+ const meetingsBase = join(operationsDir, domain, 'meetings')
280
+ try {
281
+ const months = readdirSync(meetingsBase)
282
+ .filter(d => /^\d{4}-\d{2}$/.test(d))
283
+ .sort()
284
+ .reverse()
285
+ .slice(0, 3)
286
+
287
+ for (const month of months) {
288
+ const monthDir = join(meetingsBase, month)
289
+ try {
290
+ const files = readdirSync(monthDir)
291
+ .filter(f => f.endsWith('.md'))
292
+ .sort()
293
+ .reverse()
294
+
295
+ for (const file of files) {
296
+ try {
297
+ const filepath = join(monthDir, file)
298
+ const content = readFileSync(filepath, 'utf-8')
299
+ const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
300
+ meta.month = month
301
+ allMeetings.push(meta)
302
+ } catch { /* skip unreadable files */ }
303
+ }
304
+ } catch { /* skip unreadable months */ }
305
+ }
306
+ } catch { /* domain has no meetings dir */ }
307
+ }
308
+
309
+ allMeetings.sort(compareMeetingsNewestFirst)
310
+ return allMeetings.slice(0, limit)
311
+ }
312
+
313
+ export function getCosOperationsMeetingDetail(
314
+ domain: string,
315
+ month: string,
316
+ filename: string,
317
+ ): MeetingDetail | null {
318
+ const operationsDir = resolveCosOperationsDir()
319
+ if (!operationsDir) return null
320
+
321
+ if (!(COS_MEETING_DOMAINS as readonly string[]).includes(domain)) return null
322
+ if (!/^\d{4}-\d{2}$/.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) {
323
+ return null
324
+ }
325
+
326
+ const monthDir = join(operationsDir, domain, 'meetings', month)
327
+ let resolvedFilename = filename
328
+ let filepath = join(monthDir, resolvedFilename)
329
+
330
+ try {
331
+ statSync(filepath)
332
+ } catch {
333
+ let candidates: Array<{ filename: string; content: string }> = []
334
+ try {
335
+ candidates = readdirSync(monthDir)
336
+ .filter(candidate => candidate.endsWith('.md'))
337
+ .map(candidate => ({
338
+ filename: candidate,
339
+ content: readFileSync(join(monthDir, candidate), 'utf8').slice(0, 4000),
340
+ }))
341
+ } catch {
342
+ return null
343
+ }
344
+ const renamed = matchRenamedMeetingFilename(filename, candidates)
345
+ if (!renamed) return null
346
+ resolvedFilename = renamed
347
+ filepath = join(monthDir, resolvedFilename)
348
+ }
349
+
350
+ const content = readFileSync(filepath, 'utf-8')
351
+ const meta = parseMeetingMeta(content, resolvedFilename, domain)
352
+ meta.month = month
353
+ const source = boundedMeetingSource(content)
354
+
355
+ return {
356
+ ...meta,
357
+ summary: extractSummary(content),
358
+ topics: extractTopics(content),
359
+ decisions: extractDecisions(content),
360
+ actionItems: extractActionItems(content),
361
+ attendees: extractAttendees(content),
362
+ transcript: content,
363
+ ...source,
364
+ }
365
+ }
@@ -238,6 +238,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
238
238
  {
239
239
  abortSignal: signal,
240
240
  effort: validEffort,
241
+ // Companion sends agent|ask; omit/unknown → ask (safe default for old clients).
242
+ cursorExecutionMode: request.cursorExecutionMode === 'agent' ? 'agent' : 'ask',
241
243
  clientJobId: request.clientJobId,
242
244
  generation: request.generation,
243
245
  sessionLockHeld: true,
@@ -455,6 +455,7 @@ export class QueryJobStore {
455
455
  sessionId: request.sessionId,
456
456
  ...(request.model ? { requestedModel: request.model } : {}),
457
457
  ...(request.effort ? { effort: request.effort } : {}),
458
+ ...(request.cursorExecutionMode ? { cursorExecutionMode: request.cursorExecutionMode } : {}),
458
459
  ...(request.messageEra ? { messageEra: request.messageEra } : {}),
459
460
  ...(request.globalMsgNum ? { globalMsgNum: request.globalMsgNum } : {}),
460
461
  ...(request.handoffCode ? { handoffCode: request.handoffCode } : {}),
@@ -61,6 +61,8 @@ export interface QueryJobRequest {
61
61
  sessionId: string
62
62
  model?: string
63
63
  effort?: string
64
+ /** Cursor ask|agent. Omitted means ask on the server (old clients). */
65
+ cursorExecutionMode?: string
64
66
  messageEra?: string
65
67
  globalMsgNum?: number
66
68
  reference?: QueryJobPromptReference
@@ -119,6 +121,7 @@ export interface QueryJobSnapshot extends QueryJobProviderLinkage {
119
121
  sessionId: string
120
122
  requestedModel?: string
121
123
  effort?: string
124
+ cursorExecutionMode?: string
122
125
  messageEra?: string
123
126
  globalMsgNum?: number
124
127
  handoffCode?: string
@@ -239,6 +242,10 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
239
242
 
240
243
  const model = optionalString(input.model, 'model', 64)
241
244
  const effort = optionalString(input.effort, 'effort', 32)
245
+ const cursorExecutionModeRaw = optionalString(input.cursorExecutionMode, 'cursor_execution_mode', 16)
246
+ const cursorExecutionMode = cursorExecutionModeRaw === 'agent' || cursorExecutionModeRaw === 'ask'
247
+ ? cursorExecutionModeRaw
248
+ : undefined
242
249
  const messageEra = optionalString(input.messageEra, 'message_era', 80)
243
250
  const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
244
251
  const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
@@ -277,6 +284,7 @@ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
277
284
  sessionId,
278
285
  ...(model ? { model } : {}),
279
286
  ...(effort ? { effort } : {}),
287
+ ...(cursorExecutionMode ? { cursorExecutionMode } : {}),
280
288
  ...(messageEra ? { messageEra } : {}),
281
289
  ...(globalMsgNum ? { globalMsgNum } : {}),
282
290
  ...(reference ? { reference } : {}),
@@ -1,8 +1,13 @@
1
- // Standalone meeting archive backed only by the public server's private data
2
- // directory. No COS operations paths, classifiers, or user-specific stores.
1
+ // Meeting archive: COS operations tree when configured, else standalone recordings.
3
2
 
4
3
  import { Router } from 'express'
5
4
  import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
5
+ import {
6
+ cosOperationsMeetingsConfigured,
7
+ getCosOperationsMeetingDetail,
8
+ listCosOperationsMeetings,
9
+ resolveCosOperationsDir,
10
+ } from '../lib/cos-operations-meetings.js'
6
11
 
7
12
  export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
8
13
  const router = Router()
@@ -14,7 +19,18 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
14
19
  const limit = Number.isFinite(rawLimit) ? rawLimit : 20
15
20
  const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
16
21
  res.set('Cache-Control', 'private, no-store')
17
- res.json({ meetings: store.list({ limit, domain }) })
22
+
23
+ if (cosOperationsMeetingsConfigured()) {
24
+ const meetings = listCosOperationsMeetings({ limit, domain })
25
+ res.json({
26
+ meetings,
27
+ source: 'cos_operations',
28
+ operationsDir: resolveCosOperationsDir(),
29
+ })
30
+ return
31
+ }
32
+
33
+ res.json({ meetings: store.list({ limit, domain }), source: 'standalone_recordings' })
18
34
  } catch (error) {
19
35
  sendMeetingStoreError(res, error)
20
36
  }
@@ -32,6 +48,17 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
32
48
  return
33
49
  }
34
50
  res.set('Cache-Control', 'private, no-store')
51
+
52
+ if (cosOperationsMeetingsConfigured()) {
53
+ const detail = getCosOperationsMeetingDetail(domain, month, filename)
54
+ if (detail) {
55
+ res.json(detail)
56
+ return
57
+ }
58
+ // Fall through to standalone store for G2-local recordings that share
59
+ // the same API shape when ops lookup misses.
60
+ }
61
+
35
62
  res.json(store.detail(domain, month, filename))
36
63
  } catch (error) {
37
64
  sendMeetingStoreError(res, error)
@@ -43,6 +70,19 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
43
70
  router.get('/meetings/:domain/:month/:filename', (req, res) => {
44
71
  try {
45
72
  res.set('Cache-Control', 'private, no-store')
73
+
74
+ if (cosOperationsMeetingsConfigured()) {
75
+ const detail = getCosOperationsMeetingDetail(
76
+ req.params.domain,
77
+ req.params.month,
78
+ req.params.filename,
79
+ )
80
+ if (detail) {
81
+ res.json(detail)
82
+ return
83
+ }
84
+ }
85
+
46
86
  res.json(store.detail(req.params.domain, req.params.month, req.params.filename))
47
87
  } catch (error) {
48
88
  sendMeetingStoreError(res, error)