@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 CHANGED
@@ -1,3 +1,79 @@
1
+ ## Unreleased
2
+
3
+ ## 6.27.7
4
+ - **`GET /api/agent-sessions` ships in the public package.** Claude Code, Codex,
5
+ and Cursor transcripts from this Mac, last 7 days of writes. Glasses 6.8.360
6
+ uses this instead of the Claude-only COS cache. Stock 6.27.6 404s that route.
7
+ - **Sessions list matches Control Updated.** Newest write first. Stale pins stay
8
+ in the payload (any age) but do not cluster at the top — that is Control's
9
+ Pinned clock, which glasses does not have.
10
+ - **Session discussion gist.** Agent-session list and detail include
11
+ `discussion_summary`: first real user turn plus the latest assistant prose
12
+ from a cheap transcript peek. No LLM. Glasses use it on the session row and
13
+ detail; older clients ignore the field. `first_prompt` on the list is the
14
+ first user turn, not a copy of the sidebar title.
15
+ - **Sessions lookup.** `GET /api/agent-sessions/search?q=` runs keyword over
16
+ sidebar names, `/rename` titles, first prompts, and the first ~8k of user
17
+ transcript — including chats older than the 7-day list window. Meaning search
18
+ embeds the query once against those same texts (OpenAI key, no LLM). Keyword
19
+ still returns if embeddings are down. Literal path, registered before
20
+ `/agent-sessions/:provider/:sessionId`.
21
+ - **Sessions Pinned includes Claude Desktop stars and Cursor
22
+ `pinnedComposers`.** Same rule as ChatGPT `pinned-thread-ids`: starred
23
+ Claude sessions (including Desktop-only blobs with no `~/.claude` jsonl)
24
+ and Cursor sidebar pins stay in the list at any age. Keep-warm `ready`
25
+ rows still stay out.
26
+ - **Sessions hide CLI keep-warm `ready` rows** and Control provider-proof
27
+ prompts so real chats fill the list.
28
+ - **Sessions keep ChatGPT pins and Cursor sidebar names.** Codex
29
+ `pinned-thread-ids` stay in the list even when the jsonl is weeks old.
30
+ Cursor rows use `composerHeaders.name` (the sidebar title) and skip the
31
+ `empty-window` duplicate of the same chat.
32
+ - **Memory and Threads lookup.** `GET /api/memory/search?q=` and
33
+ `GET /api/threads/search?q=` run keyword over local notes (first ~8k of each
34
+ file, ~2k file budget) then, for memories only, one embedding against the
35
+ existing `cos_memory` index via `bot_memory.py`. Threads have no embedding
36
+ index — `semanticAvailable` is false and keyword still works. Literal paths,
37
+ registered before `/:id`. Does not search meeting Qdrant. Additive; list and
38
+ detail are unchanged.
39
+ - **Meeting library calendar filters.** `GET /api/meetings` accepts `month=YYYY-MM`
40
+ and `day=YYYY-MM-DD`, raises the cap to 200 when either is set, and returns
41
+ `months` plus per-day counts for that month. Unfiltered G2 lists stay at the
42
+ existing 50-row cap. Additive — extra fields are ignored by older clients.
43
+ - **Meeting lookup.** `GET /api/meetings/search?q=` runs keyword over title,
44
+ summary, and filename, then meaning search against the existing Qdrant
45
+ meeting index (one query embedding, no LLM). Keyword still returns if Qdrant
46
+ is down. Literal path, registered before `/meetings/:domain/:month/:filename`.
47
+ - **Reset live message count.** `POST /api/message-era/reset` with `{ confirm: true }`
48
+ snapshots live sessions into the day archive, then starts short-numbering at
49
+ #1. History is not deleted — ARCHIVE / Message History still resolve old
50
+ stamps. Refuses without confirm, while a query is in flight, or if archive
51
+ fails. Disk mtime is enough; no server restart. CLI
52
+ `reset-message-era.ts --confirm` uses the same path.
53
+ - **Grok slot tracks newest high-fast.** `cursor-grok` now resolves to the
54
+ newest `cursor-grok-<ver>-high-fast` from `agent models` (today 4.6). Low,
55
+ medium, xhigh, and non-fast ids are ignored. Composer stays pinned to
56
+ `composer-2.5-fast`. No EHPK change: the phone still sends the stable slot.
57
+ - **Clear stranded video uploads.** Sideload, crash, or a killed composer can
58
+ leave a `receiving` draft for 4 hours. That draft holds `blocksRestart`, so
59
+ Repair and Update stall on it instead of clearing it. `POST /api/media/video-upload/clear-stranded`
60
+ cancels receiving drafts with no active writer and no bytes for 60 seconds.
61
+ Finalizing and published receipts are left alone.
62
+
63
+ ## 6.27.6
64
+ - **V2 original chunks are 1 MiB.** Same sequential one-in-flight loop, same
65
+ ArrayBuffer bodies, same GET-progress resume. A 244 MB clip goes from 953
66
+ round trips to ~239. That is the leftover ~10% against legacy 8 MiB, paid as
67
+ per-chunk RTT, without opening a second fetch — two concurrent ArrayBuffer
68
+ PUTs from this WebView are still an untested shape.
69
+ - **In-flight 256 KiB drafts keep their size.** `putOriginal` checks the
70
+ session's own `chunkBytes`, not the live constant, so a draft that started
71
+ before this upgrade still accepts 256 KiB parts and rejects a 1 MiB PUT into
72
+ that slot. New inits advertise 1 MiB. Do not raise this above 1 MiB until the
73
+ phone parser cap is raised first — above that, V2 capability parse returns
74
+ null and the transport silently falls back to legacy.
75
+ - Frame parts stay 256 KiB. Protocol stays 1.
76
+
1
77
  ## 6.27.5
2
78
  - **Chunk uploads now leave a server-side trace.** On 2026-08-12 a phone upload
3
79
  stalled on both media transports and the server was a complete blind spot: nothing
package/README.md CHANGED
@@ -43,10 +43,10 @@ without silently losing completed replies.
43
43
  `npm install -g @anthropic-ai/claude-code` (**never with `sudo`**), then run
44
44
  `claude` and finish the browser sign-in
45
45
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
46
- - _Optional:_ **Cursor Agent CLI** for Composer 2.5 Fast and Grok 4.5 Fast.
46
+ - _Optional:_ **Cursor Agent CLI** for Composer 2.5 Fast and the newest Grok high-fast.
47
47
  Ensure `agent` is on `PATH`, run `agent login`, and verify `agent models`
48
- lists `composer-2.5-fast` and `cursor-grok-4.5-high-fast`. COS exposes the
49
- Cursor slots only after both models resolve; it never silently substitutes
48
+ lists `composer-2.5-fast` and a `cursor-grok-*-high-fast` id. COS maps
49
+ `cursor-grok` to the newest high-fast it finds; it never silently substitutes
50
50
  Claude or Codex.
51
51
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
52
52
  - `brew install whisper-cpp` for free local voice (the launcher can download the model)
@@ -108,7 +108,7 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
108
108
  WebView reloads, and network handoffs, then reattaches without duplicate work
109
109
  or duplicate replies
110
110
  - Choose Opus, Fable, Sonnet, GPT Frontier, GPT Balanced, Composer 2.5 Fast, or
111
- Grok 4.5 Fast. Cursor slots fail closed when the local CLI or concrete model
111
+ the newest Grok high-fast. Cursor slots fail closed when the local CLI or concrete model
112
112
  is unavailable; optional redacted tool activity streams only to the
113
113
  authenticated query that requested it
114
114
  - Message History + cross-day "reference message N" — your chats are archived by day
@@ -180,7 +180,8 @@ machine-wide rollback for build 204+ server-owned query recovery),
180
180
  `COS_MEDIA_ROOT` (optional image/video store location; default
181
181
  `~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
182
182
  resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
183
- accepted 256 KiB chunks and finalize receipts across restarts; keep it off when
183
+ accepted original chunks (1 MiB on new sessions; leftover 256 KiB drafts keep
184
+ that size) and finalize receipts across restarts; keep it off when
184
185
  using an older companion. Your name + transcription vocabulary live in
185
186
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
186
187
  Factory example values are ignored; add the real names, companies, acronyms,
package/bin/cli.cjs CHANGED
@@ -179,11 +179,11 @@ function cursorCliState() {
179
179
  stdio: ['ignore', 'pipe', 'pipe'],
180
180
  timeout: 7000,
181
181
  })
182
- const required = ['composer-2.5-fast', 'cursor-grok-4.5-high-fast']
182
+ const grokHighFast = /cursor-grok-\d+(?:\.\d+)*-high-fast/.test(models)
183
183
  return {
184
184
  binary,
185
185
  version,
186
- auth: required.every((model) => models.includes(model)) ? 'ready' : 'models-unresolved',
186
+ auth: models.includes('composer-2.5-fast') && grokHighFast ? 'ready' : 'models-unresolved',
187
187
  }
188
188
  } catch (err) {
189
189
  const output = `${err.stdout?.toString() || ''}\n${err.stderr?.toString() || ''}`
@@ -230,13 +230,13 @@ if (codexVersion) {
230
230
  }
231
231
  if (cursor.binary) {
232
232
  if (cursor.auth === 'ready') {
233
- console.log(green(' ✓') + ` Cursor Agent ${cursor.version} ` + dim('(Composer 2.5 / Grok 4.5)'))
233
+ console.log(green(' ✓') + ` Cursor Agent ${cursor.version} ` + dim('(Composer 2.5 / newest Grok high-fast)'))
234
234
  } else if (cursor.auth === 'signed-out') {
235
235
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — sign-in required`)
236
236
  console.log(' Run: ' + bold('agent login'))
237
237
  } else if (cursor.auth === 'models-unresolved') {
238
238
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — required models unresolved`)
239
- console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and Grok 4.5 Fast')
239
+ console.log(' Verify: ' + bold('agent models') + ' includes Composer 2.5 Fast and a cursor-grok-*-high-fast id')
240
240
  } else {
241
241
  console.log(yellow(' ⚠') + ` Cursor Agent ${cursor.version} installed — readiness unavailable`)
242
242
  console.log(' Verify: ' + bold('agent models'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.27.5",
3
+ "version": "6.27.7",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -18,6 +18,7 @@ import { queryRouter } from './routes/query.js'
18
18
  import { providerProofRouter } from './routes/provider-proof.js'
19
19
  import { transcribeRouter } from './routes/transcribe.js'
20
20
  import { sessionIndexRouter } from './routes/session-index.js'
21
+ import { agentSessionsRouter } from './routes/agent-sessions.js'
21
22
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
22
23
  import { displayRouter } from './routes/display.js'
23
24
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
@@ -162,7 +163,8 @@ app.use('/api', requireApiToken(API_TOKEN))
162
163
  // through their true terminal boundary.
163
164
  app.use('/api', (req, res, next) => {
164
165
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
165
- // V2 video chunks are bounded to 256 KiB and commit through the upload
166
+ // V2 video original chunks are bounded to the advertised session size (1 MiB
167
+ // on new sessions, 256 KiB on leftover drafts) and commit through the upload
166
168
  // registry's own generation lock. Holding the global mutation lease while
167
169
  // the phone transfers the body recreated the exact 90-second drain failure
168
170
  // this protocol exists to remove. Admission is checked by the route before
@@ -273,6 +275,8 @@ app.use('/api', transcribeRouter)
273
275
  // Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
274
276
  // calling this and getting a 404 since the managed-runtime cutover.
275
277
  app.use('/api', sessionIndexRouter)
278
+ // Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
279
+ app.use('/api', agentSessionsRouter)
276
280
  // Presence view of Claude Code sessions on this Mac. Dark unless
277
281
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
278
282
  app.use('/api', claudeSessionsRouter)
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Sessions lookup for COS Control.
3
+ *
4
+ * Keyword: local title / sidebar name / first prompt / transcript scan. No model.
5
+ * Semantic: one OpenAI query embedding scored against those same texts.
6
+ * Sessions have no Qdrant collection — this is not meeting or memory search.
7
+ * The 7-day list window does not apply; older chats stay findable.
8
+ */
9
+
10
+ import { join } from 'node:path'
11
+ import { scoreKeywordMatch, tokenizeMeetingQuery } from './meeting-library-search.js'
12
+ import { tryGetOpenAIKey } from './openai-key.js'
13
+ import {
14
+ AGENT_SESSION_MAX_FILE_BYTES,
15
+ CLAUDE_UUID_JSONL,
16
+ agentSessionRoots,
17
+ createdFromCodexFilename,
18
+ dirents,
19
+ fileStat,
20
+ findClaudeDesktopFile,
21
+ firstClaudeUserTitle,
22
+ firstLineTitle,
23
+ idFromCodexFilename,
24
+ isKeepWarmSessionTitle,
25
+ isSkippedCursorFolder,
26
+ isWrapperPrompt,
27
+ isoFromMtime,
28
+ lastCustomTitle,
29
+ listCodexJsonlFiles,
30
+ loadClaudeStarredIds,
31
+ loadCodexPinnedIds,
32
+ loadCodexThreadNames,
33
+ loadCursorComposerNames,
34
+ loadCursorPinnedIds,
35
+ parseJsonLine,
36
+ payloadText,
37
+ peekClaudeDesktopHead,
38
+ peekCodexMeta,
39
+ preferCursorCopy,
40
+ readWindow,
41
+ workspaceLabel,
42
+ type AgentProvider,
43
+ type AgentSessionRoots,
44
+ type AgentSessionRow,
45
+ } from './agent-session-store.js'
46
+
47
+ const HEAD_TEXT = 8_000
48
+ const MAX_SCAN_FILES = 400
49
+ const SEMANTIC_DOC_CAP = 120
50
+ const SEMANTIC_MIN = 0.28
51
+ const EMBED_MODEL = 'text-embedding-3-small'
52
+ const EMBED_TIMEOUT_MS = 12_000
53
+ const EMBED_BATCH = 64
54
+
55
+ export interface AgentSessionSearchHit extends AgentSessionRow {
56
+ snippet: string
57
+ keywordScore: number
58
+ semanticScore: number
59
+ match: 'keyword' | 'semantic' | 'both'
60
+ }
61
+
62
+ export interface AgentSessionSearchResult {
63
+ hits: AgentSessionSearchHit[]
64
+ keywordCount: number
65
+ semanticCount: number
66
+ semanticAvailable: boolean
67
+ semanticReason?: string
68
+ }
69
+
70
+ export type EmbedTexts = (texts: string[]) => Promise<number[][] | { reason: string }>
71
+
72
+ interface SearchDoc {
73
+ row: AgentSessionRow
74
+ title: string
75
+ haystack: string
76
+ }
77
+
78
+ function clip(text: string, max = HEAD_TEXT): string {
79
+ return text.replace(/\s+/g, ' ').trim().slice(0, max)
80
+ }
81
+
82
+ function extractUserText(provider: AgentProvider, raw: string): string {
83
+ const parts: string[] = []
84
+ for (const line of raw.split('\n')) {
85
+ const obj = parseJsonLine(line)
86
+ if (!obj) continue
87
+ if (provider === 'claude') {
88
+ if (obj.isSidechain === true || obj.toolUseResult) continue
89
+ if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
90
+ parts.push(obj.customTitle)
91
+ continue
92
+ }
93
+ if (obj.type !== 'user') continue
94
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
95
+ const body = message ? payloadText(message) : null
96
+ if (body && !isWrapperPrompt(body)) parts.push(firstLineTitle(body) || body)
97
+ } else if (provider === 'codex') {
98
+ if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
99
+ const payload = obj.payload as Record<string, unknown>
100
+ if (payload.type !== 'message' || payload.role === 'developer' || payload.role === 'assistant') continue
101
+ const body = payloadText(payload)
102
+ if (body && !isWrapperPrompt(body)) parts.push(firstLineTitle(body) || body)
103
+ } else {
104
+ if (obj.role !== 'user') continue
105
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
106
+ const body = message ? payloadText(message) : null
107
+ if (!body) continue
108
+ const title = firstLineTitle(body)
109
+ if (title && !isWrapperPrompt(title)) parts.push(title)
110
+ }
111
+ if (parts.join('\n').length >= HEAD_TEXT) break
112
+ }
113
+ return clip(parts.join('\n'))
114
+ }
115
+
116
+ async function transcriptHaystack(provider: AgentProvider, path: string): Promise<string> {
117
+ const head = await readWindow(path, false)
118
+ const tail = await readWindow(path, true)
119
+ const raw = head === tail ? head : `${head}\n${tail}`
120
+ return extractUserText(provider, raw)
121
+ }
122
+
123
+ export function cosineSimilarity(a: number[], b: number[]): number {
124
+ if (a.length === 0 || a.length !== b.length) return 0
125
+ let dot = 0
126
+ let left = 0
127
+ let right = 0
128
+ for (let i = 0; i < a.length; i++) {
129
+ dot += a[i] * b[i]
130
+ left += a[i] * a[i]
131
+ right += b[i] * b[i]
132
+ }
133
+ if (left <= 0 || right <= 0) return 0
134
+ return dot / (Math.sqrt(left) * Math.sqrt(right))
135
+ }
136
+
137
+ export async function defaultEmbedTexts(texts: string[]): Promise<number[][] | { reason: string }> {
138
+ const key = tryGetOpenAIKey()
139
+ if (!key) return { reason: 'no_session_embeddings' }
140
+ if (texts.length === 0) return []
141
+ const vectors: number[][] = new Array(texts.length)
142
+ for (let offset = 0; offset < texts.length; offset += EMBED_BATCH) {
143
+ const slice = texts.slice(offset, offset + EMBED_BATCH).map(text => text.slice(0, HEAD_TEXT))
144
+ try {
145
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
146
+ method: 'POST',
147
+ headers: {
148
+ Authorization: `Bearer ${key}`,
149
+ 'Content-Type': 'application/json',
150
+ },
151
+ body: JSON.stringify({ model: EMBED_MODEL, input: slice }),
152
+ signal: AbortSignal.timeout(EMBED_TIMEOUT_MS),
153
+ })
154
+ if (!response.ok) return { reason: 'embeddings_unreachable' }
155
+ const parsed = await response.json() as { data?: Array<{ embedding?: number[]; index?: number }> }
156
+ const rows = Array.isArray(parsed.data) ? parsed.data : []
157
+ for (const row of rows) {
158
+ const index = typeof row.index === 'number' ? row.index : 0
159
+ if (Array.isArray(row.embedding)) vectors[offset + index] = row.embedding
160
+ }
161
+ } catch {
162
+ return { reason: 'embeddings_unreachable' }
163
+ }
164
+ }
165
+ if (vectors.some(row => !Array.isArray(row) || row.length === 0)) return { reason: 'embeddings_unreachable' }
166
+ return vectors
167
+ }
168
+
169
+ function pushDoc(docs: SearchDoc[], next: SearchDoc) {
170
+ const key = `${next.row.provider}:${next.row.session_id.toLowerCase()}`
171
+ const existing = docs.find(doc => `${doc.row.provider}:${doc.row.session_id.toLowerCase()}` === key)
172
+ if (!existing) {
173
+ docs.push(next)
174
+ return
175
+ }
176
+ if (next.haystack.length > existing.haystack.length) existing.haystack = next.haystack
177
+ if (next.title && (existing.title.endsWith(' session') || next.title.length > existing.title.length)) {
178
+ existing.title = next.title
179
+ existing.row = { ...existing.row, display_label: next.title }
180
+ }
181
+ }
182
+
183
+ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: { remaining: number }) {
184
+ const starred = await loadClaudeStarredIds(roots.claudeDesktopConfig)
185
+ for (const folder of await dirents(roots.claudeProjects)) {
186
+ const dir = join(roots.claudeProjects, folder)
187
+ for (const name of await dirents(dir)) {
188
+ if (!CLAUDE_UUID_JSONL.test(name) || budget.remaining <= 0) continue
189
+ const file = join(dir, name)
190
+ const st = await fileStat(file)
191
+ if (!st?.isFile) continue
192
+ budget.remaining -= 1
193
+ const native = name.slice(0, -6)
194
+ const custom = await lastCustomTitle(file)
195
+ const first = await firstClaudeUserTitle(file)
196
+ const users = await transcriptHaystack('claude', file)
197
+ const title = custom || first || 'Claude session'
198
+ if (isKeepWarmSessionTitle(title)) continue
199
+ const haystack = clip(`${title}\n${custom || ''}\n${first || ''}\n${workspaceLabel(folder)}\n${users}`)
200
+ pushDoc(docs, {
201
+ title,
202
+ haystack,
203
+ row: {
204
+ session_id: native,
205
+ provider: 'claude',
206
+ display_label: title,
207
+ project: workspaceLabel(folder),
208
+ modified: isoFromMtime(st.mtimeMs),
209
+ created: isoFromMtime(st.birthtimeMs),
210
+ alive: false,
211
+ state: 'recent',
212
+ pinned: starred.has(native.toLowerCase()),
213
+ },
214
+ })
215
+ }
216
+ }
217
+ for (const starredId of starred) {
218
+ if (docs.some(doc => doc.row.provider === 'claude' && doc.row.session_id.toLowerCase() === starredId)) continue
219
+ if (budget.remaining <= 0) break
220
+ const desktop = await findClaudeDesktopFile(roots.claudeCodeSessions, starredId)
221
+ if (!desktop) continue
222
+ const st = await fileStat(desktop)
223
+ if (!st?.isFile) continue
224
+ budget.remaining -= 1
225
+ const head = peekClaudeDesktopHead(await readWindow(desktop, false))
226
+ const title = head.title || 'Claude session'
227
+ if (isKeepWarmSessionTitle(title)) continue
228
+ pushDoc(docs, {
229
+ title,
230
+ haystack: clip(`${title}\n${head.cwd}\n${workspaceLabel(head.cwd)}`),
231
+ row: {
232
+ session_id: starredId,
233
+ provider: 'claude',
234
+ display_label: title,
235
+ project: workspaceLabel(head.cwd),
236
+ modified: isoFromMtime(st.mtimeMs),
237
+ created: isoFromMtime(st.birthtimeMs),
238
+ alive: false,
239
+ state: 'recent',
240
+ pinned: true,
241
+ },
242
+ })
243
+ }
244
+ }
245
+
246
+ async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: { remaining: number }) {
247
+ const names = await loadCodexThreadNames(roots.codexSessions)
248
+ const pinned = await loadCodexPinnedIds(roots.codexSessions)
249
+ for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
250
+ if (budget.remaining <= 0) break
251
+ const st = await fileStat(file)
252
+ if (!st?.isFile) continue
253
+ budget.remaining -= 1
254
+ const meta = await peekCodexMeta(file)
255
+ if (!meta || meta.subagent) continue
256
+ const name = file.split('/').pop() || file
257
+ const native = meta.id || name.slice(0, -6)
258
+ const thread = names.get(native) || ''
259
+ const title = thread || meta.title || 'Codex session'
260
+ if (isKeepWarmSessionTitle(title)) continue
261
+ const users = await transcriptHaystack('codex', file)
262
+ const created = meta.created || createdFromCodexFilename(name) || isoFromMtime(st.birthtimeMs)
263
+ pushDoc(docs, {
264
+ title,
265
+ haystack: clip(`${title}\n${thread}\n${meta.title}\n${meta.cwd}\n${users}`),
266
+ row: {
267
+ session_id: native,
268
+ provider: 'codex',
269
+ display_label: title,
270
+ project: workspaceLabel(meta.cwd),
271
+ modified: isoFromMtime(st.mtimeMs),
272
+ created,
273
+ alive: false,
274
+ state: 'recent',
275
+ pinned: pinned.has(native.toLowerCase()) || Boolean(idFromCodexFilename(name) && pinned.has(idFromCodexFilename(name)!)),
276
+ },
277
+ })
278
+ }
279
+ }
280
+
281
+ async function collectCursorDocs(
282
+ roots: AgentSessionRoots,
283
+ docs: SearchDoc[],
284
+ budget: { remaining: number },
285
+ now: Date,
286
+ ) {
287
+ const composerNames = await loadCursorComposerNames(roots.cursorComposerDb)
288
+ const pinnedIds = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
289
+ const byId = new Map<string, { file: string; project: string; mtimeMs: number; birthtimeMs: number }>()
290
+ for (const folder of await dirents(roots.cursorProjects)) {
291
+ if (isSkippedCursorFolder(folder)) continue
292
+ const transcripts = join(roots.cursorProjects, folder, 'agent-transcripts')
293
+ for (const sessionDir of await dirents(transcripts)) {
294
+ if (sessionDir === 'subagents') continue
295
+ if (folder === 'empty-window' && !pinnedIds.has(sessionDir.toLowerCase())) continue
296
+ const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
297
+ const st = await fileStat(file)
298
+ if (!st?.isFile) continue
299
+ if (st.size > AGENT_SESSION_MAX_FILE_BYTES && !pinnedIds.has(sessionDir.toLowerCase())) continue
300
+ const next = { file, project: workspaceLabel(folder), mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
301
+ const existing = byId.get(sessionDir)
302
+ byId.set(sessionDir, existing ? preferCursorCopy({ ...next, sessionDir }, { ...existing, sessionDir }) : next)
303
+ }
304
+ }
305
+ const ranked = [...byId.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
306
+ for (const [sessionDir, candidate] of ranked) {
307
+ if (budget.remaining <= 0) break
308
+ budget.remaining -= 1
309
+ const sidebar = composerNames.get(sessionDir) || ''
310
+ const users = await transcriptHaystack('cursor', candidate.file)
311
+ const title = sidebar || users.split('\n')[0] || 'Cursor session'
312
+ if (isKeepWarmSessionTitle(title)) continue
313
+ pushDoc(docs, {
314
+ title,
315
+ haystack: clip(`${title}\n${sidebar}\n${candidate.project}\n${users}`),
316
+ row: {
317
+ session_id: sessionDir,
318
+ provider: 'cursor',
319
+ display_label: title,
320
+ project: candidate.project,
321
+ modified: isoFromMtime(candidate.mtimeMs),
322
+ created: isoFromMtime(candidate.birthtimeMs),
323
+ alive: now.getTime() - candidate.mtimeMs < 180_000,
324
+ state: now.getTime() - candidate.mtimeMs < 180_000 ? 'running' : 'recent',
325
+ pinned: pinnedIds.has(sessionDir.toLowerCase()),
326
+ },
327
+ })
328
+ }
329
+ }
330
+
331
+ export async function collectAgentSessionSearchDocs(
332
+ roots: AgentSessionRoots = agentSessionRoots(),
333
+ cap = MAX_SCAN_FILES,
334
+ now = new Date(),
335
+ ): Promise<SearchDoc[]> {
336
+ const docs: SearchDoc[] = []
337
+ const share = Math.max(1, Math.ceil(Math.min(cap, MAX_SCAN_FILES) / 3))
338
+ await collectClaudeDocs(roots, docs, { remaining: share })
339
+ await collectCodexDocs(roots, docs, { remaining: share })
340
+ await collectCursorDocs(roots, docs, { remaining: share }, now)
341
+ docs.sort((a, b) => (b.row.modified || '').localeCompare(a.row.modified || ''))
342
+ return docs.slice(0, Math.max(1, Math.min(cap, MAX_SCAN_FILES)))
343
+ }
344
+
345
+ export function keywordSearchSessions(docs: SearchDoc[], query: string): AgentSessionSearchHit[] {
346
+ const tokens = tokenizeMeetingQuery(query)
347
+ if (tokens.length === 0) return []
348
+ const hits: AgentSessionSearchHit[] = []
349
+ for (const doc of docs) {
350
+ const scored = scoreKeywordMatch(tokens, doc.title, doc.haystack)
351
+ if (scored.score <= 0) continue
352
+ hits.push({
353
+ ...doc.row,
354
+ snippet: scored.snippet || doc.title,
355
+ keywordScore: scored.score,
356
+ semanticScore: 0,
357
+ match: 'keyword',
358
+ })
359
+ }
360
+ return hits.sort((a, b) => b.keywordScore - a.keywordScore)
361
+ }
362
+
363
+ export async function semanticSearchSessions(
364
+ docs: SearchDoc[],
365
+ query: string,
366
+ limit: number,
367
+ embedTexts: EmbedTexts = defaultEmbedTexts,
368
+ ): Promise<{ hits: AgentSessionSearchHit[]; reason?: string }> {
369
+ const pool = docs.slice(0, SEMANTIC_DOC_CAP)
370
+ if (pool.length === 0) return { hits: [] }
371
+ const embedded = await embedTexts([query, ...pool.map(doc => clip(`${doc.title}\n${doc.haystack}`, 1_500))])
372
+ if (!Array.isArray(embedded)) return { hits: [], reason: embedded.reason }
373
+ const [queryVec, ...docVecs] = embedded
374
+ if (!queryVec) return { hits: [], reason: 'embeddings_unreachable' }
375
+ const hits: AgentSessionSearchHit[] = []
376
+ for (let i = 0; i < pool.length; i++) {
377
+ const score = cosineSimilarity(queryVec, docVecs[i] || [])
378
+ if (score < SEMANTIC_MIN) continue
379
+ const doc = pool[i]
380
+ hits.push({
381
+ ...doc.row,
382
+ snippet: clip(doc.haystack, 180),
383
+ keywordScore: 0,
384
+ semanticScore: Math.max(0, Math.min(1, score)),
385
+ match: 'semantic',
386
+ })
387
+ }
388
+ return {
389
+ hits: hits.sort((a, b) => b.semanticScore - a.semanticScore).slice(0, Math.max(1, Math.min(limit, 50))),
390
+ }
391
+ }
392
+
393
+ export function mergeSessionSearchHits(
394
+ keywordHits: AgentSessionSearchHit[],
395
+ semanticHits: AgentSessionSearchHit[],
396
+ limit: number,
397
+ ): AgentSessionSearchHit[] {
398
+ const merged = new Map<string, AgentSessionSearchHit>()
399
+ const keyFor = (hit: AgentSessionSearchHit) => `${hit.provider}:${hit.session_id.toLowerCase()}`
400
+ for (const hit of keywordHits) merged.set(keyFor(hit), { ...hit })
401
+ for (const hit of semanticHits) {
402
+ const key = keyFor(hit)
403
+ const existing = merged.get(key)
404
+ if (!existing) {
405
+ merged.set(key, hit)
406
+ continue
407
+ }
408
+ merged.set(key, {
409
+ ...existing,
410
+ snippet: existing.snippet || hit.snippet,
411
+ semanticScore: Math.max(existing.semanticScore, hit.semanticScore),
412
+ match: existing.keywordScore > 0 && hit.semanticScore > 0 ? 'both' : existing.match,
413
+ })
414
+ }
415
+ return [...merged.values()]
416
+ .sort((a, b) => {
417
+ const bothDelta = Number(b.match === 'both') - Number(a.match === 'both')
418
+ if (bothDelta) return bothDelta
419
+ return Math.max(b.keywordScore, b.semanticScore) - Math.max(a.keywordScore, a.semanticScore)
420
+ })
421
+ .slice(0, Math.max(1, Math.min(limit, 50)))
422
+ }
423
+
424
+ export async function searchAgentSessions(options: {
425
+ query: string
426
+ limit?: number
427
+ roots?: AgentSessionRoots
428
+ embedTexts?: EmbedTexts
429
+ now?: Date
430
+ }): Promise<AgentSessionSearchResult> {
431
+ const query = options.query.trim()
432
+ const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
433
+ const docs = await collectAgentSessionSearchDocs(
434
+ options.roots ?? agentSessionRoots(),
435
+ MAX_SCAN_FILES,
436
+ options.now,
437
+ )
438
+ const keywordHits = keywordSearchSessions(docs, query)
439
+ const semantic = await semanticSearchSessions(docs, query, limit, options.embedTexts ?? defaultEmbedTexts)
440
+ return {
441
+ hits: mergeSessionSearchHits(keywordHits, semantic.hits, limit),
442
+ keywordCount: keywordHits.length,
443
+ semanticCount: semantic.hits.length,
444
+ semanticAvailable: !semantic.reason,
445
+ ...(semantic.reason ? { semanticReason: semantic.reason } : {}),
446
+ }
447
+ }