@gotcos/glasses-server 6.27.6 → 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 +62 -0
- package/README.md +4 -4
- package/bin/cli.cjs +4 -4
- package/package.json +1 -1
- package/server/index.ts +3 -0
- 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 +59 -0
- 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
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
// Local Claude / Codex / Cursor transcripts on this Mac.
|
|
2
|
+
// Default window is 7 days of last write (mtime). Codex Desktop pins keep
|
|
3
|
+
// the original YYYY/MM/DD folder, so listing by calendar path misses them.
|
|
4
|
+
// `sort=opened` uses session start instead. Same clocks as COS Control.
|
|
5
|
+
// The glasses companion cannot read these directories; the server can.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { createReadStream } from 'node:fs'
|
|
9
|
+
import { lstat, readdir, readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { createInterface } from 'node:readline'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile)
|
|
16
|
+
|
|
17
|
+
export const AGENT_SESSION_WINDOW_HOURS = 7 * 24
|
|
18
|
+
export const AGENT_SESSION_MAX_AGE_MS = AGENT_SESSION_WINDOW_HOURS * 3600 * 1000
|
|
19
|
+
export const AGENT_SESSION_MAX_FILE_BYTES = 32 * 1024 * 1024
|
|
20
|
+
export const AGENT_SESSION_PER_PROVIDER_LIMIT = 20
|
|
21
|
+
export const AGENT_SESSION_LIST_LIMIT = AGENT_SESSION_PER_PROVIDER_LIMIT * 3
|
|
22
|
+
export const AGENT_SESSION_LIST_MAX = 80
|
|
23
|
+
const HEAD_BYTES = 256 * 1024
|
|
24
|
+
export const CLAUDE_UUID_JSONL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/
|
|
25
|
+
const CODEX_ROLLOUT_STAMP = /^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-/
|
|
26
|
+
|
|
27
|
+
export type AgentProvider = 'claude' | 'codex' | 'cursor'
|
|
28
|
+
export type AgentSessionSort = 'updated' | 'opened'
|
|
29
|
+
|
|
30
|
+
export interface AgentSessionRow {
|
|
31
|
+
session_id: string
|
|
32
|
+
provider: AgentProvider
|
|
33
|
+
display_label: string
|
|
34
|
+
project: string
|
|
35
|
+
modified: string
|
|
36
|
+
created: string
|
|
37
|
+
alive: boolean
|
|
38
|
+
state: 'running' | 'recent'
|
|
39
|
+
pinned: boolean
|
|
40
|
+
first_prompt?: string
|
|
41
|
+
discussion_summary?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AgentSessionRoots {
|
|
45
|
+
claudeProjects: string
|
|
46
|
+
claudeDesktopConfig: string
|
|
47
|
+
claudeCodeSessions: string
|
|
48
|
+
codexSessions: string
|
|
49
|
+
cursorProjects: string
|
|
50
|
+
cursorComposerDb: string
|
|
51
|
+
cursorWorkspaceStorage: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME || homedir()): AgentSessionRoots {
|
|
55
|
+
return {
|
|
56
|
+
claudeProjects: join(home, '.claude', 'projects'),
|
|
57
|
+
claudeDesktopConfig: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
58
|
+
claudeCodeSessions: join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions'),
|
|
59
|
+
codexSessions: join(home, '.codex', 'sessions'),
|
|
60
|
+
cursorProjects: join(home, '.cursor', 'projects'),
|
|
61
|
+
cursorComposerDb: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
|
|
62
|
+
cursorWorkspaceStorage: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isSafeSessionId(value: string): boolean {
|
|
67
|
+
const needle = value.trim().toLowerCase()
|
|
68
|
+
return needle.length >= 8
|
|
69
|
+
&& !needle.includes('..')
|
|
70
|
+
&& !needle.includes('/')
|
|
71
|
+
&& [...needle].every(ch => /[0-9a-f-]/.test(ch))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function workspaceLabel(raw: string): string {
|
|
75
|
+
const trimmed = raw.trim()
|
|
76
|
+
if (!trimmed) return ''
|
|
77
|
+
if (trimmed.startsWith('/')) {
|
|
78
|
+
const parts = trimmed.split('/').filter(Boolean)
|
|
79
|
+
return parts[parts.length - 1] || ''
|
|
80
|
+
}
|
|
81
|
+
const encoded = trimmed.startsWith('-') ? trimmed.slice(1) : trimmed
|
|
82
|
+
if (encoded.includes('MU-Chief-Staff')) return 'MU-Chief-Staff'
|
|
83
|
+
const github = encoded.lastIndexOf('GitHub-')
|
|
84
|
+
if (github >= 0) {
|
|
85
|
+
const rest = encoded.slice(github + 'GitHub-'.length)
|
|
86
|
+
if (rest === 'MU-Chief-Staff' || rest.endsWith('-MU-Chief-Staff')) return 'MU-Chief-Staff'
|
|
87
|
+
return rest
|
|
88
|
+
}
|
|
89
|
+
const parts = trimmed.split('/').filter(Boolean)
|
|
90
|
+
return parts[parts.length - 1] || trimmed
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isScratchCursorProject(folder: string): boolean {
|
|
94
|
+
const name = folder.split('/').filter(Boolean).pop()?.toLowerCase() || folder.toLowerCase()
|
|
95
|
+
return name.includes('var-folders') || name.includes('private-var') || name === 'empty-window'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const isSkippedCursorFolder = isScratchCursorProject
|
|
99
|
+
|
|
100
|
+
export function isKeepWarmSessionTitle(title: string): boolean {
|
|
101
|
+
const t = title.trim().toLowerCase()
|
|
102
|
+
if (t === 'ready') return true
|
|
103
|
+
return t.startsWith('this is an automated local readiness check')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isWrapperPrompt(text: string): boolean {
|
|
107
|
+
const trimmed = text.trim()
|
|
108
|
+
return trimmed.startsWith('<')
|
|
109
|
+
|| trimmed.startsWith('SYSTEM INSTRUCTIONS')
|
|
110
|
+
|| trimmed.startsWith('You are an agent')
|
|
111
|
+
|| trimmed.startsWith('You are QA Agent')
|
|
112
|
+
|| trimmed.startsWith('Message Type:')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function firstLineTitle(text: string): string {
|
|
116
|
+
let body = text
|
|
117
|
+
const start = body.indexOf('<user_query>')
|
|
118
|
+
const end = body.indexOf('</user_query>')
|
|
119
|
+
if (start >= 0 && end > start) body = body.slice(start + '<user_query>'.length, end)
|
|
120
|
+
body = body.replace(/<[^>]+>/g, ' ')
|
|
121
|
+
const line = body.split('\n').map(s => s.trim()).find(s => s.length > 0) ?? ''
|
|
122
|
+
return line.slice(0, 80)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function proseSnippet(text: string, max = 160): string {
|
|
126
|
+
let body = text.replace(/```[\s\S]*?```/g, ' ')
|
|
127
|
+
body = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
128
|
+
if (!body || isWrapperPrompt(body)) return ''
|
|
129
|
+
return body.slice(0, max)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function composeDiscussionSummary(input: {
|
|
133
|
+
title?: string
|
|
134
|
+
firstPrompt?: string
|
|
135
|
+
latestAssistant?: string
|
|
136
|
+
max?: number
|
|
137
|
+
}): string {
|
|
138
|
+
const max = input.max ?? 180
|
|
139
|
+
const title = (input.title ?? '').replace(/\s+/g, ' ').trim()
|
|
140
|
+
const first = firstLineTitle(input.firstPrompt ?? '')
|
|
141
|
+
const latest = (input.latestAssistant ?? '').replace(/\s+/g, ' ').trim()
|
|
142
|
+
const opening = first && first !== title ? first : ''
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
if (opening) parts.push(opening)
|
|
145
|
+
if (latest && latest !== title && latest !== opening) parts.push(latest)
|
|
146
|
+
const joined = parts.join(' · ')
|
|
147
|
+
if (!joined) return ''
|
|
148
|
+
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}…`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
|
|
152
|
+
if (obj.type === 'assistant') {
|
|
153
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
154
|
+
const text = message ? payloadText(message) : null
|
|
155
|
+
return text ? proseSnippet(text) || null : null
|
|
156
|
+
}
|
|
157
|
+
if (obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
|
|
158
|
+
const payload = obj.payload as Record<string, unknown>
|
|
159
|
+
if (payload.role === 'assistant' && (payload.type === 'message' || !payload.type)) {
|
|
160
|
+
const text = payloadText(payload)
|
|
161
|
+
return text ? proseSnippet(text) || null : null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (obj.role === 'assistant') {
|
|
165
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
166
|
+
const text = message ? payloadText(message) : null
|
|
167
|
+
return text ? proseSnippet(text) || null : null
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function latestAssistantFromWindow(text: string): string {
|
|
173
|
+
let latest = ''
|
|
174
|
+
for (const line of text.split('\n')) {
|
|
175
|
+
const obj = parseJsonLine(line)
|
|
176
|
+
if (!obj) continue
|
|
177
|
+
const prose = assistantProseFromRecord(obj)
|
|
178
|
+
if (prose) latest = prose
|
|
179
|
+
}
|
|
180
|
+
return latest
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function discussionFields(title: string, firstPrompt: string, latestAssistant: string): {
|
|
184
|
+
first_prompt: string
|
|
185
|
+
discussion_summary: string
|
|
186
|
+
} {
|
|
187
|
+
return {
|
|
188
|
+
first_prompt: firstPrompt,
|
|
189
|
+
discussion_summary: composeDiscussionSummary({ title, firstPrompt, latestAssistant }),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function cursorUserTitle(body: string, requireQuery: boolean): string | null {
|
|
194
|
+
if (requireQuery && !body.includes('<user_query>')) return null
|
|
195
|
+
const title = firstLineTitle(body)
|
|
196
|
+
if (!title || isWrapperPrompt(title)) return null
|
|
197
|
+
return title
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function payloadText(payload: Record<string, unknown>): string | null {
|
|
201
|
+
const content = payload.content
|
|
202
|
+
if (typeof content === 'string') {
|
|
203
|
+
const trimmed = content.trim()
|
|
204
|
+
return trimmed ? trimmed : null
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(content)) return null
|
|
207
|
+
const parts: string[] = []
|
|
208
|
+
for (const block of content) {
|
|
209
|
+
if (!block || typeof block !== 'object') continue
|
|
210
|
+
const type = String((block as { type?: unknown }).type ?? '')
|
|
211
|
+
if (type === 'tool_result' || type === 'tool_use' || type === 'thinking') continue
|
|
212
|
+
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
|
213
|
+
const text = String((block as { text?: unknown }).text ?? '').trim()
|
|
214
|
+
if (text) parts.push(text)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const joined = parts.join('\n\n').trim()
|
|
218
|
+
return joined || null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isoFromMtime(mtimeMs: number): string {
|
|
222
|
+
return new Date(mtimeMs).toISOString()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function row(partial: Omit<AgentSessionRow, 'created' | 'state' | 'pinned'> & {
|
|
226
|
+
created?: string
|
|
227
|
+
state?: AgentSessionRow['state']
|
|
228
|
+
pinned?: boolean
|
|
229
|
+
}): AgentSessionRow {
|
|
230
|
+
return {
|
|
231
|
+
...partial,
|
|
232
|
+
created: partial.created ?? partial.modified,
|
|
233
|
+
state: partial.state ?? (partial.alive ? 'running' : 'recent'),
|
|
234
|
+
pinned: partial.pinned ?? false,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function idFromCodexFilename(name: string): string | null {
|
|
239
|
+
if (!name.endsWith('.jsonl')) return null
|
|
240
|
+
const parts = name.slice(0, -6).split('-')
|
|
241
|
+
if (parts.length < 5) return null
|
|
242
|
+
const uuid = parts.slice(-5).join('-').toLowerCase()
|
|
243
|
+
return uuid.length >= 36 ? uuid : null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function dirents(dir: string): Promise<string[]> {
|
|
247
|
+
try {
|
|
248
|
+
return await readdir(dir)
|
|
249
|
+
} catch {
|
|
250
|
+
return []
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function fileStat(path: string): Promise<{ mtimeMs: number; birthtimeMs: number; size: number; isFile: boolean } | null> {
|
|
255
|
+
try {
|
|
256
|
+
const st = await lstat(path)
|
|
257
|
+
return {
|
|
258
|
+
mtimeMs: st.mtimeMs,
|
|
259
|
+
birthtimeMs: st.birthtimeMs || st.mtimeMs,
|
|
260
|
+
size: st.size,
|
|
261
|
+
isFile: st.isFile(),
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function readWindow(path: string, fromEnd: boolean): Promise<string> {
|
|
269
|
+
const st = await fileStat(path)
|
|
270
|
+
if (!st?.isFile || st.size <= 0) return ''
|
|
271
|
+
let start = 0
|
|
272
|
+
let end = st.size - 1
|
|
273
|
+
if (st.size > HEAD_BYTES) {
|
|
274
|
+
if (fromEnd) start = st.size - HEAD_BYTES
|
|
275
|
+
else end = HEAD_BYTES - 1
|
|
276
|
+
}
|
|
277
|
+
const stream = createReadStream(path, { start, end })
|
|
278
|
+
const chunks: Buffer[] = []
|
|
279
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
280
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function parseJsonLine(line: string): Record<string, unknown> | null {
|
|
284
|
+
try {
|
|
285
|
+
const obj = JSON.parse(line) as unknown
|
|
286
|
+
return obj && typeof obj === 'object' ? obj as Record<string, unknown> : null
|
|
287
|
+
} catch {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function lastCustomTitle(path: string): Promise<string | null> {
|
|
293
|
+
const text = await readWindow(path, true)
|
|
294
|
+
let found: string | null = null
|
|
295
|
+
for (const line of text.split('\n')) {
|
|
296
|
+
if (!line.includes('custom-title')) continue
|
|
297
|
+
const obj = parseJsonLine(line)
|
|
298
|
+
if (obj?.type !== 'custom-title') continue
|
|
299
|
+
const title = String(obj.customTitle ?? '').trim()
|
|
300
|
+
if (title) found = title.slice(0, 120)
|
|
301
|
+
}
|
|
302
|
+
return found
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function peekClaudeDiscussion(path: string): Promise<{ customTitle: string | null; latestAssistant: string }> {
|
|
306
|
+
const text = await readWindow(path, true)
|
|
307
|
+
let customTitle: string | null = null
|
|
308
|
+
for (const line of text.split('\n')) {
|
|
309
|
+
if (line.includes('custom-title')) {
|
|
310
|
+
const obj = parseJsonLine(line)
|
|
311
|
+
if (obj?.type === 'custom-title') {
|
|
312
|
+
const title = String(obj.customTitle ?? '').trim()
|
|
313
|
+
if (title) customTitle = title.slice(0, 120)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { customTitle, latestAssistant: latestAssistantFromWindow(text) }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function firstClaudeUserTitle(path: string): Promise<string | null> {
|
|
321
|
+
const text = await readWindow(path, false)
|
|
322
|
+
for (const line of text.split('\n')) {
|
|
323
|
+
const obj = parseJsonLine(line)
|
|
324
|
+
if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) continue
|
|
325
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
326
|
+
const body = message ? payloadText(message) : null
|
|
327
|
+
if (!body) continue
|
|
328
|
+
const title = firstLineTitle(body)
|
|
329
|
+
if (title) return title
|
|
330
|
+
}
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function createdFromCodexFilename(name: string): string | null {
|
|
335
|
+
const match = CODEX_ROLLOUT_STAMP.exec(name)
|
|
336
|
+
if (!match) return null
|
|
337
|
+
const stamp = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}`)
|
|
338
|
+
return Number.isFinite(stamp.getTime()) ? stamp.toISOString() : null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function loadCodexThreadNames(sessionsRoot: string): Promise<Map<string, string>> {
|
|
342
|
+
const names = new Map<string, string>()
|
|
343
|
+
const indexPath = join(sessionsRoot, '..', 'session_index.jsonl')
|
|
344
|
+
try {
|
|
345
|
+
const rl = createInterface({ input: createReadStream(indexPath), crlfDelay: Infinity })
|
|
346
|
+
for await (const line of rl) {
|
|
347
|
+
const obj = parseJsonLine(line)
|
|
348
|
+
if (!obj) continue
|
|
349
|
+
const id = String(obj.id ?? '').trim()
|
|
350
|
+
const name = String(obj.thread_name ?? '').trim()
|
|
351
|
+
if (id && name) names.set(id, name.slice(0, 120))
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
return names
|
|
355
|
+
}
|
|
356
|
+
return names
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function loadCodexPinnedIds(sessionsRoot: string): Promise<Set<string>> {
|
|
360
|
+
const pinned = new Set<string>()
|
|
361
|
+
const statePath = join(sessionsRoot, '..', '.codex-global-state.json')
|
|
362
|
+
try {
|
|
363
|
+
const raw = await readFile(statePath, 'utf8')
|
|
364
|
+
const obj = JSON.parse(raw) as { 'pinned-thread-ids'?: unknown }
|
|
365
|
+
const list = obj['pinned-thread-ids']
|
|
366
|
+
if (!Array.isArray(list)) return pinned
|
|
367
|
+
for (const value of list) {
|
|
368
|
+
const id = String(value ?? '').trim().toLowerCase()
|
|
369
|
+
if (id) pinned.add(id)
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
return pinned
|
|
373
|
+
}
|
|
374
|
+
return pinned
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function normalizeClaudeSessionId(raw: string): string {
|
|
378
|
+
let id = raw.trim().toLowerCase()
|
|
379
|
+
if (id.startsWith('local_')) id = id.slice('local_'.length)
|
|
380
|
+
return id
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function peekClaudeDesktopHead(text: string): { title: string; cwd: string } {
|
|
384
|
+
const unescape = (value: string) => value.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
385
|
+
const title = /"title"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
|
|
386
|
+
const cwd = /"cwd"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
|
|
387
|
+
return {
|
|
388
|
+
title: title ? unescape(title[1]).slice(0, 120) : '',
|
|
389
|
+
cwd: cwd ? unescape(cwd[1]) : '',
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function loadClaudeStarredIds(configPath: string): Promise<Set<string>> {
|
|
394
|
+
const starred = new Set<string>()
|
|
395
|
+
if (!configPath) return starred
|
|
396
|
+
try {
|
|
397
|
+
const obj = JSON.parse(await readFile(configPath, 'utf8')) as {
|
|
398
|
+
preferences?: { epitaxyPrefs?: { 'starred-local-code-sessions'?: unknown } }
|
|
399
|
+
}
|
|
400
|
+
const list = obj.preferences?.epitaxyPrefs?.['starred-local-code-sessions']
|
|
401
|
+
if (!Array.isArray(list)) return starred
|
|
402
|
+
for (const value of list) {
|
|
403
|
+
const id = normalizeClaudeSessionId(String(value ?? ''))
|
|
404
|
+
if (id) starred.add(id)
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
return starred
|
|
408
|
+
}
|
|
409
|
+
return starred
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function parsePinnedComposerList(raw: string): string[] {
|
|
413
|
+
try {
|
|
414
|
+
const parsed = JSON.parse(raw) as unknown
|
|
415
|
+
const list = Array.isArray(parsed)
|
|
416
|
+
? parsed
|
|
417
|
+
: parsed && typeof parsed === 'object'
|
|
418
|
+
? (parsed as { composerIds?: unknown; ids?: unknown }).composerIds
|
|
419
|
+
?? (parsed as { ids?: unknown }).ids
|
|
420
|
+
: null
|
|
421
|
+
if (!Array.isArray(list)) return []
|
|
422
|
+
return list.map(value => String(value ?? '').trim().toLowerCase()).filter(Boolean)
|
|
423
|
+
} catch {
|
|
424
|
+
return []
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function loadCursorPinnedIds(workspaceStorage: string): Promise<Set<string>> {
|
|
429
|
+
const pinned = new Set<string>()
|
|
430
|
+
if (!workspaceStorage) return pinned
|
|
431
|
+
for (const folder of await dirents(workspaceStorage)) {
|
|
432
|
+
const dbPath = join(workspaceStorage, folder, 'state.vscdb')
|
|
433
|
+
const st = await fileStat(dbPath)
|
|
434
|
+
if (!st?.isFile) continue
|
|
435
|
+
try {
|
|
436
|
+
const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
|
|
437
|
+
'-readonly',
|
|
438
|
+
dbPath,
|
|
439
|
+
"SELECT value FROM ItemTable WHERE key = 'cursor/pinnedComposers' LIMIT 1",
|
|
440
|
+
], { timeout: 2000, maxBuffer: 256 * 1024 })
|
|
441
|
+
for (const id of parsePinnedComposerList(stdout.trim())) pinned.add(id)
|
|
442
|
+
} catch {
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return pinned
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function findClaudeDesktopFile(sessionsRoot: string, uuid: string): Promise<string | null> {
|
|
450
|
+
if (!sessionsRoot || !uuid) return null
|
|
451
|
+
const name = `local_${uuid}.json`
|
|
452
|
+
for (const account of await dirents(sessionsRoot)) {
|
|
453
|
+
const accountDir = join(sessionsRoot, account)
|
|
454
|
+
const accountSt = await fileStat(accountDir)
|
|
455
|
+
if (!accountSt || accountSt.isFile) continue
|
|
456
|
+
for (const workspace of await dirents(accountDir)) {
|
|
457
|
+
const file = join(accountDir, workspace, name)
|
|
458
|
+
const st = await fileStat(file)
|
|
459
|
+
if (st?.isFile) return file
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function preferCursorCopy(
|
|
466
|
+
next: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
|
|
467
|
+
existing: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
|
|
468
|
+
): typeof next {
|
|
469
|
+
if (next.project === 'empty-window' && existing.project !== 'empty-window') return existing
|
|
470
|
+
if (existing.project === 'empty-window' && next.project !== 'empty-window') return next
|
|
471
|
+
return next.mtimeMs >= existing.mtimeMs ? next : existing
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function loadCursorComposerNames(dbPath: string): Promise<Map<string, string>> {
|
|
475
|
+
const names = new Map<string, string>()
|
|
476
|
+
if (!dbPath) return names
|
|
477
|
+
const st = await fileStat(dbPath)
|
|
478
|
+
if (!st?.isFile) return names
|
|
479
|
+
try {
|
|
480
|
+
const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
|
|
481
|
+
'-readonly',
|
|
482
|
+
'-json',
|
|
483
|
+
dbPath,
|
|
484
|
+
"SELECT composerId AS id, json_extract(value, '$.name') AS name FROM composerHeaders WHERE json_extract(value, '$.name') IS NOT NULL AND json_extract(value, '$.name') != ''",
|
|
485
|
+
], { timeout: 4000, maxBuffer: 8 * 1024 * 1024 })
|
|
486
|
+
const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; composerId?: string; name?: string }>
|
|
487
|
+
for (const row of rows) {
|
|
488
|
+
const id = String(row.id ?? row.composerId ?? '').trim()
|
|
489
|
+
const name = String(row.name ?? '').trim()
|
|
490
|
+
if (id && name) names.set(id, name.slice(0, 120))
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
return names
|
|
494
|
+
}
|
|
495
|
+
return names
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: string; title: string; subagent: boolean; created: string } | null> {
|
|
499
|
+
const text = await readWindow(path, false)
|
|
500
|
+
let id = ''
|
|
501
|
+
let cwd = ''
|
|
502
|
+
let title = ''
|
|
503
|
+
let created = ''
|
|
504
|
+
let subagent = false
|
|
505
|
+
for (const line of text.split('\n')) {
|
|
506
|
+
const obj = parseJsonLine(line)
|
|
507
|
+
if (!obj) continue
|
|
508
|
+
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
509
|
+
const payload = obj.payload as Record<string, unknown>
|
|
510
|
+
id = String(payload.id ?? payload.session_id ?? id)
|
|
511
|
+
cwd = String(payload.cwd ?? cwd)
|
|
512
|
+
subagent = payload.thread_source === 'subagent'
|
|
513
|
+
const nick = String(payload.agent_nickname ?? '').trim()
|
|
514
|
+
if (nick && !title) title = nick
|
|
515
|
+
const stamp = String(payload.timestamp ?? obj.timestamp ?? '').trim()
|
|
516
|
+
if (stamp && !created) created = stamp
|
|
517
|
+
}
|
|
518
|
+
if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
|
|
519
|
+
const payload = obj.payload as Record<string, unknown>
|
|
520
|
+
if (payload.type === 'message' && payload.role === 'user') {
|
|
521
|
+
const textBody = payloadText(payload)
|
|
522
|
+
if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (id && title) break
|
|
526
|
+
}
|
|
527
|
+
return { id, cwd, title, subagent, created }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
531
|
+
const text = await readWindow(path, true)
|
|
532
|
+
let lastUser: string | null = null
|
|
533
|
+
for (const line of text.split('\n')) {
|
|
534
|
+
const obj = parseJsonLine(line)
|
|
535
|
+
if (!obj || obj.role !== 'user') continue
|
|
536
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
537
|
+
const body = message ? payloadText(message) : null
|
|
538
|
+
if (!body) continue
|
|
539
|
+
const title = cursorUserTitle(body, true)
|
|
540
|
+
if (title) lastUser = title
|
|
541
|
+
}
|
|
542
|
+
return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
546
|
+
const text = await readWindow(path, false)
|
|
547
|
+
let fallback: string | null = null
|
|
548
|
+
for (const line of text.split('\n')) {
|
|
549
|
+
const obj = parseJsonLine(line)
|
|
550
|
+
if (!obj || obj.role !== 'user') continue
|
|
551
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
552
|
+
const body = message ? payloadText(message) : null
|
|
553
|
+
if (!body) continue
|
|
554
|
+
const query = cursorUserTitle(body, true)
|
|
555
|
+
if (query) return query
|
|
556
|
+
if (!fallback) fallback = cursorUserTitle(body, false)
|
|
557
|
+
}
|
|
558
|
+
return fallback
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function listCodexJsonlFiles(sessionsRoot: string): Promise<string[]> {
|
|
562
|
+
const files: string[] = []
|
|
563
|
+
for (const year of await dirents(sessionsRoot)) {
|
|
564
|
+
const yearDir = join(sessionsRoot, year)
|
|
565
|
+
const yearSt = await fileStat(yearDir)
|
|
566
|
+
if (!yearSt || yearSt.isFile) continue
|
|
567
|
+
for (const month of await dirents(yearDir)) {
|
|
568
|
+
const monthDir = join(yearDir, month)
|
|
569
|
+
const monthSt = await fileStat(monthDir)
|
|
570
|
+
if (!monthSt || monthSt.isFile) continue
|
|
571
|
+
for (const day of await dirents(monthDir)) {
|
|
572
|
+
const dayDir = join(monthDir, day)
|
|
573
|
+
const daySt = await fileStat(dayDir)
|
|
574
|
+
if (!daySt || daySt.isFile) continue
|
|
575
|
+
for (const name of await dirents(dayDir)) {
|
|
576
|
+
if (name.endsWith('.jsonl')) files.push(join(dayDir, name))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return files
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function listClaudeSessions(
|
|
585
|
+
projectsRoot: string,
|
|
586
|
+
now: Date,
|
|
587
|
+
liveIds: Set<string>,
|
|
588
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
589
|
+
starredIds: ReadonlySet<string> = new Set(),
|
|
590
|
+
desktopSessionsRoot = '',
|
|
591
|
+
): Promise<AgentSessionRow[]> {
|
|
592
|
+
const seen = new Set<string>()
|
|
593
|
+
const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
594
|
+
const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
595
|
+
for (const folder of await dirents(projectsRoot)) {
|
|
596
|
+
const dir = join(projectsRoot, folder)
|
|
597
|
+
const dirSt = await fileStat(dir)
|
|
598
|
+
if (!dirSt || dirSt.isFile) continue
|
|
599
|
+
for (const name of await dirents(dir)) {
|
|
600
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
601
|
+
const native = name.slice(0, -6)
|
|
602
|
+
const shortId = native.slice(0, 8)
|
|
603
|
+
if (liveIds.has(shortId) || liveIds.has(native)) continue
|
|
604
|
+
const file = join(dir, name)
|
|
605
|
+
const st = await fileStat(file)
|
|
606
|
+
if (!st?.isFile) continue
|
|
607
|
+
const pinned = starredIds.has(native.toLowerCase())
|
|
608
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
609
|
+
if (!pinned && !fresh) continue
|
|
610
|
+
const candidate = {
|
|
611
|
+
file,
|
|
612
|
+
native,
|
|
613
|
+
project: workspaceLabel(folder),
|
|
614
|
+
mtimeMs: st.mtimeMs,
|
|
615
|
+
birthtimeMs: st.birthtimeMs,
|
|
616
|
+
}
|
|
617
|
+
seen.add(native.toLowerCase())
|
|
618
|
+
if (pinned) pinnedCandidates.push(candidate)
|
|
619
|
+
else recentCandidates.push(candidate)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const starred of starredIds) {
|
|
623
|
+
if (seen.has(starred) || liveIds.has(starred) || liveIds.has(starred.slice(0, 8))) continue
|
|
624
|
+
const desktop = await findClaudeDesktopFile(desktopSessionsRoot, starred)
|
|
625
|
+
if (!desktop) continue
|
|
626
|
+
const st = await fileStat(desktop)
|
|
627
|
+
if (!st?.isFile) continue
|
|
628
|
+
pinnedCandidates.push({
|
|
629
|
+
file: '',
|
|
630
|
+
native: starred,
|
|
631
|
+
project: '',
|
|
632
|
+
mtimeMs: st.mtimeMs,
|
|
633
|
+
birthtimeMs: st.birthtimeMs,
|
|
634
|
+
desktop,
|
|
635
|
+
})
|
|
636
|
+
seen.add(starred)
|
|
637
|
+
}
|
|
638
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
639
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
640
|
+
|
|
641
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
642
|
+
const rows: AgentSessionRow[] = []
|
|
643
|
+
for (const candidate of candidates) {
|
|
644
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
645
|
+
let title = ''
|
|
646
|
+
let project = candidate.project
|
|
647
|
+
let firstPrompt = ''
|
|
648
|
+
let latestAssistant = ''
|
|
649
|
+
if (candidate.file) {
|
|
650
|
+
const peek = await peekClaudeDiscussion(candidate.file)
|
|
651
|
+
firstPrompt = await firstClaudeUserTitle(candidate.file) ?? ''
|
|
652
|
+
title = peek.customTitle ?? firstPrompt
|
|
653
|
+
latestAssistant = peek.latestAssistant
|
|
654
|
+
}
|
|
655
|
+
if ((!title || !project) && (candidate.desktop || desktopSessionsRoot)) {
|
|
656
|
+
const desktop = candidate.desktop || await findClaudeDesktopFile(desktopSessionsRoot, candidate.native)
|
|
657
|
+
if (desktop) {
|
|
658
|
+
const head = peekClaudeDesktopHead(await readWindow(desktop, false))
|
|
659
|
+
if (!title && head.title) title = head.title
|
|
660
|
+
if (!project && head.cwd) project = workspaceLabel(head.cwd)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
title = title || 'Claude session'
|
|
664
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
665
|
+
rows.push(row({
|
|
666
|
+
session_id: candidate.native,
|
|
667
|
+
provider: 'claude',
|
|
668
|
+
display_label: title,
|
|
669
|
+
project,
|
|
670
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
671
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
672
|
+
alive: false,
|
|
673
|
+
pinned: starredIds.has(candidate.native.toLowerCase()),
|
|
674
|
+
...discussionFields(title, firstPrompt, latestAssistant),
|
|
675
|
+
}))
|
|
676
|
+
}
|
|
677
|
+
return rows
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return [
|
|
681
|
+
...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
|
|
682
|
+
...await toRows(recentCandidates, Math.max(0, cap)),
|
|
683
|
+
]
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export async function listCodexSessions(
|
|
687
|
+
sessionsRoot: string,
|
|
688
|
+
now: Date,
|
|
689
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
690
|
+
): Promise<AgentSessionRow[]> {
|
|
691
|
+
const names = await loadCodexThreadNames(sessionsRoot)
|
|
692
|
+
const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
|
|
693
|
+
const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
694
|
+
const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
695
|
+
for (const file of await listCodexJsonlFiles(sessionsRoot)) {
|
|
696
|
+
const st = await fileStat(file)
|
|
697
|
+
if (!st?.isFile) continue
|
|
698
|
+
const name = file.split('/').pop() || file
|
|
699
|
+
const fileId = idFromCodexFilename(name)
|
|
700
|
+
const pinned = fileId ? pinnedIds.has(fileId) : false
|
|
701
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
702
|
+
if (!pinned && !fresh) continue
|
|
703
|
+
const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
|
|
704
|
+
if (pinned) pinnedCandidates.push(candidate)
|
|
705
|
+
else recentCandidates.push(candidate)
|
|
706
|
+
}
|
|
707
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
708
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
709
|
+
|
|
710
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
711
|
+
const rows: AgentSessionRow[] = []
|
|
712
|
+
for (const candidate of candidates) {
|
|
713
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
714
|
+
const meta = await peekCodexMeta(candidate.file)
|
|
715
|
+
if (!meta || meta.subagent) continue
|
|
716
|
+
const native = meta.id || candidate.name.slice(0, -6)
|
|
717
|
+
const title = names.get(native) || meta.title || 'Codex session'
|
|
718
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
719
|
+
const created = meta.created || createdFromCodexFilename(candidate.name) || isoFromMtime(candidate.birthtimeMs)
|
|
720
|
+
const latestAssistant = latestAssistantFromWindow(await readWindow(candidate.file, true))
|
|
721
|
+
rows.push(row({
|
|
722
|
+
session_id: native,
|
|
723
|
+
provider: 'codex',
|
|
724
|
+
display_label: title,
|
|
725
|
+
project: workspaceLabel(meta.cwd),
|
|
726
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
727
|
+
created,
|
|
728
|
+
alive: false,
|
|
729
|
+
pinned: pinnedIds.has(native.toLowerCase()),
|
|
730
|
+
...discussionFields(title, meta.title, latestAssistant),
|
|
731
|
+
}))
|
|
732
|
+
}
|
|
733
|
+
return rows
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
|
|
737
|
+
const recentRows = await toRows(recentCandidates, Math.max(0, cap))
|
|
738
|
+
return [...pinnedRows, ...recentRows]
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export async function listCursorSessions(
|
|
742
|
+
projectsRoot: string,
|
|
743
|
+
now: Date,
|
|
744
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
745
|
+
composerDb = '',
|
|
746
|
+
pinnedIds: ReadonlySet<string> = new Set(),
|
|
747
|
+
): Promise<AgentSessionRow[]> {
|
|
748
|
+
const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
|
|
749
|
+
const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
|
|
750
|
+
for (const folder of await dirents(projectsRoot)) {
|
|
751
|
+
if (folder.includes('var-folders') || folder.includes('private-var')) continue
|
|
752
|
+
const transcripts = join(projectsRoot, folder, 'agent-transcripts')
|
|
753
|
+
for (const sessionDir of await dirents(transcripts)) {
|
|
754
|
+
if (sessionDir === 'subagents') continue
|
|
755
|
+
const pinned = pinnedIds.has(sessionDir.toLowerCase())
|
|
756
|
+
if (folder === 'empty-window' && !pinned) continue
|
|
757
|
+
const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
|
|
758
|
+
const st = await fileStat(file)
|
|
759
|
+
if (!st?.isFile) continue
|
|
760
|
+
if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
|
|
761
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
762
|
+
if (!pinned && !fresh) continue
|
|
763
|
+
const next = {
|
|
764
|
+
file,
|
|
765
|
+
sessionDir,
|
|
766
|
+
project: workspaceLabel(folder),
|
|
767
|
+
mtimeMs: st.mtimeMs,
|
|
768
|
+
birthtimeMs: st.birthtimeMs,
|
|
769
|
+
}
|
|
770
|
+
const existing = byId.get(sessionDir)
|
|
771
|
+
byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
|
|
775
|
+
const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
|
|
776
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
777
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
778
|
+
|
|
779
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
780
|
+
const rows: AgentSessionRow[] = []
|
|
781
|
+
for (const candidate of candidates) {
|
|
782
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
783
|
+
const peek = await peekCursorDiscussion(candidate.file)
|
|
784
|
+
const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
|
|
785
|
+
const title = composerNames.get(candidate.sessionDir)
|
|
786
|
+
?? peek.lastUser
|
|
787
|
+
?? firstPrompt
|
|
788
|
+
?? 'Cursor session'
|
|
789
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
790
|
+
const alive = now.getTime() - candidate.mtimeMs < 180_000
|
|
791
|
+
rows.push(row({
|
|
792
|
+
session_id: candidate.sessionDir,
|
|
793
|
+
provider: 'cursor',
|
|
794
|
+
display_label: title,
|
|
795
|
+
project: candidate.project,
|
|
796
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
797
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
798
|
+
alive,
|
|
799
|
+
state: alive ? 'running' : 'recent',
|
|
800
|
+
pinned: pinnedIds.has(candidate.sessionDir.toLowerCase()),
|
|
801
|
+
...discussionFields(title, firstPrompt, peek.latestAssistant),
|
|
802
|
+
}))
|
|
803
|
+
}
|
|
804
|
+
return rows
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return [
|
|
808
|
+
...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
|
|
809
|
+
...await toRows(recentCandidates, Math.max(0, cap)),
|
|
810
|
+
]
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots): Promise<AgentSessionRow> {
|
|
814
|
+
if (row.provider !== 'claude') return row
|
|
815
|
+
const found = await findAgentSessionFile('claude', row.session_id, roots)
|
|
816
|
+
if (!found) return row
|
|
817
|
+
const peek = await peekClaudeDiscussion(found)
|
|
818
|
+
const firstPrompt = await firstClaudeUserTitle(found)
|
|
819
|
+
const title = peek.customTitle ?? firstPrompt ?? row.display_label
|
|
820
|
+
const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
|
|
821
|
+
return {
|
|
822
|
+
...row,
|
|
823
|
+
session_id: fullId,
|
|
824
|
+
display_label: title || row.display_label || 'Claude session',
|
|
825
|
+
...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function sessionKey(row: AgentSessionRow): string {
|
|
830
|
+
return `${row.provider}:${row.session_id.trim().toLowerCase()}`
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function preferSession(next: AgentSessionRow, current: AgentSessionRow): AgentSessionRow {
|
|
834
|
+
if (next.alive !== current.alive) return next.alive ? next : current
|
|
835
|
+
if (next.pinned !== current.pinned) return next.pinned ? next : current
|
|
836
|
+
if (next.project === 'empty-window' && current.project !== 'empty-window') return current
|
|
837
|
+
if (current.project === 'empty-window' && next.project !== 'empty-window') return next
|
|
838
|
+
return (next.modified || '') > (current.modified || '') ? next : current
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function dedupeSessions(rows: AgentSessionRow[]): AgentSessionRow[] {
|
|
842
|
+
const byKey = new Map<string, AgentSessionRow>()
|
|
843
|
+
for (const row of rows) {
|
|
844
|
+
const key = sessionKey(row)
|
|
845
|
+
const existing = byKey.get(key)
|
|
846
|
+
byKey.set(key, existing ? preferSession(row, existing) : row)
|
|
847
|
+
}
|
|
848
|
+
return [...byKey.values()]
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export async function listAgentSessions(
|
|
852
|
+
roots: AgentSessionRoots,
|
|
853
|
+
now = new Date(),
|
|
854
|
+
live: AgentSessionRow[] = [],
|
|
855
|
+
limit = AGENT_SESSION_LIST_LIMIT,
|
|
856
|
+
sort: AgentSessionSort = 'updated',
|
|
857
|
+
): Promise<AgentSessionRow[]> {
|
|
858
|
+
const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
859
|
+
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
860
|
+
const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
|
|
861
|
+
.filter(entry => !isKeepWarmSessionTitle(entry.display_label))
|
|
862
|
+
.map(entry => {
|
|
863
|
+
if (entry.provider === 'claude' && starredIds.has(normalizeClaudeSessionId(entry.session_id))) {
|
|
864
|
+
return { ...entry, pinned: true }
|
|
865
|
+
}
|
|
866
|
+
if (entry.provider === 'cursor' && cursorPinned.has(entry.session_id.toLowerCase())) {
|
|
867
|
+
return { ...entry, pinned: true }
|
|
868
|
+
}
|
|
869
|
+
return entry
|
|
870
|
+
})
|
|
871
|
+
const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
|
|
872
|
+
const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
|
|
873
|
+
let rows = dedupeSessions([
|
|
874
|
+
...enrichedLive,
|
|
875
|
+
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
|
|
876
|
+
...await listCodexSessions(roots.codexSessions, now, cap),
|
|
877
|
+
...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
|
|
878
|
+
])
|
|
879
|
+
if (sort === 'opened') {
|
|
880
|
+
rows = rows.filter(entry => {
|
|
881
|
+
if (entry.alive) return true
|
|
882
|
+
const created = Date.parse(entry.created)
|
|
883
|
+
return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
|
|
884
|
+
})
|
|
885
|
+
// Control Activity → Opened: created desc. Pins stay in the payload
|
|
886
|
+
// when they were opened in-window; they do not cluster at the top.
|
|
887
|
+
rows.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
|
|
888
|
+
} else {
|
|
889
|
+
// Control Activity → Updated: newest write first. Stale pins still
|
|
890
|
+
// appear (any age) but sink by mtime. Glasses has no Pinned clock, so
|
|
891
|
+
// pin-boosting here made the lens show July stars instead of today.
|
|
892
|
+
rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
|
|
893
|
+
}
|
|
894
|
+
return rows.slice(0, limit)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export async function findAgentSessionFile(
|
|
898
|
+
provider: AgentProvider,
|
|
899
|
+
sessionId: string,
|
|
900
|
+
roots: AgentSessionRoots,
|
|
901
|
+
now = new Date(),
|
|
902
|
+
): Promise<string | null> {
|
|
903
|
+
if (!isSafeSessionId(sessionId)) return null
|
|
904
|
+
const needle = sessionId.trim().toLowerCase()
|
|
905
|
+
if (provider === 'claude') {
|
|
906
|
+
for (const folder of await dirents(roots.claudeProjects)) {
|
|
907
|
+
const dir = join(roots.claudeProjects, folder)
|
|
908
|
+
for (const name of await dirents(dir)) {
|
|
909
|
+
if (!name.endsWith('.jsonl')) continue
|
|
910
|
+
const id = name.slice(0, -6).toLowerCase()
|
|
911
|
+
if (id === needle || id.startsWith(needle)) return join(dir, name)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null
|
|
915
|
+
}
|
|
916
|
+
if (provider === 'codex') {
|
|
917
|
+
for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
|
|
918
|
+
const name = file.split('/').pop()?.toLowerCase() ?? ''
|
|
919
|
+
if (name.includes(needle)) return file
|
|
920
|
+
}
|
|
921
|
+
return null
|
|
922
|
+
}
|
|
923
|
+
let best: { file: string; mtimeMs: number } | null = null
|
|
924
|
+
for (const folder of await dirents(roots.cursorProjects)) {
|
|
925
|
+
if (isSkippedCursorFolder(folder)) continue
|
|
926
|
+
const file = join(roots.cursorProjects, folder, 'agent-transcripts', needle, `${needle}.jsonl`)
|
|
927
|
+
const st = await fileStat(file)
|
|
928
|
+
if (!st?.isFile) continue
|
|
929
|
+
if (!best || st.mtimeMs >= best.mtimeMs) best = { file, mtimeMs: st.mtimeMs }
|
|
930
|
+
}
|
|
931
|
+
return best?.file ?? null
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export interface AgentSessionDetail {
|
|
935
|
+
session_id: string
|
|
936
|
+
provider: AgentProvider
|
|
937
|
+
display_label: string
|
|
938
|
+
project: string
|
|
939
|
+
git_branch: string
|
|
940
|
+
first_prompt: string
|
|
941
|
+
discussion_summary: string
|
|
942
|
+
user_message_count: number
|
|
943
|
+
assistant_message_count: number
|
|
944
|
+
omitted_tools: number
|
|
945
|
+
file_size_bytes: number
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export async function parseAgentSession(provider: AgentProvider, path: string): Promise<AgentSessionDetail> {
|
|
949
|
+
const st = await stat(path)
|
|
950
|
+
let title = ''
|
|
951
|
+
let project = ''
|
|
952
|
+
let gitBranch = ''
|
|
953
|
+
let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
|
|
954
|
+
let firstPrompt = ''
|
|
955
|
+
let latestAssistant = ''
|
|
956
|
+
let userCount = 0
|
|
957
|
+
let assistantCount = 0
|
|
958
|
+
let omittedTools = 0
|
|
959
|
+
|
|
960
|
+
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity })
|
|
961
|
+
for await (const line of rl) {
|
|
962
|
+
const obj = parseJsonLine(line)
|
|
963
|
+
if (!obj) continue
|
|
964
|
+
if (provider === 'claude') {
|
|
965
|
+
if (typeof obj.sessionId === 'string' && obj.sessionId) sessionId = obj.sessionId
|
|
966
|
+
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string' && obj.customTitle.trim()) {
|
|
967
|
+
title = obj.customTitle.trim()
|
|
968
|
+
continue
|
|
969
|
+
}
|
|
970
|
+
if (obj.isSidechain === true) continue
|
|
971
|
+
if (!project && typeof obj.cwd === 'string') project = workspaceLabel(obj.cwd)
|
|
972
|
+
if (!gitBranch && typeof obj.gitBranch === 'string') gitBranch = obj.gitBranch
|
|
973
|
+
if (obj.type === 'user' && obj.toolUseResult) { omittedTools += 1; continue }
|
|
974
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue
|
|
975
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
976
|
+
const text = message ? payloadText(message) : null
|
|
977
|
+
if (!text) {
|
|
978
|
+
if (obj.type === 'assistant') omittedTools += 1
|
|
979
|
+
continue
|
|
980
|
+
}
|
|
981
|
+
if (obj.type === 'user') {
|
|
982
|
+
userCount += 1
|
|
983
|
+
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
984
|
+
if (!title) title = firstLineTitle(text)
|
|
985
|
+
} else {
|
|
986
|
+
assistantCount += 1
|
|
987
|
+
const snippet = proseSnippet(text)
|
|
988
|
+
if (snippet) latestAssistant = snippet
|
|
989
|
+
}
|
|
990
|
+
} else if (provider === 'codex') {
|
|
991
|
+
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
992
|
+
const payload = obj.payload as Record<string, unknown>
|
|
993
|
+
if (typeof payload.cwd === 'string') project = workspaceLabel(payload.cwd)
|
|
994
|
+
if (typeof payload.id === 'string' && payload.id) sessionId = payload.id
|
|
995
|
+
const git = payload.git && typeof payload.git === 'object' ? payload.git as Record<string, unknown> : null
|
|
996
|
+
if (git && typeof git.branch === 'string') gitBranch = git.branch
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
|
|
1000
|
+
const payload = obj.payload as Record<string, unknown>
|
|
1001
|
+
const kind = String(payload.type ?? '')
|
|
1002
|
+
if (kind === 'custom_tool_call' || kind === 'custom_tool_call_output' || kind === 'function_call') {
|
|
1003
|
+
omittedTools += 1
|
|
1004
|
+
continue
|
|
1005
|
+
}
|
|
1006
|
+
if (kind !== 'message' || payload.role === 'developer') continue
|
|
1007
|
+
const text = payloadText(payload)
|
|
1008
|
+
if (!text || isWrapperPrompt(text)) continue
|
|
1009
|
+
if (payload.role === 'assistant') {
|
|
1010
|
+
assistantCount += 1
|
|
1011
|
+
const snippet = proseSnippet(text)
|
|
1012
|
+
if (snippet) latestAssistant = snippet
|
|
1013
|
+
} else {
|
|
1014
|
+
userCount += 1
|
|
1015
|
+
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
1016
|
+
if (!title) title = firstLineTitle(text)
|
|
1017
|
+
}
|
|
1018
|
+
} else {
|
|
1019
|
+
if (obj.role !== 'user' && obj.role !== 'assistant') continue
|
|
1020
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
1021
|
+
if (Array.isArray(message?.content) && (message.content as Array<{ type?: string }>).some(b => b?.type === 'tool_use')) {
|
|
1022
|
+
omittedTools += 1
|
|
1023
|
+
}
|
|
1024
|
+
const text = message ? payloadText(message) : null
|
|
1025
|
+
if (!text) continue
|
|
1026
|
+
if (obj.role === 'user') {
|
|
1027
|
+
userCount += 1
|
|
1028
|
+
const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
|
|
1029
|
+
if (query) {
|
|
1030
|
+
title = query
|
|
1031
|
+
if (!firstPrompt) firstPrompt = query
|
|
1032
|
+
}
|
|
1033
|
+
} else {
|
|
1034
|
+
assistantCount += 1
|
|
1035
|
+
const snippet = proseSnippet(text)
|
|
1036
|
+
if (snippet) latestAssistant = snippet
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (provider === 'cursor' && !project) {
|
|
1042
|
+
const parts = path.split('/')
|
|
1043
|
+
const encoded = parts[parts.indexOf('agent-transcripts') - 1] || ''
|
|
1044
|
+
project = workspaceLabel(encoded)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const display = title || (provider === 'codex' ? 'Codex session' : provider === 'cursor' ? 'Cursor session' : 'Claude session')
|
|
1048
|
+
return {
|
|
1049
|
+
session_id: sessionId,
|
|
1050
|
+
provider,
|
|
1051
|
+
display_label: display,
|
|
1052
|
+
project,
|
|
1053
|
+
git_branch: gitBranch,
|
|
1054
|
+
first_prompt: firstPrompt || title,
|
|
1055
|
+
discussion_summary: composeDiscussionSummary({
|
|
1056
|
+
title: display,
|
|
1057
|
+
firstPrompt: firstPrompt || title,
|
|
1058
|
+
latestAssistant,
|
|
1059
|
+
}),
|
|
1060
|
+
user_message_count: userCount,
|
|
1061
|
+
assistant_message_count: assistantCount,
|
|
1062
|
+
omitted_tools: omittedTools,
|
|
1063
|
+
file_size_bytes: st.size,
|
|
1064
|
+
}
|
|
1065
|
+
}
|