@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 +7 -0
- package/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/server/lib/cos-operations-meetings.ts +365 -0
- package/server/lib/query-job-runtime.ts +2 -0
- package/server/lib/query-job-store.ts +1 -0
- package/server/lib/query-job-types.ts +8 -0
- package/server/routes/meetings.ts +43 -3
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
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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)
|