@gotcos/glasses-server 6.27.6 → 6.27.9

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.
@@ -45,6 +45,9 @@ export const VIDEO_UPLOAD_V2_MAX_CONCURRENT = 8
45
45
  export const VIDEO_UPLOAD_V2_TOTAL_RESERVED_BYTES = 4 * 1024 * 1024 * 1024
46
46
  export const VIDEO_UPLOAD_V2_FREE_DISK_RESERVE_BYTES = 512 * 1024 * 1024
47
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
48
51
 
49
52
  const UPLOAD_ID_RE = /^vu_[0-9a-f]{24}$/
50
53
  const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
@@ -62,6 +65,22 @@ export function isValidVideoUploadId(value: unknown): value is string {
62
65
  return typeof value === 'string' && UPLOAD_ID_RE.test(value)
63
66
  }
64
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
+
65
84
  export type VideoUploadState = 'receiving' | 'finalizing' | 'published' | 'cancelled' | 'failed'
66
85
 
67
86
  interface AcceptedPart {
@@ -407,6 +426,46 @@ export class VideoUploadRegistry {
407
426
  })
408
427
  }
409
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
+
410
469
  status(): VideoUploadStatus {
411
470
  this.sweepExpired()
412
471
  let receiving = 0; let finalizing = 0; let unacknowledgedPublished = 0; let failed = 0
@@ -0,0 +1,212 @@
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_WINDOW_HOURS,
23
+ agentSessionRoots,
24
+ findAgentSessionFile,
25
+ listAgentSessions,
26
+ loadCursorComposerNames,
27
+ parseAgentSession,
28
+ type AgentProvider,
29
+ type AgentSessionRow,
30
+ type AgentSessionSort,
31
+ } from '../lib/agent-session-store.js'
32
+ import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-session-search.js'
33
+ import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeers } from './claude-sessions.js'
34
+ import { workspaceFromCwd } from '../lib/claude-session-registry.js'
35
+
36
+ export const agentSessionsRouter = Router()
37
+
38
+ function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
39
+ const parsed = Number(value)
40
+ if (!Number.isFinite(parsed)) return fallback
41
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
42
+ }
43
+
44
+ function asProvider(value: string): AgentProvider | null {
45
+ if (value === 'claude' || value === 'codex' || value === 'cursor') return value
46
+ return null
47
+ }
48
+
49
+ function asSort(value: unknown): AgentSessionSort {
50
+ return String(value ?? '').toLowerCase() === 'opened' ? 'opened' : 'updated'
51
+ }
52
+
53
+ function toSearchHit(row: AgentSessionSearchHit) {
54
+ return {
55
+ ...toEntry(row),
56
+ snippet: row.snippet,
57
+ keywordScore: row.keywordScore,
58
+ semanticScore: row.semanticScore,
59
+ match: row.match,
60
+ score: Math.max(row.keywordScore, row.semanticScore),
61
+ }
62
+ }
63
+
64
+ function toEntry(row: AgentSessionRow) {
65
+ return {
66
+ session_id: row.session_id,
67
+ provider: row.provider,
68
+ slug: row.session_id,
69
+ custom_title: row.display_label,
70
+ display_label: row.display_label,
71
+ first_prompt: row.first_prompt || row.display_label,
72
+ discussion_summary: row.discussion_summary || '',
73
+ project: row.project,
74
+ created: row.created,
75
+ modified: row.modified,
76
+ duration_minutes: 0,
77
+ message_count: 0,
78
+ domain: '',
79
+ device_id: row.provider,
80
+ machine_spawned: false,
81
+ alive: row.alive,
82
+ state: row.state,
83
+ pinned: row.pinned,
84
+ }
85
+ }
86
+
87
+ async function liveClaudeRows(): Promise<AgentSessionRow[]> {
88
+ if (!claudeSessionsEnabled()) return []
89
+ const peers = await readClaudePeers(claudeSessionsDir(), undefined, claudeSessionNamesVisible())
90
+ return peers.filter(peer => peer.alive).map(peer => ({
91
+ session_id: peer.id,
92
+ provider: 'claude' as const,
93
+ display_label: peer.name || 'Claude session',
94
+ project: workspaceFromCwd(peer.workspace) || peer.workspace,
95
+ modified: peer.lastActiveAt ? new Date(peer.lastActiveAt).toISOString() : new Date().toISOString(),
96
+ created: peer.startedAt ? new Date(peer.startedAt).toISOString() : new Date().toISOString(),
97
+ alive: true,
98
+ state: 'running' as const,
99
+ pinned: false,
100
+ }))
101
+ }
102
+
103
+ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
104
+ res.set('Cache-Control', 'private, no-store')
105
+ try {
106
+ const limit = boundedInteger(req.query.limit, AGENT_SESSION_LIST_LIMIT, 1, AGENT_SESSION_LIST_MAX)
107
+ const sort = asSort(req.query.sort)
108
+ const live = await liveClaudeRows()
109
+ const sessions = await listAgentSessions(agentSessionRoots(), new Date(), live, limit, sort)
110
+ res.json({
111
+ sessions: sessions.map(toEntry),
112
+ total: sessions.length,
113
+ windowHours: AGENT_SESSION_WINDOW_HOURS,
114
+ sort,
115
+ enabled: true,
116
+ })
117
+ } catch (error) {
118
+ console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
119
+ res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
120
+ }
121
+ })
122
+
123
+ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
124
+ res.set('Cache-Control', 'private, no-store')
125
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
126
+ if (query.length < 2) {
127
+ res.status(400).json({ error: 'q must be at least 2 characters', reason: 'invalid_query' })
128
+ return
129
+ }
130
+ try {
131
+ const limit = boundedInteger(req.query.limit, 20, 1, 50)
132
+ const result = await searchAgentSessions({ query, limit })
133
+ res.json({
134
+ ...result,
135
+ hits: result.hits.map(toSearchHit),
136
+ })
137
+ } catch (error) {
138
+ console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
139
+ res.status(500).json({ error: 'Failed to search agent sessions', reason: 'agent_sessions_search_failed' })
140
+ }
141
+ })
142
+
143
+ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res) => {
144
+ res.set('Cache-Control', 'private, no-store')
145
+ const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
146
+ const sessionId = String(req.params.sessionId ?? '')
147
+ if (!provider) {
148
+ res.status(400).json({ error: 'provider must be claude, codex, or cursor', reason: 'bad_provider' })
149
+ return
150
+ }
151
+ try {
152
+ const found = await findAgentSessionFile(provider, sessionId, agentSessionRoots())
153
+ if (!found) {
154
+ res.status(404).json({ error: 'Session not found', reason: 'session_not_found' })
155
+ return
156
+ }
157
+ const st = await stat(found)
158
+ // Oversized transcripts are READ IN PART, not refused.
159
+ //
160
+ // This used to answer 413 "Session too large to open" above 32 MiB, which made the
161
+ // biggest sessions — the ones most worth reviewing before a follow-up — completely
162
+ // unopenable on the glasses. A 67 MB transcript is not exotic; this repo's own
163
+ // 2026-08-13 session is one. The detail page needs the opening turns, the recent
164
+ // turns, and stats, and a bounded head+tail carries all three.
165
+ //
166
+ // `parsed.truncated` says so out loud, and the counts are then counts of what was
167
+ // READ. Presenting a partial count as the session total would be the same
168
+ // dishonesty as a silent cap, so the digest omits the number entirely instead.
169
+ const parsed = await parseAgentSession(provider, found)
170
+ if (provider === 'cursor') {
171
+ const names = await loadCursorComposerNames(agentSessionRoots().cursorComposerDb)
172
+ const named = names.get(parsed.session_id) || names.get(sessionId)
173
+ if (named) parsed.display_label = named
174
+ }
175
+ const modified = st.mtime.toISOString()
176
+ res.json({
177
+ session_id: parsed.session_id,
178
+ provider: parsed.provider,
179
+ slug: parsed.session_id,
180
+ custom_title: parsed.display_label,
181
+ display_label: parsed.display_label,
182
+ first_prompt: parsed.first_prompt,
183
+ discussion_summary: parsed.discussion_summary || '',
184
+ // Detail only. The list row (line 73) deliberately stays on the 180-char
185
+ // summary — Miles: "it should be in the body not the title, the row should
186
+ // be no more than the 180 characters."
187
+ discussion_digest: parsed.discussion_digest || '',
188
+ truncated: parsed.truncated,
189
+ project: parsed.project,
190
+ created: modified,
191
+ modified,
192
+ duration_minutes: 0,
193
+ message_count: parsed.user_message_count + parsed.assistant_message_count,
194
+ user_message_count: parsed.user_message_count,
195
+ assistant_message_count: parsed.assistant_message_count,
196
+ domain: '',
197
+ device_id: parsed.provider,
198
+ machine_spawned: false,
199
+ tools_used: {},
200
+ files_touched: [],
201
+ git_branch: parsed.git_branch || 'unknown',
202
+ has_subagents: false,
203
+ total_input_tokens: 0,
204
+ total_output_tokens: 0,
205
+ file_size_bytes: parsed.file_size_bytes,
206
+ omitted_tools: parsed.omitted_tools,
207
+ })
208
+ } catch (error) {
209
+ console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
210
+ res.status(500).json({ error: 'Failed to read agent session', reason: 'agent_sessions_read_failed' })
211
+ }
212
+ })
@@ -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' })
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env tsx
2
- // Creates a fresh short-number namespace without touching sessions or archives.
3
- // Restart the server afterward so every new exchange is stamped into the era.
2
+ // Archives live sessions, then creates a fresh short-number namespace.
3
+ // History stays in day archives. Disk mtime is enough no server restart.
4
4
  //
5
5
  // npx tsx server/scripts/reset-message-era.ts --confirm
6
6
 
7
- import { createMessageEra, currentMessageEraState } from '../lib/message-era.js'
7
+ import { currentMessageEraState } from '../lib/message-era.js'
8
+ import { resetLiveMessageEra } from '../lib/message-era-reset.js'
8
9
 
9
10
  if (!process.argv.includes('--confirm')) {
10
11
  const current = currentMessageEraState()
@@ -13,16 +14,15 @@ if (!process.argv.includes('--confirm')) {
13
14
  process.exit(2)
14
15
  }
15
16
 
16
- const next = createMessageEra()
17
+ const next = await resetLiveMessageEra({ confirm: true })
17
18
  console.log(JSON.stringify({
18
19
  status: 'created',
19
20
  ...next,
20
21
  history: 'retained',
21
- restartRequired: true,
22
- verify: 'GET /api/message-counter should return { max: 0 or small, era: "<era above>" } after server restart',
22
+ restartRequired: false,
23
+ verify: 'GET /api/message-counter should return { max: 0, era: "<era above>" }',
23
24
  nextSteps: [
24
- 'Restart LaunchAgent / Control Update Server generation',
25
- 'Phone reconnect so syncMessageEra clears the live list',
26
- 'Send a test message — expect #1 (or low single digits)',
25
+ 'Phone: tap RESET # or reopen the companion so the live list clears',
26
+ 'Send a test message expect #1',
27
27
  ],
28
28
  }, null, 2))
@@ -195,7 +195,7 @@ export function modelLabel(model: ModelPreference): string {
195
195
  case 'haiku': return 'Haiku'
196
196
  case 'codex-frontier': return runtimeCodexLabels[model] ?? 'GPT Frontier'
197
197
  case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
198
- case 'cursor-grok': return runtimeCursorLabels[model] ?? 'Grok 4.5 Fast'
198
+ case 'cursor-grok': return runtimeCursorLabels[model] ?? 'Grok Fast'
199
199
  case 'cursor-composer': return runtimeCursorLabels[model] ?? 'Composer 2.5 Fast'
200
200
  case 'opus':
201
201
  default: