@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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,109 @@
1
+ ## Unreleased
2
+
3
+ ## 6.27.9
4
+ - **Large sessions open instead of 413ing.** `GET /api/agent-sessions/:provider/:id`
5
+ answered `413 Session too large to open` for any transcript over 32 MiB, so the
6
+ biggest sessions — the ones most worth reviewing before a follow-up — returned
7
+ nothing at all. A 67 MB transcript is not exotic; this repo's own 2026-08-13 session
8
+ is 70 MB. Oversized files are now read as a bounded **head (256 KiB) + tail
9
+ (768 KiB)**. That 70 MB session parses in **7 ms** and yields a 1,286-char digest
10
+ with both the opening ask and the most recent turns.
11
+ - Slicing at an arbitrary byte offset is safe because `parseJsonLine` returns null for
12
+ the fragmentary first line of the tail window and the loop skips it.
13
+ - **`truncated` is reported, and the counts stop pretending.** On a partial read the
14
+ message counts are counts of what was READ. The digest therefore prints
15
+ `… middle of a large session not read …` instead of a turn number it cannot know —
16
+ a confidently wrong "… 12 earlier turns …" on a 4,000-turn session is the same
17
+ dishonesty as a silent cap.
18
+ - **Slash-command scaffolding no longer eats the two best slots.** `collectTurn` now
19
+ applies the existing `isWrapperPrompt` filter and `<user_query>` stripping, so a
20
+ digest opens with the real ask rather than `<command-message>…`.
21
+ - Window sizes are injectable. With production defaults any quick-to-build fixture is
22
+ smaller than head+tail combined, so a test would read the whole file and pass
23
+ identically with windowing REMOVED — it did, until a mutation caught it. The test
24
+ now pins the read count and fails when windowing is dropped.
25
+
26
+ ## 6.27.8
27
+ - **Session bodies get a real digest.** `GET /api/agent-sessions/:provider/:id` adds
28
+ `discussion_digest`: up to **2000 chars** of what actually happened — the opening
29
+ ask, the most recent user turns in order, and where the assistant left off.
30
+ - **The list row is untouched at 180.** Miles: "it should be in the body not the
31
+ title, the row should be no more than the 180 characters." `discussion_summary`
32
+ keeps its 180-char budget for the single-line row; the digest is a separate field
33
+ the detail page reads. One shared field could not serve both — a 2000-char gist
34
+ appended to a row destroys it.
35
+ - **No LLM, no extra reads.** `parseAgentSession` already streams every line of the
36
+ transcript to count turns; it was discarding the middle. The digest is assembled
37
+ from turns it is already parsing, so it costs no tokens and no additional I/O.
38
+ - **Elision is stated, never silent.** The store keeps the opening turns plus a
39
+ 60-turn recent window so a 900-turn session cannot balloon memory, and passes the
40
+ TRUE turn count so the `… N earlier turns …` line reports what was really dropped
41
+ rather than what the buffer happened to hold.
42
+ - Opening turns are reserved BEFORE recency. Filling from the end first starved the
43
+ original ask out of a 40-turn session entirely — caught by its own test.
44
+ - Older clients ignore the field; older servers omit it and the glasses fall back to
45
+ the 180-char summary.
46
+
47
+ ## 6.27.7
48
+ - **`GET /api/agent-sessions` ships in the public package.** Claude Code, Codex,
49
+ and Cursor transcripts from this Mac, last 7 days of writes. Glasses 6.8.360
50
+ uses this instead of the Claude-only COS cache. Stock 6.27.6 404s that route.
51
+ - **Sessions list matches Control Updated.** Newest write first. Stale pins stay
52
+ in the payload (any age) but do not cluster at the top — that is Control's
53
+ Pinned clock, which glasses does not have.
54
+ - **Session discussion gist.** Agent-session list and detail include
55
+ `discussion_summary`: first real user turn plus the latest assistant prose
56
+ from a cheap transcript peek. No LLM. Glasses use it on the session row and
57
+ detail; older clients ignore the field. `first_prompt` on the list is the
58
+ first user turn, not a copy of the sidebar title.
59
+ - **Sessions lookup.** `GET /api/agent-sessions/search?q=` runs keyword over
60
+ sidebar names, `/rename` titles, first prompts, and the first ~8k of user
61
+ transcript — including chats older than the 7-day list window. Meaning search
62
+ embeds the query once against those same texts (OpenAI key, no LLM). Keyword
63
+ still returns if embeddings are down. Literal path, registered before
64
+ `/agent-sessions/:provider/:sessionId`.
65
+ - **Sessions Pinned includes Claude Desktop stars and Cursor
66
+ `pinnedComposers`.** Same rule as ChatGPT `pinned-thread-ids`: starred
67
+ Claude sessions (including Desktop-only blobs with no `~/.claude` jsonl)
68
+ and Cursor sidebar pins stay in the list at any age. Keep-warm `ready`
69
+ rows still stay out.
70
+ - **Sessions hide CLI keep-warm `ready` rows** and Control provider-proof
71
+ prompts so real chats fill the list.
72
+ - **Sessions keep ChatGPT pins and Cursor sidebar names.** Codex
73
+ `pinned-thread-ids` stay in the list even when the jsonl is weeks old.
74
+ Cursor rows use `composerHeaders.name` (the sidebar title) and skip the
75
+ `empty-window` duplicate of the same chat.
76
+ - **Memory and Threads lookup.** `GET /api/memory/search?q=` and
77
+ `GET /api/threads/search?q=` run keyword over local notes (first ~8k of each
78
+ file, ~2k file budget) then, for memories only, one embedding against the
79
+ existing `cos_memory` index via `bot_memory.py`. Threads have no embedding
80
+ index — `semanticAvailable` is false and keyword still works. Literal paths,
81
+ registered before `/:id`. Does not search meeting Qdrant. Additive; list and
82
+ detail are unchanged.
83
+ - **Meeting library calendar filters.** `GET /api/meetings` accepts `month=YYYY-MM`
84
+ and `day=YYYY-MM-DD`, raises the cap to 200 when either is set, and returns
85
+ `months` plus per-day counts for that month. Unfiltered G2 lists stay at the
86
+ existing 50-row cap. Additive — extra fields are ignored by older clients.
87
+ - **Meeting lookup.** `GET /api/meetings/search?q=` runs keyword over title,
88
+ summary, and filename, then meaning search against the existing Qdrant
89
+ meeting index (one query embedding, no LLM). Keyword still returns if Qdrant
90
+ is down. Literal path, registered before `/meetings/:domain/:month/:filename`.
91
+ - **Reset live message count.** `POST /api/message-era/reset` with `{ confirm: true }`
92
+ snapshots live sessions into the day archive, then starts short-numbering at
93
+ #1. History is not deleted — ARCHIVE / Message History still resolve old
94
+ stamps. Refuses without confirm, while a query is in flight, or if archive
95
+ fails. Disk mtime is enough; no server restart. CLI
96
+ `reset-message-era.ts --confirm` uses the same path.
97
+ - **Grok slot tracks newest high-fast.** `cursor-grok` now resolves to the
98
+ newest `cursor-grok-<ver>-high-fast` from `agent models` (today 4.6). Low,
99
+ medium, xhigh, and non-fast ids are ignored. Composer stays pinned to
100
+ `composer-2.5-fast`. No EHPK change: the phone still sends the stable slot.
101
+ - **Clear stranded video uploads.** Sideload, crash, or a killed composer can
102
+ leave a `receiving` draft for 4 hours. That draft holds `blocksRestart`, so
103
+ Repair and Update stall on it instead of clearing it. `POST /api/media/video-upload/clear-stranded`
104
+ cancels receiving drafts with no active writer and no bytes for 60 seconds.
105
+ Finalizing and published receipts are left alone.
106
+
1
107
  ## 6.27.6
2
108
  - **V2 original chunks are 1 MiB.** Same sequential one-in-flight loop, same
3
109
  ArrayBuffer bodies, same GET-progress resume. A 244 MB clip goes from 953
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
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.6",
3
+ "version": "6.27.9",
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'
@@ -274,6 +275,8 @@ app.use('/api', transcribeRouter)
274
275
  // Ported from cos-glasses-app in 6.24.0. The companion's Sessions tab has been
275
276
  // calling this and getting a 404 since the managed-runtime cutover.
276
277
  app.use('/api', sessionIndexRouter)
278
+ // Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
279
+ app.use('/api', agentSessionsRouter)
277
280
  // Presence view of Claude Code sessions on this Mac. Dark unless
278
281
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
279
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
+ }