@sergeychuvayev/claude-fleet 0.1.0

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/search.js ADDED
@@ -0,0 +1,425 @@
1
+ 'use strict'
2
+ // "Did we discuss that?" across every Claude Code transcript on this machine.
3
+ //
4
+ // Two stages. First a local lexical pass (BM25 over the visible conversation text
5
+ // of each session, nothing leaves the machine) narrows hundreds of transcripts to
6
+ // a handful of candidate sessions with the passages that matched. Then one short,
7
+ // tool-less Claude call reads those excerpts and answers the question in words,
8
+ // saying which sessions are actually about it and what was discussed there.
9
+ //
10
+ // Reading is read-only and cached per file on size+mtime, like fleet.js. The AI
11
+ // call runs with persistSession:false so a search never becomes a transcript
12
+ // that the next search would then find.
13
+ const fs = require('node:fs')
14
+ const path = require('node:path')
15
+ const os = require('node:os')
16
+ const { randomUUID } = require('node:crypto')
17
+
18
+ const CLAUDE_DIR = process.env.CLAUDE_FLEET_DIR || path.join(os.homedir(), '.claude')
19
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects')
20
+ const WINDOW_DAYS = Number(process.env.CLAUDE_FLEET_SEARCH_DAYS) || 60
21
+ const MAX_PASSAGE_CHARS = 4000 // indexed text per message; longer answers are cut, not dropped
22
+ const MAX_HITS = 12 // sessions returned
23
+ const MAX_SNIPPETS = 3 // passages per session
24
+ const SNIPPET_CHARS = 360
25
+ const AI_SESSIONS = 10 // sessions the model reads
26
+ const AI_SNIPPET_CHARS = 700
27
+ const MAX_JOBS = 20
28
+
29
+ // --- tokenising ----------------------------------------------------------------
30
+ // Question framing ("did we ever discuss…") carries no signal about the topic, so
31
+ // those words join the usual stopwords. Kept deliberately small: a rare word that
32
+ // happens to be common English ("call", "flow") is exactly what a search is for.
33
+ const STOP = new Set(('a an and are as at be been but by can could did do does for from had has have he her his how i if in into is it its ' +
34
+ 'me my no not of on or our she so some than that the their them then there these they this to too us was we were what when where which who ' +
35
+ 'will with would you your yours yourself ' +
36
+ 'about after again also already any anything anywhere before between ever everything just like maybe more most much need only other over ' +
37
+ 'own really same still such thing things up very well while why ' +
38
+ 'discuss discussed discussing discussion talk talked talking mention mentioned mentioning say said remember recall session sessions ' +
39
+ 'conversation conversations chat ask asked question topic time earlier previously ago').split(/\s+/))
40
+
41
+ function stem(word) {
42
+ if (word.length <= 4) return word
43
+ if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
44
+ if (word.endsWith('sses')) return word.slice(0, -2)
45
+ if (word.endsWith('ing') && word.length > 6) return word.slice(0, -3)
46
+ if (word.endsWith('ed') && word.length > 5) return word.slice(0, -2)
47
+ if (word.endsWith('es') && word.length > 5) return word.slice(0, -2)
48
+ if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
49
+ return word
50
+ }
51
+ // Identifiers are indexed whole and by part, so "call_flow_engine" matches a
52
+ // question about the "call flow engine" and a question quoting the identifier.
53
+ function tokenize(text) {
54
+ const out = []
55
+ for (const raw of String(text || '').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_.-]*[\p{L}\p{N}]|[\p{L}\p{N}]/gu) || []) {
56
+ const parts = raw.split(/[_.-]+/).filter(Boolean)
57
+ if (parts.length > 1 && raw.length <= 60) out.push(raw)
58
+ for (const part of parts) {
59
+ if (STOP.has(part) || part.length < 2) continue
60
+ out.push(stem(part))
61
+ }
62
+ }
63
+ return out
64
+ }
65
+
66
+ // --- reading transcripts ---------------------------------------------------------
67
+ const fileCache = new Map() // path -> { key, doc }
68
+
69
+ function visibleText(content) {
70
+ if (typeof content === 'string') return content
71
+ if (!Array.isArray(content)) return ''
72
+ return content.filter(b => b && b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n\n')
73
+ }
74
+ // Hook output and injected reminders ride inside user messages; they are context
75
+ // the operator never wrote, and full of memory indexes that would match anything.
76
+ function stripInjected(text) {
77
+ return text
78
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
79
+ .replace(/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g, ' ')
80
+ .trim()
81
+ }
82
+
83
+ function indexFile(file) {
84
+ let st
85
+ try { st = fs.statSync(file) } catch { return null }
86
+ const key = `${st.size}:${st.mtimeMs}`
87
+ const hit = fileCache.get(file)
88
+ if (hit && hit.key === key) return hit.doc
89
+ let text
90
+ try { text = fs.readFileSync(file, 'utf8') } catch { return null }
91
+
92
+ const doc = { file, sessionId: path.basename(file, '.jsonl'), cwd: null, title: null, firstAt: null, lastAt: null, passages: [], postings: new Map(), totalLen: 0 }
93
+ for (const line of text.split('\n')) {
94
+ if (!line || line[0] !== '{') continue
95
+ // Tool results are user-typed lines too, and they are the bulk of a transcript.
96
+ // They always carry tool_use_id, a token no typed message contains.
97
+ const isUser = line.includes('"type":"user"')
98
+ const isAssistant = !isUser && line.includes('"type":"assistant"')
99
+ const isTitle = !isUser && !isAssistant && line.includes('"type":"ai-title"')
100
+ if (!isUser && !isAssistant && !isTitle) continue
101
+ if (isUser && line.includes('"tool_use_id"')) continue
102
+ let d
103
+ try { d = JSON.parse(line) } catch { continue }
104
+ if (d.type === 'ai-title') { if (d.aiTitle) doc.title = d.aiTitle; continue }
105
+ if (d.type !== 'user' && d.type !== 'assistant') continue
106
+ if (d.isSidechain) continue // sub-agent chatter, not the operator's conversation
107
+ if (d.cwd && !doc.cwd) doc.cwd = d.cwd
108
+ const at = d.timestamp ? Date.parse(d.timestamp) || null : null
109
+ if (at) { if (!doc.firstAt) doc.firstAt = at; doc.lastAt = at }
110
+ let body = visibleText(d.message && d.message.content)
111
+ if (d.type === 'user') body = stripInjected(body)
112
+ body = body.trim()
113
+ if (!body) continue
114
+ if (body.length > MAX_PASSAGE_CHARS) body = body.slice(0, MAX_PASSAGE_CHARS)
115
+ const tokens = tokenize(body)
116
+ if (!tokens.length) continue
117
+ const index = doc.passages.length
118
+ doc.passages.push({ role: d.type, text: body, at, len: tokens.length })
119
+ doc.totalLen += tokens.length
120
+ const tf = new Map()
121
+ for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1)
122
+ for (const [term, count] of tf) {
123
+ let list = doc.postings.get(term)
124
+ if (!list) doc.postings.set(term, list = [])
125
+ list.push([index, count])
126
+ }
127
+ }
128
+ fileCache.set(file, { key, doc })
129
+ return doc
130
+ }
131
+
132
+ // The claude-mem plugin's indexer runs headless Claude sessions that summarise
133
+ // every OTHER session as XML observations. They are transcripts, but not
134
+ // conversations the operator had, and they mention everything, so they would
135
+ // crowd out the real answer on every question.
136
+ const isObserver = (dir, cwd) => /claude-mem/.test(dir) || /\/\.claude-mem\//.test(cwd || '')
137
+
138
+ function transcriptFiles(sinceMs) {
139
+ const files = []
140
+ let dirs = []
141
+ try { dirs = fs.readdirSync(PROJECTS_DIR) } catch { return files }
142
+ for (const dir of dirs) {
143
+ if (isObserver(dir)) continue
144
+ const full = path.join(PROJECTS_DIR, dir)
145
+ let names = []
146
+ try { names = fs.readdirSync(full) } catch { continue }
147
+ for (const name of names) {
148
+ if (!name.endsWith('.jsonl')) continue
149
+ const file = path.join(full, name)
150
+ try { if (fs.statSync(file).mtimeMs >= sinceMs) files.push(file) } catch {}
151
+ }
152
+ }
153
+ return files
154
+ }
155
+
156
+ // Every indexed transcript in the window. Unchanged files cost one stat().
157
+ function corpus({ days = WINDOW_DAYS } = {}) {
158
+ const since = Date.now() - days * 86400000
159
+ const docs = []
160
+ for (const file of transcriptFiles(since)) {
161
+ const doc = indexFile(file)
162
+ if (doc && doc.passages.length && !isObserver('', doc.cwd)) docs.push(doc)
163
+ }
164
+ // Forget files that fell out of the window or were deleted.
165
+ const live = new Set(docs.map(d => d.file))
166
+ for (const file of fileCache.keys()) if (!live.has(file)) fileCache.delete(file)
167
+ return docs
168
+ }
169
+
170
+ // Index in the background a few files at a time so the first search is quick
171
+ // without stalling the dashboard's own requests while the server starts.
172
+ async function warm({ days = WINDOW_DAYS, batch = 8 } = {}) {
173
+ const since = Date.now() - days * 86400000
174
+ const files = transcriptFiles(since)
175
+ for (let i = 0; i < files.length; i += batch) {
176
+ for (const file of files.slice(i, i + batch)) indexFile(file)
177
+ await new Promise(resolve => setImmediate(resolve))
178
+ }
179
+ return files.length
180
+ }
181
+
182
+ // --- ranking ----------------------------------------------------------------------
183
+ const K1 = 1.2, B = 0.75
184
+
185
+ function snippet(text, terms) {
186
+ const lower = text.toLowerCase()
187
+ let start = -1
188
+ for (const term of terms) {
189
+ const at = lower.indexOf(term)
190
+ if (at !== -1 && (start === -1 || at < start)) start = at
191
+ }
192
+ if (start === -1) start = 0
193
+ const from = Math.max(0, start - Math.floor(SNIPPET_CHARS / 3))
194
+ let piece = text.slice(from, from + SNIPPET_CHARS)
195
+ if (from > 0) piece = '…' + piece.replace(/^\S*\s/, '')
196
+ if (from + SNIPPET_CHARS < text.length) piece = piece.replace(/\s\S*$/, '') + '…'
197
+ return piece.replace(/\s+/g, ' ').trim()
198
+ }
199
+
200
+ function search(question, { docs = corpus(), limit = MAX_HITS, now = Date.now() } = {}) {
201
+ const terms = [...new Set(tokenize(question))]
202
+ if (!terms.length) return { terms, hits: [], sessions: docs.length, passages: docs.reduce((n, d) => n + d.passages.length, 0) }
203
+ const N = docs.reduce((n, d) => n + d.passages.length, 0)
204
+ const avgdl = N ? docs.reduce((n, d) => n + d.totalLen, 0) / N : 1
205
+ const df = new Map()
206
+ for (const term of terms) {
207
+ let n = 0
208
+ for (const doc of docs) n += doc.postings.get(term)?.length || 0
209
+ df.set(term, n)
210
+ }
211
+ const idf = term => Math.log(1 + (N - df.get(term) + 0.5) / (df.get(term) + 0.5))
212
+ // Raw words from the question (not stemmed) for the snippet and the phrase bonus.
213
+ const rawWords = [...new Set((question.toLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) || []).filter(w => !STOP.has(w)))]
214
+ const phrases = []
215
+ for (let i = 0; i + 1 < rawWords.length; i++) phrases.push(`${rawWords[i]} ${rawWords[i + 1]}`)
216
+
217
+ const hits = []
218
+ for (const doc of docs) {
219
+ const scores = new Map()
220
+ for (const term of terms) {
221
+ const list = doc.postings.get(term)
222
+ if (!list) continue
223
+ const w = idf(term)
224
+ for (const [index, tf] of list) {
225
+ const p = doc.passages[index]
226
+ const s = w * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * p.len / avgdl))
227
+ scores.set(index, (scores.get(index) || 0) + s)
228
+ }
229
+ }
230
+ if (!scores.size) continue
231
+ const ranked = [...scores.entries()].map(([index, score]) => {
232
+ const p = doc.passages[index]
233
+ const lower = p.text.toLowerCase()
234
+ // Words that appear together in the question and together in the passage.
235
+ const phraseHits = phrases.filter(ph => lower.includes(ph)).length
236
+ // A passage that covers more of the question's distinct terms is the better one.
237
+ const covered = terms.filter(t => doc.postings.get(t)?.some(([i]) => i === index)).length
238
+ return { index, score: score * (1 + 0.35 * phraseHits) * (0.6 + 0.4 * covered / terms.length) }
239
+ }).sort((a, b) => b.score - a.score)
240
+ const top = ranked.slice(0, MAX_SNIPPETS)
241
+ let score = top[0].score + 0.3 * top.slice(1).reduce((n, r) => n + r.score, 0)
242
+ // A gentle preference for recent sessions; it only reorders near-ties.
243
+ const ageDays = doc.lastAt ? Math.max(0, (now - doc.lastAt) / 86400000) : 30
244
+ score *= 1 + 0.15 * Math.exp(-ageDays / 14)
245
+ hits.push({
246
+ sessionId: doc.sessionId,
247
+ title: doc.title,
248
+ cwd: doc.cwd,
249
+ project: doc.cwd ? doc.cwd.split('/').filter(Boolean).pop() : null,
250
+ firstAt: doc.firstAt,
251
+ lastAt: doc.lastAt,
252
+ score: Math.round(score * 1000) / 1000,
253
+ matches: scores.size,
254
+ snippets: top.map(r => ({ role: doc.passages[r.index].role, at: doc.passages[r.index].at, text: snippet(doc.passages[r.index].text, rawWords.length ? rawWords : terms) })),
255
+ })
256
+ }
257
+ hits.sort((a, b) => b.score - a.score)
258
+ return { terms, hits: hits.slice(0, limit), sessions: docs.length, passages: N }
259
+ }
260
+
261
+ // --- the answer ---------------------------------------------------------------------
262
+ const SYSTEM_PROMPT = `You help a developer remember what they discussed with Claude Code across many past sessions.
263
+ You are given their question and excerpts from the sessions a keyword search found. Excerpts are the only evidence you have.
264
+ Answer the question directly in 1-3 sentences: yes or no, where, and what the state of that discussion was (decided, fixed, parked, unanswered).
265
+ Then list only the sessions that are genuinely about the question, most relevant first. For each, write one or two sentences of context a reader can act on: what was discussed there and how it ended, in plain words, past tense. Add a short verbatim quote from the excerpt when one shows the match well.
266
+ If nothing matches, say so plainly and return no sessions. Never invent details that are not in the excerpts. Refer to sessions by their id exactly as given.`
267
+
268
+ const OUTPUT_SCHEMA = {
269
+ type: 'object',
270
+ additionalProperties: false,
271
+ properties: {
272
+ answer: { type: 'string', description: 'Direct answer to the question, 1-3 sentences.' },
273
+ matches: {
274
+ type: 'array',
275
+ items: {
276
+ type: 'object',
277
+ additionalProperties: false,
278
+ properties: {
279
+ sessionId: { type: 'string' },
280
+ relevance: { type: 'string', enum: ['high', 'medium', 'low'] },
281
+ context: { type: 'string', description: 'What was discussed there and how it ended. 1-2 sentences.' },
282
+ quote: { type: 'string', description: 'Short verbatim excerpt, or empty.' },
283
+ },
284
+ required: ['sessionId', 'relevance', 'context'],
285
+ },
286
+ },
287
+ },
288
+ required: ['answer', 'matches'],
289
+ }
290
+
291
+ function when(ms) {
292
+ if (!ms) return 'unknown date'
293
+ return new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
294
+ }
295
+ function buildPrompt(question, hits) {
296
+ const lines = [`Question: ${question.trim()}`, '', `Candidate sessions (${Math.min(hits.length, AI_SESSIONS)} of ${hits.length} keyword matches):`]
297
+ for (const hit of hits.slice(0, AI_SESSIONS)) {
298
+ lines.push('', `## Session ${hit.sessionId}`, `Title: ${hit.title || '(untitled)'} · Project: ${hit.project || 'unknown'} · Last active: ${when(hit.lastAt)}`)
299
+ for (const s of hit.snippets) lines.push(`- [${s.role === 'user' ? 'developer' : 'claude'} · ${when(s.at)}] ${s.text.slice(0, AI_SNIPPET_CHARS)}`)
300
+ }
301
+ lines.push('', 'Respond with JSON matching the schema: {"answer": string, "matches": [{"sessionId", "relevance", "context", "quote"}]}.')
302
+ return lines.join('\n')
303
+ }
304
+
305
+ function parseAnswer(value) {
306
+ let data = value
307
+ if (typeof data === 'string') {
308
+ const text = data.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
309
+ try { data = JSON.parse(text) } catch { data = null }
310
+ }
311
+ if (!data || typeof data !== 'object' || typeof data.answer !== 'string') return null
312
+ const matches = Array.isArray(data.matches) ? data.matches.filter(m => m && typeof m.sessionId === 'string').map(m => ({
313
+ sessionId: m.sessionId.trim(),
314
+ relevance: ['high', 'medium', 'low'].includes(m.relevance) ? m.relevance : 'medium',
315
+ context: typeof m.context === 'string' ? m.context.trim().slice(0, 600) : '',
316
+ quote: typeof m.quote === 'string' ? m.quote.trim().slice(0, 400) : '',
317
+ })) : []
318
+ return { answer: data.answer.trim().slice(0, 1500), matches }
319
+ }
320
+
321
+ function fail(message, status = 400) { const error = new Error(message); error.status = status; throw error }
322
+
323
+ class SearchJobs {
324
+ constructor({ queryFactory, model = process.env.CLAUDE_FLEET_SEARCH_MODEL || 'haiku', getCorpus = corpus } = {}) {
325
+ this.queryFactory = queryFactory || (async args => (await import('@anthropic-ai/claude-agent-sdk')).query(args))
326
+ this.defaultModel = model
327
+ this.getCorpus = getCorpus
328
+ this.jobs = new Map()
329
+ this.current = null // { id, controller, query }
330
+ }
331
+ get(id) {
332
+ const job = this.jobs.get(id)
333
+ if (!job) fail('Search not found.', 404)
334
+ return structuredClone(job)
335
+ }
336
+ // Keyword results are ready when this returns; the answer arrives on the job later.
337
+ start(body) {
338
+ const question = typeof body.question === 'string' ? body.question.trim() : ''
339
+ if (!question || question.length > 500) fail('Ask a question of 1–500 characters.')
340
+ const model = body.model === undefined || body.model === null || body.model === '' ? this.defaultModel : body.model
341
+ if (typeof model !== 'string' || !/^[\w.:-]{1,80}$/.test(model)) fail('That model name is not valid.')
342
+ this.stopCurrent('A newer search replaced this one.')
343
+ const started = Date.now()
344
+ const result = search(question, { docs: this.getCorpus() })
345
+ const job = {
346
+ id: randomUUID(), question, model, startedAt: started, status: 'thinking',
347
+ terms: result.terms, hits: result.hits, sessions: result.sessions, passages: result.passages,
348
+ searchMs: Date.now() - started, ai: null, error: null, aiMs: null,
349
+ }
350
+ this.jobs.set(job.id, job)
351
+ while (this.jobs.size > MAX_JOBS) this.jobs.delete(this.jobs.keys().next().value)
352
+ if (!job.hits.length) {
353
+ job.status = 'done'
354
+ job.ai = { answer: 'Nothing in your recent sessions mentions this.', matches: [] }
355
+ return structuredClone(job)
356
+ }
357
+ // The run handle is shared with stopCurrent(), so a stop that lands while the
358
+ // SDK is still starting is still seen by the code that gets the query object.
359
+ const run = { id: job.id, controller: new AbortController(), query: null, stopped: false }
360
+ this.current = run
361
+ run.done = this.answer(job, run).finally(() => { if (this.current === run) this.current = null })
362
+ return structuredClone(job)
363
+ }
364
+ async answer(job, run) {
365
+ const started = Date.now()
366
+ const controller = run.controller
367
+ try {
368
+ const options = {
369
+ cwd: os.tmpdir(), tools: [], settingSources: [], persistSession: false, maxTurns: 1,
370
+ systemPrompt: SYSTEM_PROMPT, model: job.model, abortController: controller,
371
+ outputFormat: { type: 'json_schema', schema: OUTPUT_SCHEMA },
372
+ // Reading excerpts and reporting what they say needs no deliberation, and
373
+ // adaptive thinking dominated the wait: measured 33s/2800 output tokens with
374
+ // it against 9s/578 for the same answer with it off.
375
+ thinking: { type: 'disabled' },
376
+ canUseTool: () => Promise.resolve({ behavior: 'deny', message: 'Search answers from excerpts only.' }),
377
+ }
378
+ if (process.env.CLAUDE_FLEET_EXECUTABLE && process.env.CLAUDE_FLEET_EXECUTABLE !== 'bundled') options.pathToClaudeCodeExecutable = process.env.CLAUDE_FLEET_EXECUTABLE
379
+ const query = await this.queryFactory({ prompt: buildPrompt(job.question, job.hits), options })
380
+ run.query = query
381
+ // A stop can land while the runtime is starting, before there is anything to close.
382
+ if (run.stopped || controller.signal.aborted) { try { query.close?.() } catch {} ; return }
383
+ let parsed = null, text = '', failure = null
384
+ for await (const event of query) {
385
+ if (run.stopped || controller.signal.aborted) break
386
+ if (event.type === 'assistant') text = visibleText(event.message?.content) || text
387
+ if (event.type === 'result') {
388
+ if (event.is_error) failure = (event.errors || []).join('\n') || event.result || 'Claude could not answer.'
389
+ parsed = parseAnswer(event.structured_output) || parseAnswer(event.result) || parseAnswer(text)
390
+ }
391
+ }
392
+ try { query.close?.() } catch {}
393
+ if (run.stopped || controller.signal.aborted) return
394
+ if (!parsed) throw new Error(failure || 'Claude returned no usable answer.')
395
+ // Only sessions the keyword pass actually surfaced can be cited.
396
+ const known = new Set(job.hits.map(h => h.sessionId))
397
+ parsed.matches = parsed.matches.filter(m => known.has(m.sessionId))
398
+ job.ai = parsed
399
+ job.status = 'done'
400
+ } catch (error) {
401
+ if (run.stopped || controller.signal.aborted) return
402
+ job.status = 'error'
403
+ job.error = String(error.message || error).slice(0, 2000)
404
+ } finally {
405
+ job.aiMs = Date.now() - started
406
+ }
407
+ }
408
+ stopCurrent(reason = 'Search stopped.') {
409
+ const run = this.current
410
+ if (!run || run.stopped) return
411
+ run.stopped = true
412
+ const job = this.jobs.get(run.id)
413
+ if (job && job.status === 'thinking') { job.status = 'stopped'; job.error = reason }
414
+ run.controller.abort()
415
+ try { run.query?.close?.() } catch {}
416
+ this.current = null
417
+ return run
418
+ }
419
+ async close() {
420
+ const run = this.stopCurrent('Fleet is shutting down.')
421
+ try { await run?.done } catch {}
422
+ }
423
+ }
424
+
425
+ module.exports = { SearchJobs, search, corpus, warm, indexFile, tokenize, stem, stripInjected, buildPrompt, parseAnswer, isObserver, SYSTEM_PROMPT, AI_SESSIONS, WINDOW_DAYS }