@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/LICENSE +21 -0
- package/README.md +377 -0
- package/archive.js +96 -0
- package/bin/claude-fleet.js +102 -0
- package/build/make-app.sh +110 -0
- package/catalog.js +117 -0
- package/fleet.js +450 -0
- package/managed.js +456 -0
- package/package.json +62 -0
- package/paths.js +64 -0
- package/permissions.js +73 -0
- package/public/app.js +369 -0
- package/public/ask.js +119 -0
- package/public/blocks.js +180 -0
- package/public/control.js +426 -0
- package/public/icons/fleet-192.png +0 -0
- package/public/icons/fleet-512.png +0 -0
- package/public/index.html +48 -0
- package/public/styles.css +454 -0
- package/public/vendor/libs.js +75 -0
- package/search.js +425 -0
- package/server.js +255 -0
- package/theme.js +89 -0
- package/update.js +183 -0
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 }
|