@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 +106 -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 +1262 -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 +212 -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,1262 @@
|
|
|
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
|
+
/** Deep digest budget. The glasses detail page paginates at 200 chars, so 2000 is
|
|
152
|
+
* ~10 swipes — long by G2 standards, and deliberately so: this exists to be READ
|
|
153
|
+
* before deciding whether to follow up on a session, the one moment depth beats
|
|
154
|
+
* brevity. The LIST row keeps the short `discussion_summary`; a 2000-char gist
|
|
155
|
+
* appended to a single row would destroy it. Two fields, two jobs. */
|
|
156
|
+
export const DISCUSSION_DIGEST_MAX = 2000
|
|
157
|
+
|
|
158
|
+
/** Per-turn cap, so one enormous paste cannot eat the whole budget. */
|
|
159
|
+
const DIGEST_TURN_MAX = 220
|
|
160
|
+
|
|
161
|
+
/** Turns kept from the START. The opening ask frames everything after it. */
|
|
162
|
+
const DIGEST_HEAD_TURNS = 2
|
|
163
|
+
|
|
164
|
+
/** Recent turns retained while streaming. Comfortably more than 2000 chars can
|
|
165
|
+
* render (~20-25 at typical length), so the budget and not the buffer decides
|
|
166
|
+
* what appears. */
|
|
167
|
+
const DIGEST_RECENT_WINDOW = 60
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A readable account of what actually happened in a session.
|
|
171
|
+
*
|
|
172
|
+
* `composeDiscussionSummary` is the opening prompt plus the last assistant snippet at
|
|
173
|
+
* 180 chars — it says where a session started and where it stopped, and nothing about
|
|
174
|
+
* the turns between, which is exactly what you need to decide whether to reopen it.
|
|
175
|
+
*
|
|
176
|
+
* Shape: the opening ask, then the MOST RECENT user turns in chronological order, then
|
|
177
|
+
* where the assistant left off. Recency is weighted because a follow-up continues from
|
|
178
|
+
* the end, not the beginning.
|
|
179
|
+
*
|
|
180
|
+
* NO LLM, by design — assembled from turns `parseAgentSession` already streams, so a
|
|
181
|
+
* digest costs no tokens and no extra file reads.
|
|
182
|
+
*
|
|
183
|
+
* Elision is STATED, never silent: dropping 40 turns and rendering the rest as clean
|
|
184
|
+
* prose reads like the whole story. `… N earlier turns …` says otherwise.
|
|
185
|
+
*/
|
|
186
|
+
export function composeDiscussionDigest(input: {
|
|
187
|
+
userTurns: string[]
|
|
188
|
+
latestAssistant?: string
|
|
189
|
+
max?: number
|
|
190
|
+
/** True number of user turns in the session, when `userTurns` is a bounded sample.
|
|
191
|
+
* The caller keeps only head + a recent window so a 900-turn session cannot balloon
|
|
192
|
+
* memory, and without this the elision line would report only the turns it can see
|
|
193
|
+
* and quietly under-count the rest — a silent cap wearing an honest label. */
|
|
194
|
+
totalTurns?: number
|
|
195
|
+
/** The transcript was read as head+tail, so the middle was never seen and the true
|
|
196
|
+
* turn count is unknown. Print elision WITHOUT a number rather than a wrong one. */
|
|
197
|
+
truncated?: boolean
|
|
198
|
+
}): string {
|
|
199
|
+
const max = input.max ?? DISCUSSION_DIGEST_MAX
|
|
200
|
+
const clean = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
201
|
+
const cap = (s: string): string => (s.length <= DIGEST_TURN_MAX ? s : `${s.slice(0, DIGEST_TURN_MAX - 1)}…`)
|
|
202
|
+
|
|
203
|
+
const turns = input.userTurns.map(clean).filter(Boolean).map(cap)
|
|
204
|
+
const latest = clean(input.latestAssistant ?? '')
|
|
205
|
+
if (turns.length === 0 && !latest) return ''
|
|
206
|
+
const total = Math.max(input.totalTurns ?? turns.length, turns.length)
|
|
207
|
+
|
|
208
|
+
// Reserve room for the closing state before spending budget on asks.
|
|
209
|
+
const tail = latest ? `\n\nLatest: ${cap(latest)}` : ''
|
|
210
|
+
let budget = max - tail.length
|
|
211
|
+
|
|
212
|
+
const head = turns.slice(0, DIGEST_HEAD_TURNS)
|
|
213
|
+
const rest = turns.slice(DIGEST_HEAD_TURNS)
|
|
214
|
+
|
|
215
|
+
// HEAD FIRST. Claiming the opening ask "frames everything after it" and then
|
|
216
|
+
// spending the budget from the end left no room for it — a 40-turn session rendered
|
|
217
|
+
// as "… 22 earlier turns …" with the original request nowhere on the page. Reserve
|
|
218
|
+
// the framing, then spend what is left on recency. Caught by its own test.
|
|
219
|
+
const headKept: string[] = []
|
|
220
|
+
for (const turn of head) {
|
|
221
|
+
const cost = turn.length + 3
|
|
222
|
+
if (cost > budget) break
|
|
223
|
+
headKept.push(turn)
|
|
224
|
+
budget -= cost
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Then fill from the END backwards — the turns nearest a follow-up.
|
|
228
|
+
const kept: string[] = []
|
|
229
|
+
for (let i = rest.length - 1; i >= 0; i--) {
|
|
230
|
+
const cost = rest[i].length + 3
|
|
231
|
+
if (cost > budget) break
|
|
232
|
+
kept.unshift(rest[i])
|
|
233
|
+
budget -= cost
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const dropped = total - headKept.length - kept.length
|
|
237
|
+
const lines: string[] = []
|
|
238
|
+
for (const t of headKept) lines.push(`• ${t}`)
|
|
239
|
+
if (input.truncated) lines.push('… middle of a large session not read …')
|
|
240
|
+
else if (dropped > 0) lines.push(`… ${dropped} earlier turn${dropped === 1 ? '' : 's'} …`)
|
|
241
|
+
for (const t of kept) lines.push(`• ${t}`)
|
|
242
|
+
|
|
243
|
+
const body = lines.join('\n') + tail
|
|
244
|
+
return body.length <= max ? body : `${body.slice(0, max - 1)}…`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function assistantProseFromRecord(obj: Record<string, unknown>): string | null {
|
|
248
|
+
if (obj.type === 'assistant') {
|
|
249
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
250
|
+
const text = message ? payloadText(message) : null
|
|
251
|
+
return text ? proseSnippet(text) || null : null
|
|
252
|
+
}
|
|
253
|
+
if (obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
|
|
254
|
+
const payload = obj.payload as Record<string, unknown>
|
|
255
|
+
if (payload.role === 'assistant' && (payload.type === 'message' || !payload.type)) {
|
|
256
|
+
const text = payloadText(payload)
|
|
257
|
+
return text ? proseSnippet(text) || null : null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (obj.role === 'assistant') {
|
|
261
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
262
|
+
const text = message ? payloadText(message) : null
|
|
263
|
+
return text ? proseSnippet(text) || null : null
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function latestAssistantFromWindow(text: string): string {
|
|
269
|
+
let latest = ''
|
|
270
|
+
for (const line of text.split('\n')) {
|
|
271
|
+
const obj = parseJsonLine(line)
|
|
272
|
+
if (!obj) continue
|
|
273
|
+
const prose = assistantProseFromRecord(obj)
|
|
274
|
+
if (prose) latest = prose
|
|
275
|
+
}
|
|
276
|
+
return latest
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function discussionFields(title: string, firstPrompt: string, latestAssistant: string): {
|
|
280
|
+
first_prompt: string
|
|
281
|
+
discussion_summary: string
|
|
282
|
+
} {
|
|
283
|
+
return {
|
|
284
|
+
first_prompt: firstPrompt,
|
|
285
|
+
discussion_summary: composeDiscussionSummary({ title, firstPrompt, latestAssistant }),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function cursorUserTitle(body: string, requireQuery: boolean): string | null {
|
|
290
|
+
if (requireQuery && !body.includes('<user_query>')) return null
|
|
291
|
+
const title = firstLineTitle(body)
|
|
292
|
+
if (!title || isWrapperPrompt(title)) return null
|
|
293
|
+
return title
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function payloadText(payload: Record<string, unknown>): string | null {
|
|
297
|
+
const content = payload.content
|
|
298
|
+
if (typeof content === 'string') {
|
|
299
|
+
const trimmed = content.trim()
|
|
300
|
+
return trimmed ? trimmed : null
|
|
301
|
+
}
|
|
302
|
+
if (!Array.isArray(content)) return null
|
|
303
|
+
const parts: string[] = []
|
|
304
|
+
for (const block of content) {
|
|
305
|
+
if (!block || typeof block !== 'object') continue
|
|
306
|
+
const type = String((block as { type?: unknown }).type ?? '')
|
|
307
|
+
if (type === 'tool_result' || type === 'tool_use' || type === 'thinking') continue
|
|
308
|
+
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
|
309
|
+
const text = String((block as { text?: unknown }).text ?? '').trim()
|
|
310
|
+
if (text) parts.push(text)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const joined = parts.join('\n\n').trim()
|
|
314
|
+
return joined || null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function isoFromMtime(mtimeMs: number): string {
|
|
318
|
+
return new Date(mtimeMs).toISOString()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function row(partial: Omit<AgentSessionRow, 'created' | 'state' | 'pinned'> & {
|
|
322
|
+
created?: string
|
|
323
|
+
state?: AgentSessionRow['state']
|
|
324
|
+
pinned?: boolean
|
|
325
|
+
}): AgentSessionRow {
|
|
326
|
+
return {
|
|
327
|
+
...partial,
|
|
328
|
+
created: partial.created ?? partial.modified,
|
|
329
|
+
state: partial.state ?? (partial.alive ? 'running' : 'recent'),
|
|
330
|
+
pinned: partial.pinned ?? false,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function idFromCodexFilename(name: string): string | null {
|
|
335
|
+
if (!name.endsWith('.jsonl')) return null
|
|
336
|
+
const parts = name.slice(0, -6).split('-')
|
|
337
|
+
if (parts.length < 5) return null
|
|
338
|
+
const uuid = parts.slice(-5).join('-').toLowerCase()
|
|
339
|
+
return uuid.length >= 36 ? uuid : null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function dirents(dir: string): Promise<string[]> {
|
|
343
|
+
try {
|
|
344
|
+
return await readdir(dir)
|
|
345
|
+
} catch {
|
|
346
|
+
return []
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function fileStat(path: string): Promise<{ mtimeMs: number; birthtimeMs: number; size: number; isFile: boolean } | null> {
|
|
351
|
+
try {
|
|
352
|
+
const st = await lstat(path)
|
|
353
|
+
return {
|
|
354
|
+
mtimeMs: st.mtimeMs,
|
|
355
|
+
birthtimeMs: st.birthtimeMs || st.mtimeMs,
|
|
356
|
+
size: st.size,
|
|
357
|
+
isFile: st.isFile(),
|
|
358
|
+
}
|
|
359
|
+
} catch {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function readWindow(path: string, fromEnd: boolean): Promise<string> {
|
|
365
|
+
const st = await fileStat(path)
|
|
366
|
+
if (!st?.isFile || st.size <= 0) return ''
|
|
367
|
+
let start = 0
|
|
368
|
+
let end = st.size - 1
|
|
369
|
+
if (st.size > HEAD_BYTES) {
|
|
370
|
+
if (fromEnd) start = st.size - HEAD_BYTES
|
|
371
|
+
else end = HEAD_BYTES - 1
|
|
372
|
+
}
|
|
373
|
+
const stream = createReadStream(path, { start, end })
|
|
374
|
+
const chunks: Buffer[] = []
|
|
375
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
376
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function parseJsonLine(line: string): Record<string, unknown> | null {
|
|
380
|
+
try {
|
|
381
|
+
const obj = JSON.parse(line) as unknown
|
|
382
|
+
return obj && typeof obj === 'object' ? obj as Record<string, unknown> : null
|
|
383
|
+
} catch {
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function lastCustomTitle(path: string): Promise<string | null> {
|
|
389
|
+
const text = await readWindow(path, true)
|
|
390
|
+
let found: string | null = null
|
|
391
|
+
for (const line of text.split('\n')) {
|
|
392
|
+
if (!line.includes('custom-title')) continue
|
|
393
|
+
const obj = parseJsonLine(line)
|
|
394
|
+
if (obj?.type !== 'custom-title') continue
|
|
395
|
+
const title = String(obj.customTitle ?? '').trim()
|
|
396
|
+
if (title) found = title.slice(0, 120)
|
|
397
|
+
}
|
|
398
|
+
return found
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function peekClaudeDiscussion(path: string): Promise<{ customTitle: string | null; latestAssistant: string }> {
|
|
402
|
+
const text = await readWindow(path, true)
|
|
403
|
+
let customTitle: string | null = null
|
|
404
|
+
for (const line of text.split('\n')) {
|
|
405
|
+
if (line.includes('custom-title')) {
|
|
406
|
+
const obj = parseJsonLine(line)
|
|
407
|
+
if (obj?.type === 'custom-title') {
|
|
408
|
+
const title = String(obj.customTitle ?? '').trim()
|
|
409
|
+
if (title) customTitle = title.slice(0, 120)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { customTitle, latestAssistant: latestAssistantFromWindow(text) }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function firstClaudeUserTitle(path: string): Promise<string | null> {
|
|
417
|
+
const text = await readWindow(path, false)
|
|
418
|
+
for (const line of text.split('\n')) {
|
|
419
|
+
const obj = parseJsonLine(line)
|
|
420
|
+
if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) continue
|
|
421
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
422
|
+
const body = message ? payloadText(message) : null
|
|
423
|
+
if (!body) continue
|
|
424
|
+
const title = firstLineTitle(body)
|
|
425
|
+
if (title) return title
|
|
426
|
+
}
|
|
427
|
+
return null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function createdFromCodexFilename(name: string): string | null {
|
|
431
|
+
const match = CODEX_ROLLOUT_STAMP.exec(name)
|
|
432
|
+
if (!match) return null
|
|
433
|
+
const stamp = new Date(`${match[1]}T${match[2]}:${match[3]}:${match[4]}`)
|
|
434
|
+
return Number.isFinite(stamp.getTime()) ? stamp.toISOString() : null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function loadCodexThreadNames(sessionsRoot: string): Promise<Map<string, string>> {
|
|
438
|
+
const names = new Map<string, string>()
|
|
439
|
+
const indexPath = join(sessionsRoot, '..', 'session_index.jsonl')
|
|
440
|
+
try {
|
|
441
|
+
const rl = createInterface({ input: createReadStream(indexPath), crlfDelay: Infinity })
|
|
442
|
+
for await (const line of rl) {
|
|
443
|
+
const obj = parseJsonLine(line)
|
|
444
|
+
if (!obj) continue
|
|
445
|
+
const id = String(obj.id ?? '').trim()
|
|
446
|
+
const name = String(obj.thread_name ?? '').trim()
|
|
447
|
+
if (id && name) names.set(id, name.slice(0, 120))
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
return names
|
|
451
|
+
}
|
|
452
|
+
return names
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function loadCodexPinnedIds(sessionsRoot: string): Promise<Set<string>> {
|
|
456
|
+
const pinned = new Set<string>()
|
|
457
|
+
const statePath = join(sessionsRoot, '..', '.codex-global-state.json')
|
|
458
|
+
try {
|
|
459
|
+
const raw = await readFile(statePath, 'utf8')
|
|
460
|
+
const obj = JSON.parse(raw) as { 'pinned-thread-ids'?: unknown }
|
|
461
|
+
const list = obj['pinned-thread-ids']
|
|
462
|
+
if (!Array.isArray(list)) return pinned
|
|
463
|
+
for (const value of list) {
|
|
464
|
+
const id = String(value ?? '').trim().toLowerCase()
|
|
465
|
+
if (id) pinned.add(id)
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
return pinned
|
|
469
|
+
}
|
|
470
|
+
return pinned
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function normalizeClaudeSessionId(raw: string): string {
|
|
474
|
+
let id = raw.trim().toLowerCase()
|
|
475
|
+
if (id.startsWith('local_')) id = id.slice('local_'.length)
|
|
476
|
+
return id
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function peekClaudeDesktopHead(text: string): { title: string; cwd: string } {
|
|
480
|
+
const unescape = (value: string) => value.replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
481
|
+
const title = /"title"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
|
|
482
|
+
const cwd = /"cwd"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(text)
|
|
483
|
+
return {
|
|
484
|
+
title: title ? unescape(title[1]).slice(0, 120) : '',
|
|
485
|
+
cwd: cwd ? unescape(cwd[1]) : '',
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function loadClaudeStarredIds(configPath: string): Promise<Set<string>> {
|
|
490
|
+
const starred = new Set<string>()
|
|
491
|
+
if (!configPath) return starred
|
|
492
|
+
try {
|
|
493
|
+
const obj = JSON.parse(await readFile(configPath, 'utf8')) as {
|
|
494
|
+
preferences?: { epitaxyPrefs?: { 'starred-local-code-sessions'?: unknown } }
|
|
495
|
+
}
|
|
496
|
+
const list = obj.preferences?.epitaxyPrefs?.['starred-local-code-sessions']
|
|
497
|
+
if (!Array.isArray(list)) return starred
|
|
498
|
+
for (const value of list) {
|
|
499
|
+
const id = normalizeClaudeSessionId(String(value ?? ''))
|
|
500
|
+
if (id) starred.add(id)
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
return starred
|
|
504
|
+
}
|
|
505
|
+
return starred
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function parsePinnedComposerList(raw: string): string[] {
|
|
509
|
+
try {
|
|
510
|
+
const parsed = JSON.parse(raw) as unknown
|
|
511
|
+
const list = Array.isArray(parsed)
|
|
512
|
+
? parsed
|
|
513
|
+
: parsed && typeof parsed === 'object'
|
|
514
|
+
? (parsed as { composerIds?: unknown; ids?: unknown }).composerIds
|
|
515
|
+
?? (parsed as { ids?: unknown }).ids
|
|
516
|
+
: null
|
|
517
|
+
if (!Array.isArray(list)) return []
|
|
518
|
+
return list.map(value => String(value ?? '').trim().toLowerCase()).filter(Boolean)
|
|
519
|
+
} catch {
|
|
520
|
+
return []
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function loadCursorPinnedIds(workspaceStorage: string): Promise<Set<string>> {
|
|
525
|
+
const pinned = new Set<string>()
|
|
526
|
+
if (!workspaceStorage) return pinned
|
|
527
|
+
for (const folder of await dirents(workspaceStorage)) {
|
|
528
|
+
const dbPath = join(workspaceStorage, folder, 'state.vscdb')
|
|
529
|
+
const st = await fileStat(dbPath)
|
|
530
|
+
if (!st?.isFile) continue
|
|
531
|
+
try {
|
|
532
|
+
const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
|
|
533
|
+
'-readonly',
|
|
534
|
+
dbPath,
|
|
535
|
+
"SELECT value FROM ItemTable WHERE key = 'cursor/pinnedComposers' LIMIT 1",
|
|
536
|
+
], { timeout: 2000, maxBuffer: 256 * 1024 })
|
|
537
|
+
for (const id of parsePinnedComposerList(stdout.trim())) pinned.add(id)
|
|
538
|
+
} catch {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return pinned
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function findClaudeDesktopFile(sessionsRoot: string, uuid: string): Promise<string | null> {
|
|
546
|
+
if (!sessionsRoot || !uuid) return null
|
|
547
|
+
const name = `local_${uuid}.json`
|
|
548
|
+
for (const account of await dirents(sessionsRoot)) {
|
|
549
|
+
const accountDir = join(sessionsRoot, account)
|
|
550
|
+
const accountSt = await fileStat(accountDir)
|
|
551
|
+
if (!accountSt || accountSt.isFile) continue
|
|
552
|
+
for (const workspace of await dirents(accountDir)) {
|
|
553
|
+
const file = join(accountDir, workspace, name)
|
|
554
|
+
const st = await fileStat(file)
|
|
555
|
+
if (st?.isFile) return file
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return null
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function preferCursorCopy(
|
|
562
|
+
next: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
|
|
563
|
+
existing: { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number },
|
|
564
|
+
): typeof next {
|
|
565
|
+
if (next.project === 'empty-window' && existing.project !== 'empty-window') return existing
|
|
566
|
+
if (existing.project === 'empty-window' && next.project !== 'empty-window') return next
|
|
567
|
+
return next.mtimeMs >= existing.mtimeMs ? next : existing
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function loadCursorComposerNames(dbPath: string): Promise<Map<string, string>> {
|
|
571
|
+
const names = new Map<string, string>()
|
|
572
|
+
if (!dbPath) return names
|
|
573
|
+
const st = await fileStat(dbPath)
|
|
574
|
+
if (!st?.isFile) return names
|
|
575
|
+
try {
|
|
576
|
+
const { stdout } = await execFileAsync('/usr/bin/sqlite3', [
|
|
577
|
+
'-readonly',
|
|
578
|
+
'-json',
|
|
579
|
+
dbPath,
|
|
580
|
+
"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') != ''",
|
|
581
|
+
], { timeout: 4000, maxBuffer: 8 * 1024 * 1024 })
|
|
582
|
+
const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; composerId?: string; name?: string }>
|
|
583
|
+
for (const row of rows) {
|
|
584
|
+
const id = String(row.id ?? row.composerId ?? '').trim()
|
|
585
|
+
const name = String(row.name ?? '').trim()
|
|
586
|
+
if (id && name) names.set(id, name.slice(0, 120))
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
return names
|
|
590
|
+
}
|
|
591
|
+
return names
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: string; title: string; subagent: boolean; created: string } | null> {
|
|
595
|
+
const text = await readWindow(path, false)
|
|
596
|
+
let id = ''
|
|
597
|
+
let cwd = ''
|
|
598
|
+
let title = ''
|
|
599
|
+
let created = ''
|
|
600
|
+
let subagent = false
|
|
601
|
+
for (const line of text.split('\n')) {
|
|
602
|
+
const obj = parseJsonLine(line)
|
|
603
|
+
if (!obj) continue
|
|
604
|
+
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
605
|
+
const payload = obj.payload as Record<string, unknown>
|
|
606
|
+
id = String(payload.id ?? payload.session_id ?? id)
|
|
607
|
+
cwd = String(payload.cwd ?? cwd)
|
|
608
|
+
subagent = payload.thread_source === 'subagent'
|
|
609
|
+
const nick = String(payload.agent_nickname ?? '').trim()
|
|
610
|
+
if (nick && !title) title = nick
|
|
611
|
+
const stamp = String(payload.timestamp ?? obj.timestamp ?? '').trim()
|
|
612
|
+
if (stamp && !created) created = stamp
|
|
613
|
+
}
|
|
614
|
+
if (!title && obj.type === 'response_item' && obj.payload && typeof obj.payload === 'object') {
|
|
615
|
+
const payload = obj.payload as Record<string, unknown>
|
|
616
|
+
if (payload.type === 'message' && payload.role === 'user') {
|
|
617
|
+
const textBody = payloadText(payload)
|
|
618
|
+
if (textBody && !isWrapperPrompt(textBody)) title = firstLineTitle(textBody)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (id && title) break
|
|
622
|
+
}
|
|
623
|
+
return { id, cwd, title, subagent, created }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
|
|
627
|
+
const text = await readWindow(path, true)
|
|
628
|
+
let lastUser: string | null = null
|
|
629
|
+
for (const line of text.split('\n')) {
|
|
630
|
+
const obj = parseJsonLine(line)
|
|
631
|
+
if (!obj || obj.role !== 'user') continue
|
|
632
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
633
|
+
const body = message ? payloadText(message) : null
|
|
634
|
+
if (!body) continue
|
|
635
|
+
const title = cursorUserTitle(body, true)
|
|
636
|
+
if (title) lastUser = title
|
|
637
|
+
}
|
|
638
|
+
return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function firstCursorUserTitle(path: string): Promise<string | null> {
|
|
642
|
+
const text = await readWindow(path, false)
|
|
643
|
+
let fallback: string | null = null
|
|
644
|
+
for (const line of text.split('\n')) {
|
|
645
|
+
const obj = parseJsonLine(line)
|
|
646
|
+
if (!obj || obj.role !== 'user') continue
|
|
647
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
648
|
+
const body = message ? payloadText(message) : null
|
|
649
|
+
if (!body) continue
|
|
650
|
+
const query = cursorUserTitle(body, true)
|
|
651
|
+
if (query) return query
|
|
652
|
+
if (!fallback) fallback = cursorUserTitle(body, false)
|
|
653
|
+
}
|
|
654
|
+
return fallback
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export async function listCodexJsonlFiles(sessionsRoot: string): Promise<string[]> {
|
|
658
|
+
const files: string[] = []
|
|
659
|
+
for (const year of await dirents(sessionsRoot)) {
|
|
660
|
+
const yearDir = join(sessionsRoot, year)
|
|
661
|
+
const yearSt = await fileStat(yearDir)
|
|
662
|
+
if (!yearSt || yearSt.isFile) continue
|
|
663
|
+
for (const month of await dirents(yearDir)) {
|
|
664
|
+
const monthDir = join(yearDir, month)
|
|
665
|
+
const monthSt = await fileStat(monthDir)
|
|
666
|
+
if (!monthSt || monthSt.isFile) continue
|
|
667
|
+
for (const day of await dirents(monthDir)) {
|
|
668
|
+
const dayDir = join(monthDir, day)
|
|
669
|
+
const daySt = await fileStat(dayDir)
|
|
670
|
+
if (!daySt || daySt.isFile) continue
|
|
671
|
+
for (const name of await dirents(dayDir)) {
|
|
672
|
+
if (name.endsWith('.jsonl')) files.push(join(dayDir, name))
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return files
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function listClaudeSessions(
|
|
681
|
+
projectsRoot: string,
|
|
682
|
+
now: Date,
|
|
683
|
+
liveIds: Set<string>,
|
|
684
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
685
|
+
starredIds: ReadonlySet<string> = new Set(),
|
|
686
|
+
desktopSessionsRoot = '',
|
|
687
|
+
): Promise<AgentSessionRow[]> {
|
|
688
|
+
const seen = new Set<string>()
|
|
689
|
+
const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
690
|
+
const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
|
|
691
|
+
for (const folder of await dirents(projectsRoot)) {
|
|
692
|
+
const dir = join(projectsRoot, folder)
|
|
693
|
+
const dirSt = await fileStat(dir)
|
|
694
|
+
if (!dirSt || dirSt.isFile) continue
|
|
695
|
+
for (const name of await dirents(dir)) {
|
|
696
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
697
|
+
const native = name.slice(0, -6)
|
|
698
|
+
const shortId = native.slice(0, 8)
|
|
699
|
+
if (liveIds.has(shortId) || liveIds.has(native)) continue
|
|
700
|
+
const file = join(dir, name)
|
|
701
|
+
const st = await fileStat(file)
|
|
702
|
+
if (!st?.isFile) continue
|
|
703
|
+
const pinned = starredIds.has(native.toLowerCase())
|
|
704
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
705
|
+
if (!pinned && !fresh) continue
|
|
706
|
+
const candidate = {
|
|
707
|
+
file,
|
|
708
|
+
native,
|
|
709
|
+
project: workspaceLabel(folder),
|
|
710
|
+
mtimeMs: st.mtimeMs,
|
|
711
|
+
birthtimeMs: st.birthtimeMs,
|
|
712
|
+
}
|
|
713
|
+
seen.add(native.toLowerCase())
|
|
714
|
+
if (pinned) pinnedCandidates.push(candidate)
|
|
715
|
+
else recentCandidates.push(candidate)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
for (const starred of starredIds) {
|
|
719
|
+
if (seen.has(starred) || liveIds.has(starred) || liveIds.has(starred.slice(0, 8))) continue
|
|
720
|
+
const desktop = await findClaudeDesktopFile(desktopSessionsRoot, starred)
|
|
721
|
+
if (!desktop) continue
|
|
722
|
+
const st = await fileStat(desktop)
|
|
723
|
+
if (!st?.isFile) continue
|
|
724
|
+
pinnedCandidates.push({
|
|
725
|
+
file: '',
|
|
726
|
+
native: starred,
|
|
727
|
+
project: '',
|
|
728
|
+
mtimeMs: st.mtimeMs,
|
|
729
|
+
birthtimeMs: st.birthtimeMs,
|
|
730
|
+
desktop,
|
|
731
|
+
})
|
|
732
|
+
seen.add(starred)
|
|
733
|
+
}
|
|
734
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
735
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
736
|
+
|
|
737
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
738
|
+
const rows: AgentSessionRow[] = []
|
|
739
|
+
for (const candidate of candidates) {
|
|
740
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
741
|
+
let title = ''
|
|
742
|
+
let project = candidate.project
|
|
743
|
+
let firstPrompt = ''
|
|
744
|
+
let latestAssistant = ''
|
|
745
|
+
if (candidate.file) {
|
|
746
|
+
const peek = await peekClaudeDiscussion(candidate.file)
|
|
747
|
+
firstPrompt = await firstClaudeUserTitle(candidate.file) ?? ''
|
|
748
|
+
title = peek.customTitle ?? firstPrompt
|
|
749
|
+
latestAssistant = peek.latestAssistant
|
|
750
|
+
}
|
|
751
|
+
if ((!title || !project) && (candidate.desktop || desktopSessionsRoot)) {
|
|
752
|
+
const desktop = candidate.desktop || await findClaudeDesktopFile(desktopSessionsRoot, candidate.native)
|
|
753
|
+
if (desktop) {
|
|
754
|
+
const head = peekClaudeDesktopHead(await readWindow(desktop, false))
|
|
755
|
+
if (!title && head.title) title = head.title
|
|
756
|
+
if (!project && head.cwd) project = workspaceLabel(head.cwd)
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
title = title || 'Claude session'
|
|
760
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
761
|
+
rows.push(row({
|
|
762
|
+
session_id: candidate.native,
|
|
763
|
+
provider: 'claude',
|
|
764
|
+
display_label: title,
|
|
765
|
+
project,
|
|
766
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
767
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
768
|
+
alive: false,
|
|
769
|
+
pinned: starredIds.has(candidate.native.toLowerCase()),
|
|
770
|
+
...discussionFields(title, firstPrompt, latestAssistant),
|
|
771
|
+
}))
|
|
772
|
+
}
|
|
773
|
+
return rows
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return [
|
|
777
|
+
...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
|
|
778
|
+
...await toRows(recentCandidates, Math.max(0, cap)),
|
|
779
|
+
]
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export async function listCodexSessions(
|
|
783
|
+
sessionsRoot: string,
|
|
784
|
+
now: Date,
|
|
785
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
786
|
+
): Promise<AgentSessionRow[]> {
|
|
787
|
+
const names = await loadCodexThreadNames(sessionsRoot)
|
|
788
|
+
const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
|
|
789
|
+
const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
790
|
+
const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
|
|
791
|
+
for (const file of await listCodexJsonlFiles(sessionsRoot)) {
|
|
792
|
+
const st = await fileStat(file)
|
|
793
|
+
if (!st?.isFile) continue
|
|
794
|
+
const name = file.split('/').pop() || file
|
|
795
|
+
const fileId = idFromCodexFilename(name)
|
|
796
|
+
const pinned = fileId ? pinnedIds.has(fileId) : false
|
|
797
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
798
|
+
if (!pinned && !fresh) continue
|
|
799
|
+
const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
|
|
800
|
+
if (pinned) pinnedCandidates.push(candidate)
|
|
801
|
+
else recentCandidates.push(candidate)
|
|
802
|
+
}
|
|
803
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
804
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
805
|
+
|
|
806
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
807
|
+
const rows: AgentSessionRow[] = []
|
|
808
|
+
for (const candidate of candidates) {
|
|
809
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
810
|
+
const meta = await peekCodexMeta(candidate.file)
|
|
811
|
+
if (!meta || meta.subagent) continue
|
|
812
|
+
const native = meta.id || candidate.name.slice(0, -6)
|
|
813
|
+
const title = names.get(native) || meta.title || 'Codex session'
|
|
814
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
815
|
+
const created = meta.created || createdFromCodexFilename(candidate.name) || isoFromMtime(candidate.birthtimeMs)
|
|
816
|
+
const latestAssistant = latestAssistantFromWindow(await readWindow(candidate.file, true))
|
|
817
|
+
rows.push(row({
|
|
818
|
+
session_id: native,
|
|
819
|
+
provider: 'codex',
|
|
820
|
+
display_label: title,
|
|
821
|
+
project: workspaceLabel(meta.cwd),
|
|
822
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
823
|
+
created,
|
|
824
|
+
alive: false,
|
|
825
|
+
pinned: pinnedIds.has(native.toLowerCase()),
|
|
826
|
+
...discussionFields(title, meta.title, latestAssistant),
|
|
827
|
+
}))
|
|
828
|
+
}
|
|
829
|
+
return rows
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
|
|
833
|
+
const recentRows = await toRows(recentCandidates, Math.max(0, cap))
|
|
834
|
+
return [...pinnedRows, ...recentRows]
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export async function listCursorSessions(
|
|
838
|
+
projectsRoot: string,
|
|
839
|
+
now: Date,
|
|
840
|
+
cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
|
|
841
|
+
composerDb = '',
|
|
842
|
+
pinnedIds: ReadonlySet<string> = new Set(),
|
|
843
|
+
): Promise<AgentSessionRow[]> {
|
|
844
|
+
const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
|
|
845
|
+
const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
|
|
846
|
+
for (const folder of await dirents(projectsRoot)) {
|
|
847
|
+
if (folder.includes('var-folders') || folder.includes('private-var')) continue
|
|
848
|
+
const transcripts = join(projectsRoot, folder, 'agent-transcripts')
|
|
849
|
+
for (const sessionDir of await dirents(transcripts)) {
|
|
850
|
+
if (sessionDir === 'subagents') continue
|
|
851
|
+
const pinned = pinnedIds.has(sessionDir.toLowerCase())
|
|
852
|
+
if (folder === 'empty-window' && !pinned) continue
|
|
853
|
+
const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
|
|
854
|
+
const st = await fileStat(file)
|
|
855
|
+
if (!st?.isFile) continue
|
|
856
|
+
if (!pinned && st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
|
|
857
|
+
const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
|
|
858
|
+
if (!pinned && !fresh) continue
|
|
859
|
+
const next = {
|
|
860
|
+
file,
|
|
861
|
+
sessionDir,
|
|
862
|
+
project: workspaceLabel(folder),
|
|
863
|
+
mtimeMs: st.mtimeMs,
|
|
864
|
+
birthtimeMs: st.birthtimeMs,
|
|
865
|
+
}
|
|
866
|
+
const existing = byId.get(sessionDir)
|
|
867
|
+
byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
|
|
871
|
+
const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
|
|
872
|
+
pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
873
|
+
recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
874
|
+
|
|
875
|
+
const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
|
|
876
|
+
const rows: AgentSessionRow[] = []
|
|
877
|
+
for (const candidate of candidates) {
|
|
878
|
+
if (rows.length >= Math.max(0, limit)) break
|
|
879
|
+
const peek = await peekCursorDiscussion(candidate.file)
|
|
880
|
+
const firstPrompt = await firstCursorUserTitle(candidate.file) ?? peek.lastUser ?? ''
|
|
881
|
+
const title = composerNames.get(candidate.sessionDir)
|
|
882
|
+
?? peek.lastUser
|
|
883
|
+
?? firstPrompt
|
|
884
|
+
?? 'Cursor session'
|
|
885
|
+
if (isKeepWarmSessionTitle(title)) continue
|
|
886
|
+
const alive = now.getTime() - candidate.mtimeMs < 180_000
|
|
887
|
+
rows.push(row({
|
|
888
|
+
session_id: candidate.sessionDir,
|
|
889
|
+
provider: 'cursor',
|
|
890
|
+
display_label: title,
|
|
891
|
+
project: candidate.project,
|
|
892
|
+
modified: isoFromMtime(candidate.mtimeMs),
|
|
893
|
+
created: isoFromMtime(candidate.birthtimeMs),
|
|
894
|
+
alive,
|
|
895
|
+
state: alive ? 'running' : 'recent',
|
|
896
|
+
pinned: pinnedIds.has(candidate.sessionDir.toLowerCase()),
|
|
897
|
+
...discussionFields(title, firstPrompt, peek.latestAssistant),
|
|
898
|
+
}))
|
|
899
|
+
}
|
|
900
|
+
return rows
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return [
|
|
904
|
+
...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
|
|
905
|
+
...await toRows(recentCandidates, Math.max(0, cap)),
|
|
906
|
+
]
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function enrichLiveClaude(row: AgentSessionRow, roots: AgentSessionRoots): Promise<AgentSessionRow> {
|
|
910
|
+
if (row.provider !== 'claude') return row
|
|
911
|
+
const found = await findAgentSessionFile('claude', row.session_id, roots)
|
|
912
|
+
if (!found) return row
|
|
913
|
+
const peek = await peekClaudeDiscussion(found)
|
|
914
|
+
const firstPrompt = await firstClaudeUserTitle(found)
|
|
915
|
+
const title = peek.customTitle ?? firstPrompt ?? row.display_label
|
|
916
|
+
const fullId = found.split('/').pop()?.replace(/\.jsonl$/i, '') || row.session_id
|
|
917
|
+
return {
|
|
918
|
+
...row,
|
|
919
|
+
session_id: fullId,
|
|
920
|
+
display_label: title || row.display_label || 'Claude session',
|
|
921
|
+
...discussionFields(title || row.display_label, firstPrompt || '', peek.latestAssistant),
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function sessionKey(row: AgentSessionRow): string {
|
|
926
|
+
return `${row.provider}:${row.session_id.trim().toLowerCase()}`
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function preferSession(next: AgentSessionRow, current: AgentSessionRow): AgentSessionRow {
|
|
930
|
+
if (next.alive !== current.alive) return next.alive ? next : current
|
|
931
|
+
if (next.pinned !== current.pinned) return next.pinned ? next : current
|
|
932
|
+
if (next.project === 'empty-window' && current.project !== 'empty-window') return current
|
|
933
|
+
if (current.project === 'empty-window' && next.project !== 'empty-window') return next
|
|
934
|
+
return (next.modified || '') > (current.modified || '') ? next : current
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function dedupeSessions(rows: AgentSessionRow[]): AgentSessionRow[] {
|
|
938
|
+
const byKey = new Map<string, AgentSessionRow>()
|
|
939
|
+
for (const row of rows) {
|
|
940
|
+
const key = sessionKey(row)
|
|
941
|
+
const existing = byKey.get(key)
|
|
942
|
+
byKey.set(key, existing ? preferSession(row, existing) : row)
|
|
943
|
+
}
|
|
944
|
+
return [...byKey.values()]
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export async function listAgentSessions(
|
|
948
|
+
roots: AgentSessionRoots,
|
|
949
|
+
now = new Date(),
|
|
950
|
+
live: AgentSessionRow[] = [],
|
|
951
|
+
limit = AGENT_SESSION_LIST_LIMIT,
|
|
952
|
+
sort: AgentSessionSort = 'updated',
|
|
953
|
+
): Promise<AgentSessionRow[]> {
|
|
954
|
+
const starredIds = await loadClaudeStarredIds(roots.claudeDesktopConfig)
|
|
955
|
+
const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
|
|
956
|
+
const enrichedLive = (await Promise.all(live.map(row => enrichLiveClaude(row, roots))))
|
|
957
|
+
.filter(entry => !isKeepWarmSessionTitle(entry.display_label))
|
|
958
|
+
.map(entry => {
|
|
959
|
+
if (entry.provider === 'claude' && starredIds.has(normalizeClaudeSessionId(entry.session_id))) {
|
|
960
|
+
return { ...entry, pinned: true }
|
|
961
|
+
}
|
|
962
|
+
if (entry.provider === 'cursor' && cursorPinned.has(entry.session_id.toLowerCase())) {
|
|
963
|
+
return { ...entry, pinned: true }
|
|
964
|
+
}
|
|
965
|
+
return entry
|
|
966
|
+
})
|
|
967
|
+
const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
|
|
968
|
+
const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
|
|
969
|
+
let rows = dedupeSessions([
|
|
970
|
+
...enrichedLive,
|
|
971
|
+
...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions),
|
|
972
|
+
...await listCodexSessions(roots.codexSessions, now, cap),
|
|
973
|
+
...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned),
|
|
974
|
+
])
|
|
975
|
+
if (sort === 'opened') {
|
|
976
|
+
rows = rows.filter(entry => {
|
|
977
|
+
if (entry.alive) return true
|
|
978
|
+
const created = Date.parse(entry.created)
|
|
979
|
+
return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
|
|
980
|
+
})
|
|
981
|
+
// Control Activity → Opened: created desc. Pins stay in the payload
|
|
982
|
+
// when they were opened in-window; they do not cluster at the top.
|
|
983
|
+
rows.sort((a, b) => (b.created || '').localeCompare(a.created || ''))
|
|
984
|
+
} else {
|
|
985
|
+
// Control Activity → Updated: newest write first. Stale pins still
|
|
986
|
+
// appear (any age) but sink by mtime. Glasses has no Pinned clock, so
|
|
987
|
+
// pin-boosting here made the lens show July stars instead of today.
|
|
988
|
+
rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
|
|
989
|
+
}
|
|
990
|
+
return rows.slice(0, limit)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export async function findAgentSessionFile(
|
|
994
|
+
provider: AgentProvider,
|
|
995
|
+
sessionId: string,
|
|
996
|
+
roots: AgentSessionRoots,
|
|
997
|
+
now = new Date(),
|
|
998
|
+
): Promise<string | null> {
|
|
999
|
+
if (!isSafeSessionId(sessionId)) return null
|
|
1000
|
+
const needle = sessionId.trim().toLowerCase()
|
|
1001
|
+
if (provider === 'claude') {
|
|
1002
|
+
for (const folder of await dirents(roots.claudeProjects)) {
|
|
1003
|
+
const dir = join(roots.claudeProjects, folder)
|
|
1004
|
+
for (const name of await dirents(dir)) {
|
|
1005
|
+
if (!name.endsWith('.jsonl')) continue
|
|
1006
|
+
const id = name.slice(0, -6).toLowerCase()
|
|
1007
|
+
if (id === needle || id.startsWith(needle)) return join(dir, name)
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return null
|
|
1011
|
+
}
|
|
1012
|
+
if (provider === 'codex') {
|
|
1013
|
+
for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
|
|
1014
|
+
const name = file.split('/').pop()?.toLowerCase() ?? ''
|
|
1015
|
+
if (name.includes(needle)) return file
|
|
1016
|
+
}
|
|
1017
|
+
return null
|
|
1018
|
+
}
|
|
1019
|
+
let best: { file: string; mtimeMs: number } | null = null
|
|
1020
|
+
for (const folder of await dirents(roots.cursorProjects)) {
|
|
1021
|
+
if (isSkippedCursorFolder(folder)) continue
|
|
1022
|
+
const file = join(roots.cursorProjects, folder, 'agent-transcripts', needle, `${needle}.jsonl`)
|
|
1023
|
+
const st = await fileStat(file)
|
|
1024
|
+
if (!st?.isFile) continue
|
|
1025
|
+
if (!best || st.mtimeMs >= best.mtimeMs) best = { file, mtimeMs: st.mtimeMs }
|
|
1026
|
+
}
|
|
1027
|
+
return best?.file ?? null
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Bytes read from the START of an oversized transcript — enough for the opening ask
|
|
1031
|
+
* and the session_meta record that carries cwd/branch. */
|
|
1032
|
+
export const PARTIAL_HEAD_BYTES = 256 * 1024
|
|
1033
|
+
/** Bytes read from the END. Larger than the head because the recent turns are what a
|
|
1034
|
+
* follow-up continues from. */
|
|
1035
|
+
export const PARTIAL_TAIL_BYTES = 768 * 1024
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Lines of a transcript, reading the WHOLE file when it fits and head+tail when it
|
|
1039
|
+
* does not.
|
|
1040
|
+
*
|
|
1041
|
+
* WHY. `GET /api/agent-sessions/:provider/:id` used to answer 413 "Session too large
|
|
1042
|
+
* to open" for anything over 32 MiB, so the biggest sessions — the ones most worth
|
|
1043
|
+
* summarizing before a follow-up — returned nothing at all. A 67 MB transcript is
|
|
1044
|
+
* real; today's own session is one. Refusing was never necessary: the detail page
|
|
1045
|
+
* needs the opening turns, the recent turns, and the counts, and two bounded windows
|
|
1046
|
+
* carry all three without loading 67 MB into memory.
|
|
1047
|
+
*
|
|
1048
|
+
* The first line of the tail window is almost always a fragment of a JSON record.
|
|
1049
|
+
* `parseJsonLine` returns null for it and the caller's loop skips it, which is why
|
|
1050
|
+
* this can slice at an arbitrary byte offset and stay correct.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function* agentSessionLines(
|
|
1053
|
+
path: string,
|
|
1054
|
+
size: number,
|
|
1055
|
+
maxBytes: number,
|
|
1056
|
+
// Window sizes are injectable so a test can prove ELISION without writing a
|
|
1057
|
+
// multi-megabyte fixture. With the production 256 KiB + 768 KiB defaults, any
|
|
1058
|
+
// fixture small enough to build quickly is fully covered by the two windows — so
|
|
1059
|
+
// the test would read the whole file, see every turn, and pass identically with
|
|
1060
|
+
// windowing removed. It did exactly that until a mutation caught it.
|
|
1061
|
+
headBytes = PARTIAL_HEAD_BYTES,
|
|
1062
|
+
tailBytes = PARTIAL_TAIL_BYTES,
|
|
1063
|
+
): AsyncGenerator<string> {
|
|
1064
|
+
if (size <= maxBytes) {
|
|
1065
|
+
yield* createInterface({ input: createReadStream(path), crlfDelay: Infinity })
|
|
1066
|
+
return
|
|
1067
|
+
}
|
|
1068
|
+
yield* createInterface({
|
|
1069
|
+
input: createReadStream(path, { start: 0, end: headBytes - 1 }),
|
|
1070
|
+
crlfDelay: Infinity,
|
|
1071
|
+
})
|
|
1072
|
+
yield* createInterface({
|
|
1073
|
+
input: createReadStream(path, { start: Math.max(headBytes, size - tailBytes) }),
|
|
1074
|
+
crlfDelay: Infinity,
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export interface AgentSessionDetail {
|
|
1079
|
+
session_id: string
|
|
1080
|
+
provider: AgentProvider
|
|
1081
|
+
display_label: string
|
|
1082
|
+
project: string
|
|
1083
|
+
git_branch: string
|
|
1084
|
+
first_prompt: string
|
|
1085
|
+
discussion_summary: string
|
|
1086
|
+
/** Deep, paginated body text for the detail page — up to 2000 chars. The list row
|
|
1087
|
+
* keeps `discussion_summary` at 180. Older clients ignore this field. */
|
|
1088
|
+
discussion_digest: string
|
|
1089
|
+
user_message_count: number
|
|
1090
|
+
assistant_message_count: number
|
|
1091
|
+
omitted_tools: number
|
|
1092
|
+
file_size_bytes: number
|
|
1093
|
+
/** True when the transcript exceeded the read ceiling and only head+tail were
|
|
1094
|
+
* parsed. The message counts are then counts of what was READ, not of the
|
|
1095
|
+
* session — the client must not present them as exact. */
|
|
1096
|
+
truncated: boolean
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
export async function parseAgentSession(
|
|
1100
|
+
provider: AgentProvider,
|
|
1101
|
+
path: string,
|
|
1102
|
+
opts: { maxBytes?: number; headBytes?: number; tailBytes?: number } = {},
|
|
1103
|
+
): Promise<AgentSessionDetail> {
|
|
1104
|
+
const st = await stat(path)
|
|
1105
|
+
const maxBytes = opts.maxBytes ?? AGENT_SESSION_MAX_FILE_BYTES
|
|
1106
|
+
const truncated = st.size > maxBytes
|
|
1107
|
+
let title = ''
|
|
1108
|
+
let project = ''
|
|
1109
|
+
let gitBranch = ''
|
|
1110
|
+
let sessionId = path.split('/').pop()?.replace(/\.jsonl$/, '') || ''
|
|
1111
|
+
let firstPrompt = ''
|
|
1112
|
+
let latestAssistant = ''
|
|
1113
|
+
let userCount = 0
|
|
1114
|
+
// Bounded sample for the deep digest: the opening turns plus a recent window.
|
|
1115
|
+
// The composer only ever renders head + as many recent as fit 2000 chars, so
|
|
1116
|
+
// retaining the whole conversation would be memory held for nothing — a 900-turn
|
|
1117
|
+
// session is real. `userCount` still carries the TRUE total so the elision line
|
|
1118
|
+
// reports what was actually dropped rather than what this buffer happens to hold.
|
|
1119
|
+
const digestHead: string[] = []
|
|
1120
|
+
const digestRecent: string[] = []
|
|
1121
|
+
const collectTurn = (text: string): void => {
|
|
1122
|
+
// Reuse the wrapper filter the rest of this module already trusts. Without it the
|
|
1123
|
+
// opening turns of a slash-command session render as
|
|
1124
|
+
// "<command-message>cos-glasses</command-message>…" — scaffolding, not the ask,
|
|
1125
|
+
// and it lands in the two most valuable slots in the digest.
|
|
1126
|
+
const raw = (text ?? '').trim()
|
|
1127
|
+
if (!raw || isWrapperPrompt(raw)) return
|
|
1128
|
+
// Same tag-stripping firstLineTitle does, so an inline <user_query> wrapper does
|
|
1129
|
+
// not leak angle brackets into the body.
|
|
1130
|
+
const unwrapped = (() => {
|
|
1131
|
+
const start = raw.indexOf('<user_query>')
|
|
1132
|
+
const end = raw.indexOf('</user_query>')
|
|
1133
|
+
const inner = start >= 0 && end > start ? raw.slice(start + '<user_query>'.length, end) : raw
|
|
1134
|
+
return inner.replace(/<[^>]+>/g, ' ')
|
|
1135
|
+
})()
|
|
1136
|
+
const line = unwrapped.replace(/\s+/g, ' ').trim()
|
|
1137
|
+
if (!line) return
|
|
1138
|
+
if (digestHead.length < DIGEST_HEAD_TURNS) { digestHead.push(line.slice(0, DIGEST_TURN_MAX)); return }
|
|
1139
|
+
digestRecent.push(line.slice(0, DIGEST_TURN_MAX))
|
|
1140
|
+
if (digestRecent.length > DIGEST_RECENT_WINDOW) digestRecent.shift()
|
|
1141
|
+
}
|
|
1142
|
+
let assistantCount = 0
|
|
1143
|
+
let omittedTools = 0
|
|
1144
|
+
|
|
1145
|
+
for await (const line of agentSessionLines(path, st.size, maxBytes, opts.headBytes, opts.tailBytes)) {
|
|
1146
|
+
const obj = parseJsonLine(line)
|
|
1147
|
+
if (!obj) continue
|
|
1148
|
+
if (provider === 'claude') {
|
|
1149
|
+
if (typeof obj.sessionId === 'string' && obj.sessionId) sessionId = obj.sessionId
|
|
1150
|
+
if (obj.type === 'custom-title' && typeof obj.customTitle === 'string' && obj.customTitle.trim()) {
|
|
1151
|
+
title = obj.customTitle.trim()
|
|
1152
|
+
continue
|
|
1153
|
+
}
|
|
1154
|
+
if (obj.isSidechain === true) continue
|
|
1155
|
+
if (!project && typeof obj.cwd === 'string') project = workspaceLabel(obj.cwd)
|
|
1156
|
+
if (!gitBranch && typeof obj.gitBranch === 'string') gitBranch = obj.gitBranch
|
|
1157
|
+
if (obj.type === 'user' && obj.toolUseResult) { omittedTools += 1; continue }
|
|
1158
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue
|
|
1159
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
1160
|
+
const text = message ? payloadText(message) : null
|
|
1161
|
+
if (!text) {
|
|
1162
|
+
if (obj.type === 'assistant') omittedTools += 1
|
|
1163
|
+
continue
|
|
1164
|
+
}
|
|
1165
|
+
if (obj.type === 'user') {
|
|
1166
|
+
userCount += 1
|
|
1167
|
+
collectTurn(text)
|
|
1168
|
+
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
1169
|
+
if (!title) title = firstLineTitle(text)
|
|
1170
|
+
} else {
|
|
1171
|
+
assistantCount += 1
|
|
1172
|
+
const snippet = proseSnippet(text)
|
|
1173
|
+
if (snippet) latestAssistant = snippet
|
|
1174
|
+
}
|
|
1175
|
+
} else if (provider === 'codex') {
|
|
1176
|
+
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
1177
|
+
const payload = obj.payload as Record<string, unknown>
|
|
1178
|
+
if (typeof payload.cwd === 'string') project = workspaceLabel(payload.cwd)
|
|
1179
|
+
if (typeof payload.id === 'string' && payload.id) sessionId = payload.id
|
|
1180
|
+
const git = payload.git && typeof payload.git === 'object' ? payload.git as Record<string, unknown> : null
|
|
1181
|
+
if (git && typeof git.branch === 'string') gitBranch = git.branch
|
|
1182
|
+
continue
|
|
1183
|
+
}
|
|
1184
|
+
if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') continue
|
|
1185
|
+
const payload = obj.payload as Record<string, unknown>
|
|
1186
|
+
const kind = String(payload.type ?? '')
|
|
1187
|
+
if (kind === 'custom_tool_call' || kind === 'custom_tool_call_output' || kind === 'function_call') {
|
|
1188
|
+
omittedTools += 1
|
|
1189
|
+
continue
|
|
1190
|
+
}
|
|
1191
|
+
if (kind !== 'message' || payload.role === 'developer') continue
|
|
1192
|
+
const text = payloadText(payload)
|
|
1193
|
+
if (!text || isWrapperPrompt(text)) continue
|
|
1194
|
+
if (payload.role === 'assistant') {
|
|
1195
|
+
assistantCount += 1
|
|
1196
|
+
const snippet = proseSnippet(text)
|
|
1197
|
+
if (snippet) latestAssistant = snippet
|
|
1198
|
+
} else {
|
|
1199
|
+
userCount += 1
|
|
1200
|
+
collectTurn(text)
|
|
1201
|
+
if (!firstPrompt) firstPrompt = firstLineTitle(text)
|
|
1202
|
+
if (!title) title = firstLineTitle(text)
|
|
1203
|
+
}
|
|
1204
|
+
} else {
|
|
1205
|
+
if (obj.role !== 'user' && obj.role !== 'assistant') continue
|
|
1206
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
1207
|
+
if (Array.isArray(message?.content) && (message.content as Array<{ type?: string }>).some(b => b?.type === 'tool_use')) {
|
|
1208
|
+
omittedTools += 1
|
|
1209
|
+
}
|
|
1210
|
+
const text = message ? payloadText(message) : null
|
|
1211
|
+
if (!text) continue
|
|
1212
|
+
if (obj.role === 'user') {
|
|
1213
|
+
userCount += 1
|
|
1214
|
+
const query = cursorUserTitle(text, true) ?? cursorUserTitle(text, false)
|
|
1215
|
+
collectTurn(query ?? text)
|
|
1216
|
+
if (query) {
|
|
1217
|
+
title = query
|
|
1218
|
+
if (!firstPrompt) firstPrompt = query
|
|
1219
|
+
}
|
|
1220
|
+
} else {
|
|
1221
|
+
assistantCount += 1
|
|
1222
|
+
const snippet = proseSnippet(text)
|
|
1223
|
+
if (snippet) latestAssistant = snippet
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (provider === 'cursor' && !project) {
|
|
1229
|
+
const parts = path.split('/')
|
|
1230
|
+
const encoded = parts[parts.indexOf('agent-transcripts') - 1] || ''
|
|
1231
|
+
project = workspaceLabel(encoded)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const display = title || (provider === 'codex' ? 'Codex session' : provider === 'cursor' ? 'Cursor session' : 'Claude session')
|
|
1235
|
+
return {
|
|
1236
|
+
session_id: sessionId,
|
|
1237
|
+
provider,
|
|
1238
|
+
display_label: display,
|
|
1239
|
+
project,
|
|
1240
|
+
git_branch: gitBranch,
|
|
1241
|
+
first_prompt: firstPrompt || title,
|
|
1242
|
+
discussion_summary: composeDiscussionSummary({
|
|
1243
|
+
title: display,
|
|
1244
|
+
firstPrompt: firstPrompt || title,
|
|
1245
|
+
latestAssistant,
|
|
1246
|
+
}),
|
|
1247
|
+
discussion_digest: composeDiscussionDigest({
|
|
1248
|
+
userTurns: [...digestHead, ...digestRecent],
|
|
1249
|
+
latestAssistant,
|
|
1250
|
+
// On a truncated read `userCount` counts only the sampled windows, so passing
|
|
1251
|
+
// it as the total would print a confidently WRONG "… 12 earlier turns …" for a
|
|
1252
|
+
// session with hundreds. The digest drops the number instead of inventing one.
|
|
1253
|
+
totalTurns: truncated ? undefined : userCount,
|
|
1254
|
+
truncated,
|
|
1255
|
+
}),
|
|
1256
|
+
user_message_count: userCount,
|
|
1257
|
+
assistant_message_count: assistantCount,
|
|
1258
|
+
omitted_tools: omittedTools,
|
|
1259
|
+
file_size_bytes: st.size,
|
|
1260
|
+
truncated,
|
|
1261
|
+
}
|
|
1262
|
+
}
|