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