@gotcos/glasses-server 6.27.5 → 6.27.7

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.
@@ -27,7 +27,12 @@ import { getMediaStore } from './media-store.js'
27
27
  import { MAX_CHUNKED_MEDIA_BYTES, MAX_VIDEO_DURATION_MS } from './rich-media-safety.js'
28
28
 
29
29
  export const VIDEO_UPLOAD_V2_PROTOCOL = 1
30
- export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 256 * 1024
30
+ /** New sessions only. In-flight drafts keep the chunkBytes baked into their
31
+ * manifest — a 256 KiB upload that survives this upgrade must not be rewritten
32
+ * to 1 MiB mid-transfer. The phone parser currently rejects advertised sizes
33
+ * above 1 MiB and disables V2 entirely, so do not raise this without raising
34
+ * that cap first. */
35
+ export const VIDEO_UPLOAD_V2_CHUNK_BYTES = 1024 * 1024
31
36
  export const VIDEO_UPLOAD_V2_MAX_FRAME_BYTES = 256 * 1024
32
37
  export const VIDEO_UPLOAD_V2_MAX_FRAME_PACK_BYTES = 2 * 1024 * 1024
33
38
  export const VIDEO_UPLOAD_PHONE_FRAMES_MIN = 8
@@ -40,6 +45,9 @@ export const VIDEO_UPLOAD_V2_MAX_CONCURRENT = 8
40
45
  export const VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES = 4 * 1024 * 1024 * 1024
41
46
  export const VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES = 512 * 1024 * 1024
42
47
  export const VIDEO_UPLOAD_V2_ACCEPTED_MIMES = ['video/mp4', 'video/quicktime'] as const
48
+ /** Idle receiving drafts older than this are stranded. Live PUTs refresh
49
+ * updatedAtMs; a 1 MiB chunk on a clean link is seconds, not a minute. */
50
+ export const VIDEO_UPLOAD_V2_STRANDED_IDLE_MS = 60_000
43
51
 
44
52
  const UPLOAD_ID_RE = /^vu_[0-9a-f]{24}$/
45
53
  const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
@@ -57,6 +65,22 @@ export function isValidVideoUploadId(value: unknown): value is string {
57
65
  return typeof value === 'string' && UPLOAD_ID_RE.test(value)
58
66
  }
59
67
 
68
+ /** Sideload/kill leftover: receiving, no writer, no bytes for idleMs.
69
+ * Never true for finalizing or published — those are live work or receipts. */
70
+ export function isStrandedReceivingVideoUpload(input: {
71
+ state: string
72
+ updatedAtMs: number
73
+ nowMs: number
74
+ activeWriters?: number
75
+ idleMs?: number
76
+ }): boolean {
77
+ const idleMs = input.idleMs ?? VIDEO_UPLOAD_V2_STRANDED_IDLE_MS
78
+ if (input.state !== 'receiving') return false
79
+ if ((input.activeWriters ?? 0) > 0) return false
80
+ if (!Number.isFinite(input.updatedAtMs) || !Number.isFinite(input.nowMs) || idleMs < 0) return false
81
+ return input.nowMs - input.updatedAtMs >= idleMs
82
+ }
83
+
60
84
  export type VideoUploadState = 'receiving' | 'finalizing' | 'published' | 'cancelled' | 'failed'
61
85
 
62
86
  interface AcceptedPart {
@@ -177,6 +201,17 @@ function parseIndex(value: unknown): number | null {
177
201
  return typeof raw === 'number' && Number.isSafeInteger(raw) && raw >= 0 ? raw : null
178
202
  }
179
203
 
204
+ /** Exact byte length this session expects for original index `index`.
205
+ * Non-final parts are the session's own chunkBytes (which may be 256 KiB on a
206
+ * draft that started before the 1 MiB advertisement). The last part is the
207
+ * remainder. Using the live constant here would accept a 1 MiB PUT into a
208
+ * 256 KiB slot and fail assembly, or reject a legitimate leftover last chunk. */
209
+ function expectedOriginalPartBytes(manifest: VideoUploadManifest, index: number): number {
210
+ if (index < 0 || index >= manifest.chunkCount) return 0
211
+ if (index === manifest.chunkCount - 1) return manifest.totalBytes - index * manifest.chunkBytes
212
+ return manifest.chunkBytes
213
+ }
214
+
180
215
  function sameInit(manifest: VideoUploadManifest, input: Required<Pick<VideoUploadManifest,
181
216
  'serverInstanceId' | 'totalBytes' | 'mime'>> & Pick<VideoUploadManifest, 'label' | 'capturedAt' | 'sessionId'>): boolean {
182
217
  return manifest.serverInstanceId === input.serverInstanceId
@@ -391,6 +426,46 @@ export class VideoUploadRegistry {
391
426
  })
392
427
  }
393
428
 
429
+ async clearStrandedReceiving(serverInstanceId?: string): Promise<{
430
+ cancelled: string[]
431
+ skipped: Array<{ uploadId: string; reason: string }>
432
+ }> {
433
+ const nowMs = this.now()
434
+ const cancelled: string[] = []
435
+ const skipped: Array<{ uploadId: string; reason: string }> = []
436
+ for (const manifest of [...this.manifests.values()]) {
437
+ if (manifest.state === 'finalizing') {
438
+ skipped.push({ uploadId: manifest.uploadId, reason: 'finalizing' })
439
+ continue
440
+ }
441
+ if (manifest.state !== 'receiving') continue
442
+ const activeWriters = this.activeWriters.get(manifest.uploadId) ?? 0
443
+ if (!isStrandedReceivingVideoUpload({
444
+ state: manifest.state,
445
+ updatedAtMs: manifest.updatedAtMs,
446
+ nowMs,
447
+ activeWriters,
448
+ })) {
449
+ skipped.push({
450
+ uploadId: manifest.uploadId,
451
+ reason: activeWriters > 0 ? 'active_writer' : 'recently_updated',
452
+ })
453
+ continue
454
+ }
455
+ try {
456
+ const progress = await this.cancel(manifest.uploadId, serverInstanceId)
457
+ if (progress) cancelled.push(manifest.uploadId)
458
+ } catch (error) {
459
+ if (error instanceof VideoUploadError && error.code === 'server_identity_mismatch') {
460
+ skipped.push({ uploadId: manifest.uploadId, reason: 'identity_mismatch' })
461
+ continue
462
+ }
463
+ throw error
464
+ }
465
+ }
466
+ return { cancelled, skipped }
467
+ }
468
+
394
469
  status(): VideoUploadStatus {
395
470
  this.sweepExpired()
396
471
  let receiving = 0; let finalizing = 0; let unacknowledgedPublished = 0; let failed = 0
@@ -442,8 +517,8 @@ export class VideoUploadRegistry {
442
517
  ): Promise<VideoUploadProgress> {
443
518
  const index = parseIndex(indexValue)
444
519
  if (index === null || bytes.length === 0) throw new VideoUploadError('video_upload_invalid', 'valid non-empty part required')
445
- const max = kind === 'original' ? VIDEO_UPLOAD_V2_CHUNK_BYTES : VIDEO_UPLOAD_V2_MAX_FRAME_BYTES
446
- if (bytes.length > max) throw new VideoUploadError('video_upload_invalid', 'part exceeds its byte ceiling', { maxBytes: max })
520
+ const advertisedMax = kind === 'original' ? VIDEO_UPLOAD_V2_CHUNK_BYTES : VIDEO_UPLOAD_V2_MAX_FRAME_BYTES
521
+ if (bytes.length > advertisedMax) throw new VideoUploadError('video_upload_invalid', 'part exceeds its byte ceiling', { maxBytes: advertisedMax })
447
522
  this.activeWriters.set(uploadId, (this.activeWriters.get(uploadId) ?? 0) + 1)
448
523
  try {
449
524
  return await this.withLock(uploadId, () => {
@@ -451,6 +526,14 @@ export class VideoUploadRegistry {
451
526
  if (manifest.state !== 'receiving') throw new VideoUploadError('video_upload_busy', `upload is ${manifest.state}`)
452
527
  if (kind === 'original' && index >= manifest.chunkCount) throw new VideoUploadError('video_upload_invalid', 'chunk index exceeds declared upload')
453
528
  if (kind === 'frames' && index >= VIDEO_UPLOAD_PHONE_FRAMES_MAX) throw new VideoUploadError('video_upload_invalid', 'frame index exceeds pack limit')
529
+ if (kind === 'original') {
530
+ const expected = expectedOriginalPartBytes(manifest, index)
531
+ if (bytes.length !== expected) {
532
+ throw new VideoUploadError('video_upload_invalid', 'part does not match the session chunk size', {
533
+ expectedBytes: expected, receivedBytes: bytes.length, chunkBytes: manifest.chunkBytes,
534
+ })
535
+ }
536
+ }
454
537
  const collection = manifest[kind]
455
538
  const key = String(index)
456
539
  const digest = sha256(bytes)
@@ -0,0 +1,201 @@
1
+ // GET /api/agent-sessions
2
+ // GET /api/agent-sessions/search?q=
3
+ // GET /api/agent-sessions/:provider/:sessionId
4
+ //
5
+ // Same local stores COS Control reads for Activity → Sessions:
6
+ // Claude jsonl, Codex rollouts, Cursor agent-transcripts.
7
+ // Default list is last 7 days of writes (mtime), including pinned Codex
8
+ // threads whose jsonl still lives in the original day folder.
9
+ // `?sort=updated` matches Control's Updated clock: newest mtime first.
10
+ // Stale pins stay in the payload but do not cluster at the top.
11
+ // `?sort=opened` keeps the same window on session start instead.
12
+ // Search scans titles, sidebar names, first prompts, and transcript heads
13
+ // without the 7-day list window. Literal /search is registered first.
14
+ // Does not need COS_SCRIPTS_DIR. Codex subagents stay out. Files over 32 MB
15
+ // still appear in the list; detail remains capped.
16
+
17
+ import { Router } from 'express'
18
+ import { stat } from 'node:fs/promises'
19
+ import {
20
+ AGENT_SESSION_LIST_LIMIT,
21
+ AGENT_SESSION_LIST_MAX,
22
+ AGENT_SESSION_MAX_FILE_BYTES,
23
+ AGENT_SESSION_WINDOW_HOURS,
24
+ agentSessionRoots,
25
+ findAgentSessionFile,
26
+ listAgentSessions,
27
+ loadCursorComposerNames,
28
+ parseAgentSession,
29
+ type AgentProvider,
30
+ type AgentSessionRow,
31
+ type AgentSessionSort,
32
+ } from '../lib/agent-session-store.js'
33
+ import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
34
+ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
35
+ import { workspaceFromCwd } from '../lib/claude-session-registry.js'
36
+
37
+ export const agentSessionsRouter = Router()
38
+
39
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
40
+ const parsed = Number(value)
41
+ if (!Number.isFinite(parsed)) return fallback
42
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
43
+ }
44
+
45
+ function asProvider(value: string): AgentProvider | null {
46
+ if (value === 'claude' || value === 'codex' || value === 'cursor') return value
47
+ return null
48
+ }
49
+
50
+ function asSort(value: unknown): AgentSessionSort {
51
+ return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
52
+ }
53
+
54
+ function toSearchHit(row: AgentSessionSearchHit) {
55
+ return {
56
+ ...toEntry(row),
57
+ snippet: row.snippet,
58
+ keywordScore: row.keywordScore,
59
+ semanticScore: row.semanticScore,
60
+ match: row.match,
61
+ score: Math.max(row.keywordScore, row.semanticScore),
62
+ }
63
+ }
64
+
65
+ function toEntry(row: AgentSessionRow) {
66
+ return {
67
+ session_id: row.session_id,
68
+ provider: row.provider,
69
+ slug: row.session_id,
70
+ custom_title: row.display_label,
71
+ display_label: row.display_label,
72
+ first_prompt: row.first_prompt || row.display_label,
73
+ discussion_summary: row.discussion_summary || '',
74
+ project: row.project,
75
+ created: row.created,
76
+ modified: row.modified,
77
+ duration_minutes: 0,
78
+ message_count: 0,
79
+ domain: '',
80
+ device_id: row.provider,
81
+ machine_spawned: false,
82
+ alive: row.alive,
83
+ state: row.state,
84
+ pinned: row.pinned,
85
+ }
86
+ }
87
+
88
+ async function liveClaudeRows(): Promise<AgentSessionRow[]> {
89
+ if (!claudeSessionsEnabled()) return []
90
+ const peers = await readClaudePeers(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
91
+ return peers.filter(peer => peer.alive).map(peer => ({
92
+ session_id: peer.id,
93
+ provider: 'claude' as const,
94
+ display_label: peer.name || 'Claude session',
95
+ project: workspaceFromCwd(peer.workspace) || peer.workspace,
96
+ modified: peer.lastActiveAt ? new Date(peer.lastActiveAt).toISOString() : new Date().toISOString(),
97
+ created: peer.startedAt ? new Date(peer.startedAt).toISOString() : new Date().toISOString(),
98
+ alive: true,
99
+ state: 'running' as const,
100
+ pinned: false,
101
+ }))
102
+ }
103
+
104
+ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
105
+ res.set('Cache-Control', 'private, no-store')
106
+ try {
107
+ const limit = boundedInteger(req.query.limit, AGENT_SESSION_LIST_LIMIT, 1, AGENT_SESSION_LIST_MAX)
108
+ const sort = asSort(req.query.sort)
109
+ const live = await liveClaudeRows()
110
+ const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
111
+ res.json({
112
+ sessions: sessions.map(toEntry),
113
+ total: sessions.length,
114
+ windowHours: AGENT_SESSION_WINDOW_HOURS,
115
+ sort,
116
+ enabled: true,
117
+ })
118
+ } catch (error) {
119
+ console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
120
+ res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
121
+ }
122
+ })
123
+
124
+ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
125
+ res.set('Cache-Control', 'private, no-store')
126
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
127
+ if (query.length < 2) {
128
+ res.status(400).json({ error: 'q must be at least 2 characters', reason: 'invalid_query' })
129
+ return
130
+ }
131
+ try {
132
+ const limit = boundedInteger(req.query.limit, 20, 1, 50)
133
+ const result = await searchAgentSessions({ query, limit })
134
+ res.json({
135
+ ...result,
136
+ hits: result.hits.map(toSearchHit),
137
+ })
138
+ } catch (error) {
139
+ console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
140
+ res.status(500).json({ error: 'Failed to search agent sessions', reason: 'agent_sessions_search_failed' })
141
+ }
142
+ })
143
+
144
+ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res) => {
145
+ res.set('Cache-Control', 'private, no-store')
146
+ const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
147
+ const sessionId = String(req.params.sessionId ?? '')
148
+ if (!provider) {
149
+ res.status(400).json({ error: 'provider must be claude, codex, or cursor', reason: 'bad_provider' })
150
+ return
151
+ }
152
+ try {
153
+ const found = await findAgentSessionFile(provider, sessionId, agentSessionRoots())
154
+ if (!found) {
155
+ res.status(404).json({ error: 'Session not found', reason: 'session_not_found' })
156
+ return
157
+ }
158
+ const st = await stat(found)
159
+ if (st.size > AGENT_SESSION_MAX_FILE_BYTES) {
160
+ res.status(413).json({ error: 'Session too large to open', reason: 'session_too_large' })
161
+ return
162
+ }
163
+ const parsed = await parseAgentSession(provider, found)
164
+ if (provider === 'cursor') {
165
+ const names = await loadCursorComposerNames(agentSessionRoots().cursorComposerDb)
166
+ const named = names.get(parsed.session_id) || names.get(sessionId)
167
+ if (named) parsed.display_label = named
168
+ }
169
+ const modified = st.mtime.toISOString()
170
+ res.json({
171
+ session_id: parsed.session_id,
172
+ provider: parsed.provider,
173
+ slug: parsed.session_id,
174
+ custom_title: parsed.display_label,
175
+ display_label: parsed.display_label,
176
+ first_prompt: parsed.first_prompt,
177
+ discussion_summary: parsed.discussion_summary || '',
178
+ project: parsed.project,
179
+ created: modified,
180
+ modified,
181
+ duration_minutes: 0,
182
+ message_count: parsed.user_message_count + parsed.assistant_message_count,
183
+ user_message_count: parsed.user_message_count,
184
+ assistant_message_count: parsed.assistant_message_count,
185
+ domain: '',
186
+ device_id: parsed.provider,
187
+ machine_spawned: false,
188
+ tools_used: {},
189
+ files_touched: [],
190
+ git_branch: parsed.git_branch || 'unknown',
191
+ has_subagents: false,
192
+ total_input_tokens: 0,
193
+ total_output_tokens: 0,
194
+ file_size_bytes: parsed.file_size_bytes,
195
+ omitted_tools: parsed.omitted_tools,
196
+ })
197
+ } catch (error) {
198
+ console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
199
+ res.status(500).json({ error: 'Failed to read agent session', reason: 'agent_sessions_read_failed' })
200
+ }
201
+ })
@@ -620,6 +620,16 @@ mediaRouter.post('/media/video-upload/init', (req: Request, res: Response) => {
620
620
  }
621
621
  })
622
622
 
623
+ mediaRouter.post('/media/video-upload/clear-stranded', async (req: Request, res: Response) => {
624
+ try {
625
+ const serverInstanceId = requireCurrentServerIdentity(req)
626
+ const result = await getVideoUploadRegistry().clearStrandedReceiving(serverInstanceId)
627
+ res.json({ ok: true, ...result })
628
+ } catch (err) {
629
+ sendMediaError(res, err)
630
+ }
631
+ })
632
+
623
633
  mediaRouter.get('/media/video-upload/:uploadId', (req: Request, res: Response) => {
624
634
  try {
625
635
  const serverInstanceId = requireCurrentServerIdentity(req)
@@ -7,10 +7,19 @@ import {
7
7
  getDirectLibraryMeetingDetail,
8
8
  getCosOperationsMeetingDetail,
9
9
  listDirectLibraryMeetings,
10
+ listDirectLibraryMeetingDays,
11
+ listDirectLibraryMeetingMonths,
10
12
  listCosOperationsMeetings,
13
+ listCosOperationsMeetingDays,
14
+ listCosOperationsMeetingMonths,
11
15
  resolveMeetingLibrary,
12
16
  } from '../lib/cos-operations-meetings.js'
13
17
  import type { MeetingMeta } from '../lib/meeting-store.js'
18
+ import { meetingListLimit } from '../lib/meeting-store.js'
19
+ import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
20
+
21
+ const MONTH_QUERY = /^\d{4}-(0[1-9]|1[0-2])$/
22
+ const DAY_QUERY = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
14
23
 
15
24
  function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
16
25
  return {
@@ -21,7 +30,7 @@ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
21
30
  }
22
31
  }
23
32
 
24
- function mergeMeetingSources(groups: MeetingMeta[][], limit: number): MeetingMeta[] {
33
+ function mergeMeetingSources(groups: MeetingMeta[][], limit: number, max = 50): MeetingMeta[] {
25
34
  const seenSessions = new Set<string>()
26
35
  const seenExact = new Set<string>()
27
36
  const merged: MeetingMeta[] = []
@@ -40,18 +49,67 @@ function mergeMeetingSources(groups: MeetingMeta[][], limit: number): MeetingMet
40
49
  }
41
50
  merged.sort((a, b) => `${b.date}T${b.time || '00:00'}`.localeCompare(`${a.date}T${a.time || '00:00'}`)
42
51
  || b.filename.localeCompare(a.filename))
43
- return merged.slice(0, Math.min(Math.max(limit, 1), 50))
52
+ return merged.slice(0, Math.min(Math.max(limit, 1), max))
53
+ }
54
+
55
+ function parseListFilters(query: { month?: unknown; day?: unknown }): {
56
+ month?: string
57
+ day?: string
58
+ error?: { status: number; body: { error: string; reason: string } }
59
+ } {
60
+ const rawMonth = typeof query.month === 'string' ? query.month : ''
61
+ const rawDay = typeof query.day === 'string' ? query.day : ''
62
+ if (rawMonth && !MONTH_QUERY.test(rawMonth)) {
63
+ return { error: { status: 400, body: { error: 'Invalid month', reason: 'invalid_month' } } }
64
+ }
65
+ if (rawDay && !DAY_QUERY.test(rawDay)) {
66
+ return { error: { status: 400, body: { error: 'Invalid day', reason: 'invalid_day' } } }
67
+ }
68
+ if (rawDay && rawMonth && !rawDay.startsWith(`${rawMonth}-`)) {
69
+ return { error: { status: 400, body: { error: 'day is not in month', reason: 'month_day_mismatch' } } }
70
+ }
71
+ const day = rawDay || undefined
72
+ const month = rawMonth || (day ? day.slice(0, 7) : undefined)
73
+ return { month, day }
74
+ }
75
+
76
+ function mergeDayCounts(
77
+ groups: Array<Array<{ date: string; count: number }>>,
78
+ ): Array<{ date: string; count: number }> {
79
+ const counts = new Map<string, number>()
80
+ for (const group of groups) {
81
+ for (const { date, count } of group) {
82
+ counts.set(date, (counts.get(date) ?? 0) + count)
83
+ }
84
+ }
85
+ return [...counts.entries()]
86
+ .sort((a, b) => a[0].localeCompare(b[0]))
87
+ .map(([date, count]) => ({ date, count }))
88
+ }
89
+
90
+ function uniqueSortedMonths(groups: string[][]): string[] {
91
+ return [...new Set(groups.flat())].sort().reverse()
44
92
  }
45
93
 
46
94
  export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
47
95
  const router = Router()
48
96
 
49
97
  // GET /api/meetings?limit=20&domain=all
98
+ // Optional month=YYYY-MM and day=YYYY-MM-DD raise the cap to 200 and
99
+ // return `months` / `days` for the Control calendar. G2 omits those
100
+ // filters and still receives at most 50 rows.
50
101
  router.get('/meetings', (req, res) => {
51
102
  try {
52
103
  const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 20
53
- const limit = Number.isFinite(rawLimit) ? rawLimit : 20
54
104
  const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
105
+ const filters = parseListFilters(req.query)
106
+ if (filters.error) {
107
+ res.status(filters.error.status).json(filters.error.body)
108
+ return
109
+ }
110
+ const scoped = Boolean(filters.month || filters.day)
111
+ const limit = meetingListLimit(Number.isFinite(rawLimit) ? rawLimit : 20, scoped)
112
+ const sourceLimit = scoped ? 200 : 50
55
113
  res.set('Cache-Control', 'private, no-store')
56
114
 
57
115
  const library = resolveMeetingLibrary()
@@ -65,17 +123,33 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
65
123
  return
66
124
  }
67
125
 
126
+ const listOptions = { limit: sourceLimit, domain, month: filters.month, day: filters.day }
127
+
68
128
  if (library.layout === 'direct') {
69
129
  const operations = cosOperationsMeetingsConfigured()
70
- ? listCosOperationsMeetings({ limit: 50, domain })
130
+ ? listCosOperationsMeetings(listOptions)
71
131
  : []
72
132
  const direct = domain === 'all' || domain === 'library'
73
- ? listDirectLibraryMeetings({ limit: 50 })
133
+ ? listDirectLibraryMeetings({ limit: sourceLimit, month: filters.month, day: filters.day })
134
+ : []
135
+ const standalone = store.list(listOptions).map(withStandaloneIdentity)
136
+ const meetings = mergeMeetingSources([operations, direct, standalone], limit, sourceLimit)
137
+ const months = uniqueSortedMonths([
138
+ ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingMonths(domain)] : []),
139
+ ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingMonths()] : []),
140
+ store.listMonths(),
141
+ ])
142
+ const days = filters.month
143
+ ? mergeDayCounts([
144
+ ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingDays(filters.month, domain)] : []),
145
+ ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingDays(filters.month)] : []),
146
+ store.listDayCounts(filters.month),
147
+ ])
74
148
  : []
75
- const standalone = store.list({ limit: 50, domain }).map(withStandaloneIdentity)
76
- const meetings = mergeMeetingSources([operations, direct, standalone], limit)
77
149
  res.json({
78
150
  meetings,
151
+ months,
152
+ days,
79
153
  source: operations.length > 0 ? 'mixed_library' : 'direct_library',
80
154
  layout: 'direct',
81
155
  root: library.root,
@@ -87,9 +161,16 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
87
161
  }
88
162
 
89
163
  if (library.layout === 'multi_domain') {
90
- const meetings = listCosOperationsMeetings({ limit, domain })
164
+ const meetings = listCosOperationsMeetings({
165
+ limit,
166
+ domain,
167
+ month: filters.month,
168
+ day: filters.day,
169
+ })
91
170
  res.json({
92
171
  meetings,
172
+ months: listCosOperationsMeetingMonths(domain),
173
+ days: filters.month ? listCosOperationsMeetingDays(filters.month, domain) : [],
93
174
  source: 'cos_operations',
94
175
  layout: 'multi_domain',
95
176
  root: library.root,
@@ -100,8 +181,42 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
100
181
  return
101
182
  }
102
183
 
103
- const meetings = store.list({ limit, domain }).map(withStandaloneIdentity)
104
- res.json({ meetings, source: 'standalone_recordings', layout: 'standalone', meetingCount: meetings.length })
184
+ const meetings = store.list({
185
+ limit,
186
+ domain,
187
+ month: filters.month,
188
+ day: filters.day,
189
+ }).map(withStandaloneIdentity)
190
+ res.json({
191
+ meetings,
192
+ months: store.listMonths(),
193
+ days: filters.month ? store.listDayCounts(filters.month) : [],
194
+ source: 'standalone_recordings',
195
+ layout: 'standalone',
196
+ meetingCount: meetings.length,
197
+ })
198
+ } catch (error) {
199
+ sendMeetingStoreError(res, error)
200
+ }
201
+ })
202
+
203
+ // Literal path before /meetings/:domain/:month/:filename.
204
+ router.get('/meetings/search', async (req, res) => {
205
+ try {
206
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
207
+ if (query.length < 2) {
208
+ res.status(400).json({ error: 'q must be at least 2 characters', reason: 'invalid_query' })
209
+ return
210
+ }
211
+ const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 20
212
+ const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
213
+ res.set('Cache-Control', 'private, no-store')
214
+ const result = await searchMeetingLibrary({
215
+ query,
216
+ domain,
217
+ limit: Number.isFinite(rawLimit) ? rawLimit : 20,
218
+ }, store)
219
+ res.json(result)
105
220
  } catch (error) {
106
221
  sendMeetingStoreError(res, error)
107
222
  }
@@ -1,5 +1,6 @@
1
1
  import { Router } from 'express'
2
2
  import { callPython, contextSourceAvailable, pythonBridgeState } from '../lib/python-bridge.js'
3
+ import { searchMemories } from '../lib/context-library-search.js'
3
4
 
4
5
  /**
5
6
  * Is there anything to serve — a Python bridge OR plain files on disk?
@@ -78,6 +79,25 @@ memoryRouter.get('/memory/overview', async (_req, res) => {
78
79
  }
79
80
  })
80
81
 
82
+ memoryRouter.get('/memory/search', async (req, res) => {
83
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
84
+ if (query.length < 2) {
85
+ res.status(400).json({ error: 'q must be at least 2 characters', reason: 'invalid_query' })
86
+ return
87
+ }
88
+ const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 20
89
+ res.set('Cache-Control', 'private, no-store')
90
+ try {
91
+ const result = await searchMemories({
92
+ query,
93
+ limit: Number.isFinite(rawLimit) ? rawLimit : 20,
94
+ })
95
+ res.json(result)
96
+ } catch {
97
+ res.status(503).json({ error: 'memory_search_unavailable', reason: 'memory_search_unavailable' })
98
+ }
99
+ })
100
+
81
101
  memoryRouter.get('/memory/:id', async (req, res) => {
82
102
  if (!MEMORY_ID_PATTERN.test(req.params.id)) {
83
103
  res.status(400).json({ error: 'invalid_memory_id' })
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
9
9
  // GET /api/message-counter → { max, era }
10
+ // POST /api/message-era/reset { confirm: true } → archive live sessions, start at #1
10
11
  //
11
12
  // Resolution order (per the prompt-queue/archive plan): live in-memory
12
13
  // sessions first (covers the mirror's 15-minute lag), then day archives
@@ -24,6 +25,7 @@ import {
24
25
  currentMessageEra,
25
26
  exchangeBelongsToEra,
26
27
  } from '../lib/message-era.js'
28
+ import { MessageEraResetError, resetLiveMessageEra } from '../lib/message-era-reset.js'
27
29
 
28
30
  // v6.3.0 — read archives from the SAME persistent location the archive-mirror
29
31
  // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
@@ -203,6 +205,20 @@ function resolveFromLiveSessions(
203
205
 
204
206
  export const messageRefRouter = Router()
205
207
 
208
+ messageRefRouter.post('/message-era/reset', async (req, res) => {
209
+ try {
210
+ const result = await resetLiveMessageEra({ confirm: req.body?.confirm === true })
211
+ res.json(result)
212
+ } catch (err) {
213
+ if (err instanceof MessageEraResetError) {
214
+ res.status(err.status).json({ error: err.message, code: err.code })
215
+ return
216
+ }
217
+ console.error('[message-era] reset failed:', err)
218
+ res.status(500).json({ error: 'Archive failed; message count was not reset.', code: 'archive_failed' })
219
+ }
220
+ })
221
+
206
222
  messageRefRouter.get('/message/:num', (req, res) => {
207
223
  const num = Number.parseInt(req.params.num, 10)
208
224
  if (!Number.isFinite(num) || num < 1) {
@@ -1,9 +1,29 @@
1
1
  import { Router } from 'express'
2
2
  import { callPython, contextSourceAvailable, pythonBridgeState } from '../lib/python-bridge.js'
3
+ import { searchThreads } from '../lib/context-library-search.js'
3
4
  import { THREAD_ID_PATTERN, normalizeThreadDetail, normalizeThreads } from '../lib/cos-context-browser.js'
4
5
 
5
6
  export const threadsRouter = Router()
6
7
 
8
+ threadsRouter.get('/threads/search', async (req, res) => {
9
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
10
+ if (query.length < 2) {
11
+ res.status(400).json({ error: 'q must be at least 2 characters', reason: 'invalid_query' })
12
+ return
13
+ }
14
+ const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 20
15
+ res.set('Cache-Control', 'private, no-store')
16
+ try {
17
+ const result = await searchThreads({
18
+ query,
19
+ limit: Number.isFinite(rawLimit) ? rawLimit : 20,
20
+ })
21
+ res.json(result)
22
+ } catch {
23
+ res.status(503).json({ error: 'threads_search_unavailable', reason: 'threads_search_unavailable' })
24
+ }
25
+ })
26
+
7
27
  threadsRouter.get('/threads/:id', async (req, res) => {
8
28
  if (!THREAD_ID_PATTERN.test(req.params.id)) {
9
29
  res.status(400).json({ error: 'invalid_thread_id' })