@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.
- package/CHANGELOG.md +76 -0
- package/README.md +6 -5
- package/bin/cli.cjs +4 -4
- package/package.json +1 -1
- package/server/index.ts +5 -1
- package/server/lib/agent-session-search.ts +447 -0
- package/server/lib/agent-session-store.ts +1065 -0
- package/server/lib/context-library-search.ts +346 -0
- package/server/lib/cos-operations-meetings.ts +75 -6
- package/server/lib/cursor-model-catalog.ts +55 -14
- package/server/lib/meeting-library-search.ts +439 -0
- package/server/lib/meeting-store.ts +56 -2
- package/server/lib/message-era-reset.ts +112 -0
- package/server/lib/video-upload-v2.ts +86 -3
- package/server/routes/agent-sessions.ts +201 -0
- package/server/routes/media.ts +10 -0
- package/server/routes/meetings.ts +125 -10
- package/server/routes/memory.ts +20 -0
- package/server/routes/message-ref.ts +16 -0
- package/server/routes/threads.ts +20 -0
- package/server/scripts/reset-message-era.ts +9 -9
- package/shared/model-preference.ts +1 -1
|
@@ -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
|
-
|
|
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
|
|
446
|
-
if (bytes.length >
|
|
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
|
+
})
|
package/server/routes/media.ts
CHANGED
|
@@ -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),
|
|
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(
|
|
130
|
+
? listCosOperationsMeetings(listOptions)
|
|
71
131
|
: []
|
|
72
132
|
const direct = domain === 'all' || domain === 'library'
|
|
73
|
-
? listDirectLibraryMeetings({ limit:
|
|
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({
|
|
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({
|
|
104
|
-
|
|
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
|
}
|
package/server/routes/memory.ts
CHANGED
|
@@ -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) {
|
package/server/routes/threads.ts
CHANGED
|
@@ -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' })
|