@gotcos/glasses-server 6.95.0 → 6.98.1
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 +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sessions meaning search can ask about (Control 0.5.363, the "find" route): which sessions exist, what each is
|
|
3
|
+
* called, what its first typed prompt says, and when it last did something.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT THE LIST OR THE SESSIONS-TAB COLLECTOR (validated 2026-11-01, all measured on the owner's Mac).
|
|
6
|
+
* - The list keeps the first LINE of the first prompt, cut to 80 characters. 431 of 467 first prompts are longer, and
|
|
7
|
+
* meaning search on titles alone found the right session 15 times in 20 against 19 with the first 150 characters.
|
|
8
|
+
* - The list is capped at 7 days of file writes and 80 rows. mtime also lies: 234 of 854 Claude transcripts were touched
|
|
9
|
+
* 7 or more days after their last record (a relaunch appends bookkeeping). This collector orders by the transcript's own
|
|
10
|
+
* last record, with its own memo, because the list's memo holds 480 entries and thrashes at this size.
|
|
11
|
+
* - Machine sessions fill the newest 254 slots: raw, they reach 8 days; without them, about 33. Dropped here: no typed
|
|
12
|
+
* first prompt, a title seen 5 or more times, a Claude Desktop scheduled task, a keep-warm title, an archived Desktop
|
|
13
|
+
* session, and anything that ran on a key.
|
|
14
|
+
* - Codex compresses finished rollouts (1,080 of 1,099 on that Mac are `.jsonl.zst`). `listCodexJsonlFiles` only takes
|
|
15
|
+
* `.jsonl`, and every reader of it (detail, stream, completion, tails) is zstd-blind, so it is left alone. This module
|
|
16
|
+
* has its own head-only lister and reader. A compressed row carries `compressed: true` and no file path ever leaves.
|
|
17
|
+
*
|
|
18
|
+
* ZSTD IS OPTIONAL. `createZstdDecompress` exists from Node 22.15 and 23.8; `engines` is `>=20.11`. A NAMED import of it
|
|
19
|
+
* fails when the module loads on an older Node and the server would not boot. The default import plus a `typeof` check at
|
|
20
|
+
* the call keeps boot safe; without the function a compressed file is skipped and COUNTED (`skippedNoDecoder`), never
|
|
21
|
+
* guessed at and never silent.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here reads more than the first 16 MB of a file, calls a model, or logs a title or a prompt.
|
|
24
|
+
*/
|
|
25
|
+
import { createReadStream } from 'node:fs'
|
|
26
|
+
import { execFile } from 'node:child_process'
|
|
27
|
+
import { open } from 'node:fs/promises'
|
|
28
|
+
import { promisify } from 'node:util'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
31
|
+
import type { Transform } from 'node:stream'
|
|
32
|
+
import zlib from 'node:zlib'
|
|
33
|
+
import {
|
|
34
|
+
AGENT_SESSION_MAX_FILE_BYTES,
|
|
35
|
+
CLAUDE_UUID_JSONL,
|
|
36
|
+
agentSessionRoots,
|
|
37
|
+
codexUserText,
|
|
38
|
+
createdFromCodexFilename,
|
|
39
|
+
dirents,
|
|
40
|
+
fileStat,
|
|
41
|
+
firstLineTitle,
|
|
42
|
+
isKeepWarmSessionTitle,
|
|
43
|
+
isSkippedCursorFolder,
|
|
44
|
+
isWrapperPrompt,
|
|
45
|
+
isoFromMtime,
|
|
46
|
+
lastCustomTitle,
|
|
47
|
+
loadClaudeDesktopAliases,
|
|
48
|
+
loadCodexThreadNames,
|
|
49
|
+
parseJsonLine,
|
|
50
|
+
payloadText,
|
|
51
|
+
preferCursorCopy,
|
|
52
|
+
workspaceLabel,
|
|
53
|
+
type AgentProvider,
|
|
54
|
+
type AgentSessionRoots,
|
|
55
|
+
} from './agent-session-store.js'
|
|
56
|
+
import { ACTIVITY_READ_CONCURRENCY, ACTIVITY_TAIL_BYTES, ACTIVITY_TAIL_MAX_BYTES, activityFromTail, mapWithConcurrency } from './agent-session-activity.js'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The most that is read from the top of a file (decompressed) to find its first typed prompt, stopping at the first one. A typed
|
|
60
|
+
* prompt is rarely deep: 256 KB found 135 of 172 Codex first prompts, 1 MB found 151, 16 MB all of them. Only a file that has none
|
|
61
|
+
* near the top reads further, so the cost lands on long machine runs, once per file (the memo keeps the answer).
|
|
62
|
+
*/
|
|
63
|
+
export const FIND_HEAD_BYTES = 16 * 1024 * 1024
|
|
64
|
+
const FIND_CHUNK_BYTES = 64 * 1024
|
|
65
|
+
/** A title seen this many times in the pool is a machine run (a routine, a proxy, a probe), not a conversation. */
|
|
66
|
+
export const FIND_REPEAT_TITLE_LIMIT = 5
|
|
67
|
+
/** The typed first prompt kept per session, unwrapped and on one line. Redaction reads all of it; the model gets 150. */
|
|
68
|
+
export const FIND_PROMPT_KEEP_CHARS = 2_000
|
|
69
|
+
/**
|
|
70
|
+
* A prompt is unwrapped from at most this much of its start, so an unclosed tag cannot make the scan quadratic (the wrapper
|
|
71
|
+
* pattern rescans to the end from every opener). Likewise the list's firstLineTitle, whose `<[^>]+>` is quadratic on a body of
|
|
72
|
+
* `a<b a<b ...` and blocked a server for 68 s on 800 KB: it only ever sees the first TITLE_SCAN_CHARS.
|
|
73
|
+
*/
|
|
74
|
+
const UNWRAP_SCAN_CHARS = 16_000
|
|
75
|
+
const TITLE_SCAN_CHARS = 8_000
|
|
76
|
+
/** A line longer than this is skipped through to its newline: it is an attachment or a dump, never a typed prompt. */
|
|
77
|
+
const MAX_LINE_CHARS = 2 * 1024 * 1024
|
|
78
|
+
/** Files opened at most per walk. The newest-first stop below ends nearly every walk long before this. */
|
|
79
|
+
export const FIND_MAX_OPEN = 4_000
|
|
80
|
+
/** Files read at once, and files per batch before the stop rule is checked. */
|
|
81
|
+
const BATCH = 32
|
|
82
|
+
/** A Desktop record read whole to find its scheduled-task and archived flags. The largest seen is 0.7 MB. */
|
|
83
|
+
const DESKTOP_RECORD_MAX_BYTES = 8 * 1024 * 1024
|
|
84
|
+
export const FIND_MEMO_MAX = 8_000
|
|
85
|
+
/** What the memo may hold in all, by the characters it keeps (each entry is also flattened, see flat()). */
|
|
86
|
+
export const FIND_MEMO_MAX_CHARS = 16 * 1024 * 1024
|
|
87
|
+
|
|
88
|
+
const WRAP_TAGS = 'system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|local-command-stdout|local-command-stderr|command-name|command-message|command-args|task-notification|user-prompt-submit-hook|ide_opened_file|ide_selection|environment_context|attached_files|open_files|user_info|git_status|rules|agent_transcripts|image'
|
|
89
|
+
const WRAP_RE = new RegExp(`<(${WRAP_TAGS})(?:\\s[^>]*)?>[\\s\\S]*?</\\1>`, 'gi')
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What the person typed, out of a user turn: the Cursor `<user_query>` body when there is one, the harness wrapper blocks
|
|
93
|
+
* out, whitespace on one line. The canary's rule (2026-11-01), which found the right session first in 19 of 20 searches.
|
|
94
|
+
*/
|
|
95
|
+
export function unwrapPrompt(text: string): string {
|
|
96
|
+
let body = text.slice(0, UNWRAP_SCAN_CHARS)
|
|
97
|
+
const open = body.indexOf('<user_query>'), close = body.indexOf('</user_query>')
|
|
98
|
+
if (open >= 0 && close > open) body = body.slice(open + '<user_query>'.length, close)
|
|
99
|
+
return body.replace(WRAP_RE, ' ').replace(/\s+/g, ' ').trim()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface FindCandidate {
|
|
103
|
+
provider: AgentProvider
|
|
104
|
+
session_id: string
|
|
105
|
+
/** `provider:id` in lower case: one identity across the pool, the cache and the merge. */
|
|
106
|
+
key: string
|
|
107
|
+
/** As the list titles it (Desktop alias, then custom title, then the first line of the first prompt). Never sent as is. */
|
|
108
|
+
title: string
|
|
109
|
+
/** The list's first line of the first prompt, at most 80 characters. */
|
|
110
|
+
listFirstLine: string
|
|
111
|
+
/** The first typed prompt, unwrapped, on one line, at most FIND_PROMPT_KEEP_CHARS. Never sent as is. */
|
|
112
|
+
typed: string
|
|
113
|
+
project: string
|
|
114
|
+
/** The newest record's time (or the file's write time where records carry none), never later than the write time. */
|
|
115
|
+
clockMs: number
|
|
116
|
+
mtimeMs: number
|
|
117
|
+
birthtimeMs: number
|
|
118
|
+
created: string
|
|
119
|
+
/** A `.jsonl.zst` Codex rollout: no tail can be read, so it cannot be opened from COS, only from the Codex app. */
|
|
120
|
+
compressed: boolean
|
|
121
|
+
/** Where the transcript is. Server side only; no row carries it. */
|
|
122
|
+
file: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FindPool {
|
|
126
|
+
candidates: FindCandidate[]
|
|
127
|
+
/** Compressed files left out because this Node cannot decompress zstd. */
|
|
128
|
+
skippedNoDecoder: number
|
|
129
|
+
/** Compressed files left out because they could not be read (a damaged or half-written file). */
|
|
130
|
+
skippedUnreadable: number
|
|
131
|
+
/** Files whose heads were read this walk (memo hits are free and not counted). */
|
|
132
|
+
opened: number
|
|
133
|
+
considered: number
|
|
134
|
+
ms: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type ZstdFactory = () => Transform | null
|
|
138
|
+
|
|
139
|
+
/** Resolved at the call, so a Node without it (and a test that removes it) both read as "absent". */
|
|
140
|
+
export function zstdDecompressor(): Transform | null {
|
|
141
|
+
const make = (zlib as { createZstdDecompress?: () => Transform }).createZstdDecompress
|
|
142
|
+
return typeof make === 'function' ? make.call(zlib) : null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `rollout-<stamp>-<uuid>.jsonl` or `.jsonl.zst`: the uuid, or null. Not `idFromCodexFilename`, which stays `.jsonl` only. */
|
|
146
|
+
export function rolloutFileId(name: string): string | null {
|
|
147
|
+
const base = rolloutBaseName(name)
|
|
148
|
+
if (!base) return null
|
|
149
|
+
const parts = base.split('-')
|
|
150
|
+
if (parts.length < 5) return null
|
|
151
|
+
const uuid = parts.slice(-5).join('-').toLowerCase()
|
|
152
|
+
return uuid.length >= 36 ? uuid : null
|
|
153
|
+
}
|
|
154
|
+
function rolloutBaseName(name: string): string | null {
|
|
155
|
+
if (name.endsWith('.jsonl.zst')) return name.slice(0, -'.jsonl.zst'.length)
|
|
156
|
+
if (name.endsWith('.jsonl')) return name.slice(0, -'.jsonl'.length)
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every Codex rollout under one sessions root, plain and compressed, in directory order. Head-only: nothing is opened. */
|
|
161
|
+
export async function listCodexRolloutFiles(sessionsRoot: string): Promise<Array<{ file: string; compressed: boolean }>> {
|
|
162
|
+
const files: Array<{ file: string; compressed: boolean }> = []
|
|
163
|
+
for (const year of await dirents(sessionsRoot)) {
|
|
164
|
+
const yearDir = join(sessionsRoot, year)
|
|
165
|
+
const yearSt = await fileStat(yearDir)
|
|
166
|
+
if (!yearSt || yearSt.isFile) continue
|
|
167
|
+
for (const month of await dirents(yearDir)) {
|
|
168
|
+
const monthDir = join(yearDir, month)
|
|
169
|
+
const monthSt = await fileStat(monthDir)
|
|
170
|
+
if (!monthSt || monthSt.isFile) continue
|
|
171
|
+
for (const day of await dirents(monthDir)) {
|
|
172
|
+
const dayDir = join(monthDir, day)
|
|
173
|
+
const daySt = await fileStat(dayDir)
|
|
174
|
+
if (!daySt || daySt.isFile) continue
|
|
175
|
+
for (const name of await dirents(dayDir)) {
|
|
176
|
+
if (name.endsWith('.jsonl.zst')) files.push({ file: join(dayDir, name), compressed: true })
|
|
177
|
+
else if (name.endsWith('.jsonl')) files.push({ file: join(dayDir, name), compressed: false })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return files
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
class NoDecoderError extends Error {}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The first `maxBytes` of a file's (decompressed) content, one line at a time, until `onLine` says stop. A line cut by the
|
|
189
|
+
* limit is handed over as it stands and will usually not parse, which is the reader's cue to skip it.
|
|
190
|
+
*/
|
|
191
|
+
async function readHeadLines(file: string, compressed: boolean, maxBytes: number, zstd: ZstdFactory, onLine: (line: string) => boolean): Promise<void> {
|
|
192
|
+
const decoder = new StringDecoder('utf8')
|
|
193
|
+
let carry = ''
|
|
194
|
+
let scanFrom = 0
|
|
195
|
+
let skipping = false
|
|
196
|
+
let total = 0
|
|
197
|
+
let stopped = false
|
|
198
|
+
const feed = (chunk: Buffer): boolean => {
|
|
199
|
+
total += chunk.length
|
|
200
|
+
carry += decoder.write(chunk)
|
|
201
|
+
for (;;) {
|
|
202
|
+
// Resume the newline search where the last one stopped: one enormous line is not rescanned from 0 on every chunk.
|
|
203
|
+
const at = carry.indexOf('\n', scanFrom)
|
|
204
|
+
if (at < 0) {
|
|
205
|
+
scanFrom = carry.length
|
|
206
|
+
if (carry.length > MAX_LINE_CHARS) { skipping = true; carry = ''; scanFrom = 0 }
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
const line = carry.slice(0, at)
|
|
210
|
+
carry = carry.slice(at + 1)
|
|
211
|
+
scanFrom = 0
|
|
212
|
+
if (skipping) { skipping = false; continue }
|
|
213
|
+
if (line && line.length <= MAX_LINE_CHARS && onLine(line)) return true
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!compressed) {
|
|
217
|
+
const handle = await open(file, 'r')
|
|
218
|
+
try {
|
|
219
|
+
const buffer = Buffer.alloc(FIND_CHUNK_BYTES)
|
|
220
|
+
while (total < maxBytes && !stopped) {
|
|
221
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(FIND_CHUNK_BYTES, maxBytes - total), total)
|
|
222
|
+
if (bytesRead <= 0) break
|
|
223
|
+
stopped = feed(Buffer.from(buffer.subarray(0, bytesRead)))
|
|
224
|
+
}
|
|
225
|
+
} finally { await handle.close() }
|
|
226
|
+
} else {
|
|
227
|
+
const inflate = zstd()
|
|
228
|
+
if (!inflate) throw new NoDecoderError('zstd unavailable')
|
|
229
|
+
const raw = createReadStream(file, { highWaterMark: FIND_CHUNK_BYTES })
|
|
230
|
+
raw.on('error', error => inflate.destroy(error))
|
|
231
|
+
raw.pipe(inflate)
|
|
232
|
+
try {
|
|
233
|
+
for await (const chunk of inflate) {
|
|
234
|
+
stopped = feed(chunk as Buffer)
|
|
235
|
+
if (stopped || total >= maxBytes) break
|
|
236
|
+
}
|
|
237
|
+
} finally { raw.destroy(); inflate.destroy() }
|
|
238
|
+
}
|
|
239
|
+
if (!stopped && !skipping && carry.trim()) onLine(carry + decoder.end())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface HeadInfo {
|
|
243
|
+
/** A typed first prompt was found (or this is a subagent, which is left out whatever it says). */
|
|
244
|
+
typed: string
|
|
245
|
+
listFirstLine: string
|
|
246
|
+
subagent: boolean
|
|
247
|
+
metaId: string
|
|
248
|
+
cwd: string
|
|
249
|
+
created: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A fresh flat copy. A slice of a big string keeps the whole parent alive in V8, and the memo outlives the parse (418 MB for 400 files). */
|
|
253
|
+
const flat = (text: string): string => Buffer.from(text, 'utf8').toString('utf8')
|
|
254
|
+
|
|
255
|
+
const emptyHead = (): HeadInfo => ({ typed: '', listFirstLine: '', subagent: false, metaId: '', cwd: '', created: '' })
|
|
256
|
+
|
|
257
|
+
function parseClaudeHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
|
|
258
|
+
const head = emptyHead()
|
|
259
|
+
return { head, onLine: line => {
|
|
260
|
+
if (!line.includes('"user"')) return false
|
|
261
|
+
const obj = parseJsonLine(line)
|
|
262
|
+
if (!obj || obj.type !== 'user' || obj.toolUseResult || obj.isSidechain === true) return false
|
|
263
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
264
|
+
const body = message ? payloadText(message) : null
|
|
265
|
+
if (!body) return false
|
|
266
|
+
// Not from a wrapper body: the list titles a session from whatever the first record says, which can be harness text.
|
|
267
|
+
if (isWrapperPrompt(body)) return false
|
|
268
|
+
const typed = unwrapPrompt(body)
|
|
269
|
+
if (typed.length < 3) return false
|
|
270
|
+
head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
|
|
271
|
+
head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
|
|
272
|
+
return true
|
|
273
|
+
} }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseCodexHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
|
|
277
|
+
const head = emptyHead()
|
|
278
|
+
return { head, onLine: line => {
|
|
279
|
+
if (!line.includes('"session_meta"') && !line.includes('"response_item"')) return false
|
|
280
|
+
const obj = parseJsonLine(line)
|
|
281
|
+
if (!obj || !obj.payload || typeof obj.payload !== 'object') return false
|
|
282
|
+
const payload = obj.payload as Record<string, unknown>
|
|
283
|
+
if (obj.type === 'session_meta') {
|
|
284
|
+
head.metaId = flat(String(payload.id ?? payload.session_id ?? head.metaId))
|
|
285
|
+
head.cwd = flat(String(payload.cwd ?? head.cwd))
|
|
286
|
+
head.subagent = payload.thread_source === 'subagent'
|
|
287
|
+
head.created = flat(String(payload.timestamp ?? obj.timestamp ?? head.created).trim())
|
|
288
|
+
return head.subagent
|
|
289
|
+
}
|
|
290
|
+
if (obj.type !== 'response_item' || payload.type !== 'message' || payload.role !== 'user') return false
|
|
291
|
+
const body = codexUserText(payload)
|
|
292
|
+
if (!body) return false
|
|
293
|
+
if (isWrapperPrompt(body)) return false
|
|
294
|
+
const typed = unwrapPrompt(body)
|
|
295
|
+
if (typed.length < 3) return false
|
|
296
|
+
head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
|
|
297
|
+
head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
|
|
298
|
+
return true
|
|
299
|
+
} }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseCursorHead(): { head: HeadInfo; onLine: (line: string) => boolean } {
|
|
303
|
+
const head = emptyHead()
|
|
304
|
+
return { head, onLine: line => {
|
|
305
|
+
if (!line.includes('"user"')) return false
|
|
306
|
+
const obj = parseJsonLine(line)
|
|
307
|
+
if (!obj || obj.role !== 'user') return false
|
|
308
|
+
const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
|
|
309
|
+
const body = message ? payloadText(message) : null
|
|
310
|
+
if (!body) return false
|
|
311
|
+
if (isWrapperPrompt(body) && !body.includes('<user_query>')) return false
|
|
312
|
+
const typed = unwrapPrompt(body)
|
|
313
|
+
if (typed.length < 3) return false
|
|
314
|
+
head.listFirstLine = flat(firstLineTitle(body.slice(0, TITLE_SCAN_CHARS)))
|
|
315
|
+
head.typed = flat(typed.slice(0, FIND_PROMPT_KEEP_CHARS))
|
|
316
|
+
return true
|
|
317
|
+
} }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface FileRef {
|
|
321
|
+
provider: AgentProvider
|
|
322
|
+
file: string
|
|
323
|
+
/** Claude: the uuid. Codex: the file's uuid. Cursor: the session directory. */
|
|
324
|
+
id: string
|
|
325
|
+
/** Claude: the project folder. Cursor: the project folder. Codex: unused. */
|
|
326
|
+
folder: string
|
|
327
|
+
mtimeMs: number
|
|
328
|
+
birthtimeMs: number
|
|
329
|
+
size: number
|
|
330
|
+
compressed: boolean
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface FileInfo {
|
|
334
|
+
mtimeMs: number
|
|
335
|
+
size: number
|
|
336
|
+
head: HeadInfo
|
|
337
|
+
/** Claude only, read the first time it is needed (a Desktop alias title wins over it). `null` is "none found". */
|
|
338
|
+
customTitle?: string | null
|
|
339
|
+
clockMs?: number
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface DesktopFlags { mtimeMs: number; size: number; scheduled: boolean; archived: boolean }
|
|
343
|
+
|
|
344
|
+
/** Both flags are keys of the Desktop record: archived near its top, scheduledTaskId anywhere in its first megabytes. */
|
|
345
|
+
async function readDesktopFlags(file: string, size: number): Promise<{ scheduled: boolean; archived: boolean } | null> {
|
|
346
|
+
if (size > DESKTOP_RECORD_MAX_BYTES) return null
|
|
347
|
+
try {
|
|
348
|
+
const handle = await open(file, 'r')
|
|
349
|
+
try {
|
|
350
|
+
const buffer = Buffer.alloc(size)
|
|
351
|
+
const { bytesRead } = await handle.read(buffer, 0, size, 0)
|
|
352
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8')
|
|
353
|
+
return {
|
|
354
|
+
scheduled: /"scheduledTaskId"\s*:\s*"[^"]+"/.test(text),
|
|
355
|
+
archived: /"isArchived"\s*:\s*true/.test(text.slice(0, 64 * 1024)),
|
|
356
|
+
}
|
|
357
|
+
} finally { await handle.close() }
|
|
358
|
+
} catch { return null }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface FindPoolOptions {
|
|
362
|
+
roots?: AgentSessionRoots
|
|
363
|
+
/** How many sessions the pool should hold at most: 254 for the first request of a search, 508 for both. */
|
|
364
|
+
limit: number
|
|
365
|
+
signal?: AbortSignal
|
|
366
|
+
/** Lower-case ids (Claude session ids, Codex thread ids) that ran on a key. They never leave the Mac. */
|
|
367
|
+
keyedIds?: ReadonlySet<string>
|
|
368
|
+
/** Test seam: where the zstd decoder comes from. Defaults to node:zlib at the call. */
|
|
369
|
+
zstd?: ZstdFactory
|
|
370
|
+
maxOpen?: number
|
|
371
|
+
/** Test seam: how far into a file to look for the first typed prompt. Defaults to FIND_HEAD_BYTES. */
|
|
372
|
+
headBytes?: number
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const normTitle = (title: string) => title.toLowerCase().replace(/\s+/g, ' ').trim()
|
|
376
|
+
|
|
377
|
+
const execFileAsync = promisify(execFile)
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Cursor's sidebar names and which of its sessions are archived, read-only, from composerHeaders. loadCursorComposerNames
|
|
381
|
+
* (the list's reader) keeps names only; an archived session still has one, and 21 of 828 headers here are archived.
|
|
382
|
+
*/
|
|
383
|
+
async function loadComposerHeaders(db: string): Promise<{ names: Map<string, string>; archived: Set<string> }> {
|
|
384
|
+
const names = new Map<string, string>(), archived = new Set<string>()
|
|
385
|
+
try {
|
|
386
|
+
const { stdout } = await execFileAsync('/usr/bin/sqlite3', ['-readonly', '-json', db,
|
|
387
|
+
"SELECT composerId AS id, json_extract(value, '$.name') AS name, json_extract(value, '$.isArchived') AS archived FROM composerHeaders"],
|
|
388
|
+
{ timeout: 4000, maxBuffer: 16 * 1024 * 1024 })
|
|
389
|
+
const rows = JSON.parse(stdout || '[]') as Array<{ id?: string; name?: string | null; archived?: number | boolean | null }>
|
|
390
|
+
for (const row of rows) {
|
|
391
|
+
const id = String(row.id ?? '').trim()
|
|
392
|
+
if (!id) continue
|
|
393
|
+
const name = String(row.name ?? '').trim()
|
|
394
|
+
if (name) names.set(id, flat(name.slice(0, 120)))
|
|
395
|
+
if (row.archived === 1 || row.archived === true) archived.add(id)
|
|
396
|
+
}
|
|
397
|
+
} catch { /* unreadable: no names and nothing marked archived, as the list's reader does */ }
|
|
398
|
+
return { names, archived }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The newest conversation record's time from the transcript's own tail, widening x4 only while the tail has none. Not the list's memo. */
|
|
402
|
+
async function lastRecordMs(provider: AgentProvider, file: string, size: number): Promise<number | null> {
|
|
403
|
+
try {
|
|
404
|
+
const handle = await open(file, 'r')
|
|
405
|
+
try {
|
|
406
|
+
for (let window = ACTIVITY_TAIL_BYTES; ; window *= 4) {
|
|
407
|
+
const length = Math.min(size, window)
|
|
408
|
+
const start = size - length
|
|
409
|
+
const buffer = Buffer.alloc(length)
|
|
410
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start)
|
|
411
|
+
const at = activityFromTail(provider, buffer.subarray(0, bytesRead).toString('utf8'), start > 0).lastActivityAt
|
|
412
|
+
if (at) { const ms = Date.parse(at); return Number.isFinite(ms) ? ms : null }
|
|
413
|
+
if (start === 0 || window >= ACTIVITY_TAIL_MAX_BYTES) return null
|
|
414
|
+
}
|
|
415
|
+
} finally { await handle.close() }
|
|
416
|
+
} catch { return null }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export class SessionFindCollector {
|
|
420
|
+
private memo = new Map<string, FileInfo>()
|
|
421
|
+
private flags = new Map<string, DesktopFlags>()
|
|
422
|
+
private cursorNames: { stamp: string; names: Map<string, string>; archived: Set<string> } | null = null
|
|
423
|
+
private flights = new Map<string, { promise: Promise<FindPool>; waiters: number; controller: AbortController }>()
|
|
424
|
+
|
|
425
|
+
constructor(private readonly memoMaxChars: number = FIND_MEMO_MAX_CHARS) {}
|
|
426
|
+
|
|
427
|
+
/** Memo entries held. A test seam: the memo must never outgrow the files it mirrors. */
|
|
428
|
+
memoSize(): number { return this.memo.size }
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The pool. Concurrent calls for the same roots and limit share one walk; the walk stops when its LAST caller has gone
|
|
432
|
+
* (a request that closed), and what it had read stays in the memo, so the next try starts further along.
|
|
433
|
+
*/
|
|
434
|
+
collect(options: FindPoolOptions): Promise<FindPool> {
|
|
435
|
+
const roots = options.roots ?? agentSessionRoots()
|
|
436
|
+
const key = `${options.limit}\0${JSON.stringify(roots)}\0${options.keyedIds ? [...options.keyedIds].sort().join(',') : ''}`
|
|
437
|
+
let flight = this.flights.get(key)
|
|
438
|
+
// A flight whose last caller left is stopping; a new caller starts a fresh walk (the memo keeps what the old one read).
|
|
439
|
+
if (!flight || flight.controller.signal.aborted) {
|
|
440
|
+
const controller = new AbortController()
|
|
441
|
+
const created = { promise: undefined as unknown as Promise<FindPool>, waiters: 0, controller }
|
|
442
|
+
created.promise = this.walk({ ...options, roots }, controller.signal).finally(() => { if (this.flights.get(key) === created) this.flights.delete(key) })
|
|
443
|
+
// A walk nobody waits for any more rejects; that is not an unhandled error.
|
|
444
|
+
created.promise.catch(() => undefined)
|
|
445
|
+
this.flights.set(key, created)
|
|
446
|
+
flight = created
|
|
447
|
+
}
|
|
448
|
+
const shared = flight
|
|
449
|
+
shared.waiters += 1
|
|
450
|
+
return new Promise<FindPool>((resolve, reject) => {
|
|
451
|
+
let left = false
|
|
452
|
+
const leave = () => {
|
|
453
|
+
if (left) return
|
|
454
|
+
left = true
|
|
455
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
456
|
+
shared.waiters -= 1
|
|
457
|
+
if (shared.waiters <= 0) shared.controller.abort()
|
|
458
|
+
}
|
|
459
|
+
const onAbort = () => { leave(); reject(abortError()) }
|
|
460
|
+
if (options.signal?.aborted) { onAbort(); return }
|
|
461
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
462
|
+
shared.promise.then(pool => { leave(); resolve(pool) }, error => { leave(); reject(error) })
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async walk(options: FindPoolOptions & { roots: AgentSessionRoots }, signal: AbortSignal): Promise<FindPool> {
|
|
467
|
+
const started = performance.now()
|
|
468
|
+
const { roots } = options
|
|
469
|
+
const zstd = options.zstd ?? zstdDecompressor
|
|
470
|
+
const keyed = options.keyedIds ?? new Set<string>()
|
|
471
|
+
const maxOpen = options.maxOpen ?? FIND_MAX_OPEN
|
|
472
|
+
const throwIfStopped = () => { if (signal.aborted) throw abortError() }
|
|
473
|
+
|
|
474
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
475
|
+
throwIfStopped()
|
|
476
|
+
const desktopByCli = new Map<string, { title: string; file: string; mtimeMs: number }>()
|
|
477
|
+
for (const [id, head] of aliases) {
|
|
478
|
+
if (!head) continue
|
|
479
|
+
const cli = head.cliSessionId && CLAUDE_UUID_JSONL.test(head.cliSessionId + '.jsonl') ? head.cliSessionId : id
|
|
480
|
+
const previous = desktopByCli.get(cli)
|
|
481
|
+
if (!previous || head.mtimeMs >= previous.mtimeMs) desktopByCli.set(cli, { title: head.title, file: head.file, mtimeMs: head.mtimeMs })
|
|
482
|
+
}
|
|
483
|
+
const codexNames = await loadCodexThreadNames(roots.codexSessions)
|
|
484
|
+
const composers = await this.composerNames(roots.cursorComposerDb)
|
|
485
|
+
throwIfStopped()
|
|
486
|
+
|
|
487
|
+
const refs = await this.enumerate(roots)
|
|
488
|
+
throwIfStopped()
|
|
489
|
+
this.prune(refs)
|
|
490
|
+
refs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
491
|
+
|
|
492
|
+
const assembled = new Map<string, FindCandidate>()
|
|
493
|
+
let skippedNoDecoder = 0, skippedUnreadable = 0, opened = 0
|
|
494
|
+
const noDecoderFiles = new Set<string>(), unreadableFiles = new Set<string>()
|
|
495
|
+
let pool: FindCandidate[] = []
|
|
496
|
+
let next = 0
|
|
497
|
+
while (next < refs.length) {
|
|
498
|
+
throwIfStopped()
|
|
499
|
+
// Everything not yet opened was written no later than refs[next], and a transcript's last record is never later than its
|
|
500
|
+
// file's write time. So once the pool is full and its oldest entry is newer than that, no unopened file can displace it.
|
|
501
|
+
if (pool.length >= options.limit && pool[options.limit - 1]!.clockMs >= refs[next]!.mtimeMs) break
|
|
502
|
+
if (opened >= maxOpen) break
|
|
503
|
+
const batch = refs.slice(next, next + BATCH)
|
|
504
|
+
next += batch.length
|
|
505
|
+
const results = await mapWithConcurrency(batch, ACTIVITY_READ_CONCURRENCY, async ref => {
|
|
506
|
+
if (signal.aborted) return null
|
|
507
|
+
return this.inspect(ref, { desktopByCli, codexNames, cursorNames: composers.names, cursorArchived: composers.archived, keyed, zstd, headBytes: options.headBytes ?? FIND_HEAD_BYTES })
|
|
508
|
+
})
|
|
509
|
+
for (let i = 0; i < batch.length; i++) {
|
|
510
|
+
const outcome = results[i]
|
|
511
|
+
const ref = batch[i]!
|
|
512
|
+
if (!outcome) continue
|
|
513
|
+
if (outcome.read) opened += 1
|
|
514
|
+
if (outcome.skip === 'nodecoder') noDecoderFiles.add(ref.file)
|
|
515
|
+
if (outcome.skip === 'unreadable') unreadableFiles.add(ref.file)
|
|
516
|
+
if (outcome.candidate) assembled.set(ref.file, outcome.candidate)
|
|
517
|
+
}
|
|
518
|
+
pool = this.settle([...assembled.values()])
|
|
519
|
+
}
|
|
520
|
+
throwIfStopped()
|
|
521
|
+
this.trim()
|
|
522
|
+
skippedNoDecoder = noDecoderFiles.size
|
|
523
|
+
skippedUnreadable = unreadableFiles.size
|
|
524
|
+
return { candidates: pool.slice(0, options.limit), skippedNoDecoder, skippedUnreadable, opened, considered: refs.length, ms: Math.round(performance.now() - started) }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The pool-wide rules (a title seen 5 or more times is a machine), one identity per session, newest record first. */
|
|
528
|
+
private settle(all: FindCandidate[]): FindCandidate[] {
|
|
529
|
+
const seen = new Map<string, number>()
|
|
530
|
+
for (const candidate of all) {
|
|
531
|
+
const t = normTitle(candidate.title)
|
|
532
|
+
seen.set(t, (seen.get(t) ?? 0) + 1)
|
|
533
|
+
}
|
|
534
|
+
const byKey = new Map<string, FindCandidate>()
|
|
535
|
+
for (const candidate of all) {
|
|
536
|
+
if ((seen.get(normTitle(candidate.title)) ?? 0) >= FIND_REPEAT_TITLE_LIMIT) continue
|
|
537
|
+
const existing = byKey.get(candidate.key)
|
|
538
|
+
if (!existing || candidate.clockMs > existing.clockMs) byKey.set(candidate.key, candidate)
|
|
539
|
+
}
|
|
540
|
+
return [...byKey.values()].sort((a, b) => b.clockMs - a.clockMs || a.key.localeCompare(b.key))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async composerNames(db: string): Promise<{ names: Map<string, string>; archived: Set<string> }> {
|
|
544
|
+
const st = db ? await fileStat(db) : null
|
|
545
|
+
if (!st?.isFile) return { names: new Map(), archived: new Set() }
|
|
546
|
+
const stamp = `${db}:${st.mtimeMs}:${st.size}`
|
|
547
|
+
if (this.cursorNames?.stamp === stamp) return this.cursorNames
|
|
548
|
+
const loaded = { stamp, ...(await loadComposerHeaders(db)) }
|
|
549
|
+
this.cursorNames = loaded
|
|
550
|
+
return loaded
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Stat every transcript. Claude and Cursor from their own stores; Codex from the SERVER home only (never a keyed home). */
|
|
554
|
+
private async enumerate(roots: AgentSessionRoots): Promise<FileRef[]> {
|
|
555
|
+
const refs: FileRef[] = []
|
|
556
|
+
for (const folder of await dirents(roots.claudeProjects)) {
|
|
557
|
+
const dir = join(roots.claudeProjects, folder)
|
|
558
|
+
for (const name of await dirents(dir)) {
|
|
559
|
+
if (!CLAUDE_UUID_JSONL.test(name)) continue
|
|
560
|
+
const file = join(dir, name)
|
|
561
|
+
const st = await fileStat(file)
|
|
562
|
+
if (!st?.isFile) continue
|
|
563
|
+
refs.push({ provider: 'claude', file, id: name.slice(0, -'.jsonl'.length), folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed: false })
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const { file, compressed } of await listCodexRolloutFiles(roots.codexSessions)) {
|
|
567
|
+
const st = await fileStat(file)
|
|
568
|
+
if (!st?.isFile) continue
|
|
569
|
+
refs.push({ provider: 'codex', file, id: file.split('/').pop() || file, folder: '', mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed })
|
|
570
|
+
}
|
|
571
|
+
const cursor = new Map<string, FileRef>()
|
|
572
|
+
for (const folder of await dirents(roots.cursorProjects)) {
|
|
573
|
+
if (isSkippedCursorFolder(folder)) continue
|
|
574
|
+
const transcripts = join(roots.cursorProjects, folder, 'agent-transcripts')
|
|
575
|
+
for (const sessionDir of await dirents(transcripts)) {
|
|
576
|
+
if (sessionDir === 'subagents') continue
|
|
577
|
+
const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
|
|
578
|
+
const st = await fileStat(file)
|
|
579
|
+
if (!st?.isFile || st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
|
|
580
|
+
const next: FileRef = { provider: 'cursor', file, id: sessionDir, folder, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, compressed: false }
|
|
581
|
+
const existing = cursor.get(sessionDir)
|
|
582
|
+
if (!existing) { cursor.set(sessionDir, next); continue }
|
|
583
|
+
const pick = preferCursorCopy(
|
|
584
|
+
{ file: next.file, sessionDir, project: workspaceLabel(next.folder), mtimeMs: next.mtimeMs, birthtimeMs: next.birthtimeMs },
|
|
585
|
+
{ file: existing.file, sessionDir, project: workspaceLabel(existing.folder), mtimeMs: existing.mtimeMs, birthtimeMs: existing.birthtimeMs })
|
|
586
|
+
cursor.set(sessionDir, pick.file === next.file ? next : existing)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
refs.push(...cursor.values())
|
|
590
|
+
return refs
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Forget memo entries for files that are gone, and keep the memo inside its bound. */
|
|
594
|
+
private prune(refs: readonly FileRef[]): void {
|
|
595
|
+
const live = new Set(refs.map(ref => ref.file))
|
|
596
|
+
for (const file of this.memo.keys()) if (!live.has(file)) this.memo.delete(file)
|
|
597
|
+
this.trim()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Evict least recently used entries until the memo is inside both bounds: entries and characters held. */
|
|
601
|
+
private trim(): void {
|
|
602
|
+
const chars = (info: FileInfo) => info.head.typed.length + info.head.listFirstLine.length + info.head.metaId.length + info.head.cwd.length + (info.customTitle?.length ?? 0) + 64
|
|
603
|
+
let held = 0
|
|
604
|
+
for (const info of this.memo.values()) held += chars(info)
|
|
605
|
+
while (this.memo.size > FIND_MEMO_MAX || held > this.memoMaxChars) {
|
|
606
|
+
const oldestKey = this.memo.keys().next().value
|
|
607
|
+
if (oldestKey === undefined) break
|
|
608
|
+
held -= chars(this.memo.get(oldestKey)!)
|
|
609
|
+
this.memo.delete(oldestKey)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private async inspect(ref: FileRef, ctx: {
|
|
614
|
+
desktopByCli: Map<string, { title: string; file: string; mtimeMs: number }>
|
|
615
|
+
codexNames: Map<string, string>
|
|
616
|
+
cursorNames: Map<string, string>
|
|
617
|
+
cursorArchived: ReadonlySet<string>
|
|
618
|
+
keyed: ReadonlySet<string>
|
|
619
|
+
zstd: ZstdFactory
|
|
620
|
+
headBytes: number
|
|
621
|
+
}): Promise<{ candidate: FindCandidate | null; read: boolean; skip?: 'nodecoder' | 'unreadable' } | null> {
|
|
622
|
+
let info = this.memo.get(ref.file)
|
|
623
|
+
let read = false
|
|
624
|
+
if (!info || info.mtimeMs !== ref.mtimeMs || info.size !== ref.size) {
|
|
625
|
+
const parser = ref.provider === 'claude' ? parseClaudeHead() : ref.provider === 'codex' ? parseCodexHead() : parseCursorHead()
|
|
626
|
+
try {
|
|
627
|
+
await readHeadLines(ref.file, ref.compressed, ctx.headBytes, ctx.zstd, parser.onLine)
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return { candidate: null, read: false, skip: error instanceof NoDecoderError ? 'nodecoder' : 'unreadable' }
|
|
630
|
+
}
|
|
631
|
+
read = true
|
|
632
|
+
info = { mtimeMs: ref.mtimeMs, size: ref.size, head: parser.head }
|
|
633
|
+
this.memo.delete(ref.file)
|
|
634
|
+
this.memo.set(ref.file, info)
|
|
635
|
+
} else {
|
|
636
|
+
this.memo.delete(ref.file)
|
|
637
|
+
this.memo.set(ref.file, info)
|
|
638
|
+
}
|
|
639
|
+
const { head } = info
|
|
640
|
+
if (head.subagent || !head.typed) return { candidate: null, read }
|
|
641
|
+
|
|
642
|
+
let sessionId: string
|
|
643
|
+
let title: string
|
|
644
|
+
let project: string
|
|
645
|
+
let created = isoFromMtime(ref.birthtimeMs)
|
|
646
|
+
if (ref.provider === 'claude') {
|
|
647
|
+
sessionId = ref.id
|
|
648
|
+
if (ctx.keyed.has(sessionId.toLowerCase())) return { candidate: null, read }
|
|
649
|
+
const desktop = ctx.desktopByCli.get(sessionId)
|
|
650
|
+
if (desktop) {
|
|
651
|
+
// The Desktop record says whether it was a scheduled run or put away. Unreadable counts as yes: it fails toward not sending.
|
|
652
|
+
const flags = await this.desktopFlags(desktop.file)
|
|
653
|
+
if (!flags || flags.scheduled || flags.archived) return { candidate: null, read }
|
|
654
|
+
}
|
|
655
|
+
if (!desktop?.title && info.customTitle === undefined) info.customTitle = await lastCustomTitle(ref.file).then(t => t === null ? null : flat(t), () => null)
|
|
656
|
+
title = desktop?.title || info.customTitle || head.listFirstLine || 'Claude session'
|
|
657
|
+
project = workspaceLabel(ref.folder)
|
|
658
|
+
} else if (ref.provider === 'codex') {
|
|
659
|
+
const name = ref.file.split('/').pop() || ref.file
|
|
660
|
+
const fileId = rolloutFileId(name)
|
|
661
|
+
const metaId = head.metaId.trim().toLowerCase()
|
|
662
|
+
// The filename wins when it disagrees: a cloned thread carries its parent's id in session_meta (listCodexSessions).
|
|
663
|
+
sessionId = fileId && metaId && fileId !== metaId ? fileId : (head.metaId || rolloutBaseName(name) || name)
|
|
664
|
+
if (ctx.keyed.has(sessionId.toLowerCase())) return { candidate: null, read }
|
|
665
|
+
title = ctx.codexNames.get(sessionId) || head.listFirstLine || 'Codex session'
|
|
666
|
+
project = workspaceLabel(head.cwd)
|
|
667
|
+
created = head.created || createdFromCodexFilename(name) || created
|
|
668
|
+
} else {
|
|
669
|
+
sessionId = ref.id
|
|
670
|
+
if (ctx.keyed.has(sessionId.toLowerCase()) || ctx.cursorArchived.has(sessionId)) return { candidate: null, read }
|
|
671
|
+
title = ctx.cursorNames.get(sessionId) || head.listFirstLine || 'Cursor session'
|
|
672
|
+
project = workspaceLabel(ref.folder)
|
|
673
|
+
}
|
|
674
|
+
if (isKeepWarmSessionTitle(title) || isKeepWarmSessionTitle(head.listFirstLine) || isKeepWarmSessionTitle(head.typed)) return { candidate: null, read }
|
|
675
|
+
|
|
676
|
+
if (info.clockMs === undefined) {
|
|
677
|
+
let clock = ref.mtimeMs
|
|
678
|
+
// A compressed rollout cannot be tailed; its write time was preserved from the last write (median 0.21 days after the start, which is the stamp's own offset).
|
|
679
|
+
if (ref.provider !== 'cursor' && !ref.compressed) {
|
|
680
|
+
const at = await lastRecordMs(ref.provider, ref.file, ref.size)
|
|
681
|
+
if (at !== null) clock = Math.min(at, ref.mtimeMs)
|
|
682
|
+
}
|
|
683
|
+
info.clockMs = clock
|
|
684
|
+
}
|
|
685
|
+
return { read, candidate: {
|
|
686
|
+
provider: ref.provider, session_id: sessionId, key: `${ref.provider}:${sessionId.toLowerCase()}`, title: title.slice(0, 120),
|
|
687
|
+
listFirstLine: head.listFirstLine, typed: head.typed, project, clockMs: info.clockMs, mtimeMs: ref.mtimeMs, birthtimeMs: ref.birthtimeMs,
|
|
688
|
+
created, compressed: ref.compressed, file: ref.file,
|
|
689
|
+
} }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private async desktopFlags(file: string): Promise<{ scheduled: boolean; archived: boolean } | null> {
|
|
693
|
+
const st = await fileStat(file)
|
|
694
|
+
if (!st?.isFile) return null
|
|
695
|
+
const known = this.flags.get(file)
|
|
696
|
+
if (known && known.mtimeMs === st.mtimeMs && known.size === st.size) return known
|
|
697
|
+
const read = await readDesktopFlags(file, st.size)
|
|
698
|
+
if (!read) { this.flags.delete(file); return null }
|
|
699
|
+
const flags: DesktopFlags = { mtimeMs: st.mtimeMs, size: st.size, ...read }
|
|
700
|
+
this.flags.set(file, flags)
|
|
701
|
+
return flags
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function abortError(): Error {
|
|
706
|
+
const error = new Error('cancelled')
|
|
707
|
+
error.name = 'AbortError'
|
|
708
|
+
return error
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' }
|
|
712
|
+
|
|
713
|
+
let shared: SessionFindCollector | null = null
|
|
714
|
+
/** One collector per process: it owns the memo that makes the second search cheap. */
|
|
715
|
+
export function sharedSessionFindCollector(): SessionFindCollector { return shared ??= new SessionFindCollector() }
|