Skip to content

Commit fac2a7e

Browse files
committed
feat(search): add full-text search across session conversation content
Adds SQLite FTS5-powered full-text search (inspired by arjunkmrm/recall) that indexes actual conversation messages from Claude Code sessions. Features BM25 ranking with recency bias, Porter stemming, incremental indexing, and highlighted search snippets in the session manager UI. https://claude.ai/code/session_01M5UqQYVXHPdHHz1oPH4aqT
1 parent 2896684 commit fac2a7e

File tree

8 files changed

+656
-93
lines changed

8 files changed

+656
-93
lines changed

app/pages/ai/sessions.vue

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ const { data, refresh, status } = useFetch('/api/ai/sessions', {
2323
const list_sessions = computed(() => data.value?.sessions ?? [])
2424
const list_projects = computed(() => data.value?.projects ?? [])
2525
26+
// Full-text search (FTS) — triggers when query >= 3 chars
27+
const is_content_search = computed(() => (query_debounced.value?.length ?? 0) >= 3)
28+
const search_params = computed(() => ({
29+
q: query_debounced.value || undefined,
30+
project: filter_project.value || undefined,
31+
limit: 50
32+
}))
33+
34+
const { data: data_search, status: status_search } = useFetch('/api/ai/sessions/search', {
35+
query: search_params,
36+
watch: [search_params],
37+
default: () => ({ results: [] as Array<{ session_id: string, project: string, title: string, cwd: string, time_modified: string, snippet: string, score: number }>, query: '' })
38+
})
39+
40+
const list_search_results = computed(() => data_search.value?.results ?? [])
41+
2642
// Archive data
2743
const { data: list_archive, refresh: refreshArchive } = useFetch('/api/ai/sessions/archive', {
2844
default: () => [] as Array<{ id: string; title: string; project: string; deleted_at: string }>
@@ -312,10 +328,48 @@ async function handleEmptyTrash(): Promise<void> {
312328
</div>
313329

314330
<!-- Loading state -->
315-
<div v-if="status === 'pending'" class="px-4 space-y-2">
331+
<div v-if="status === 'pending' && !is_content_search" class="px-4 space-y-2">
316332
<div v-for="i in 6" :key="i" class="h-12 rounded-lg bg-elevated/30 animate-pulse" />
317333
</div>
318334

335+
<!-- FTS content search results -->
336+
<div v-else-if="is_content_search" class="px-4 pb-4">
337+
<div v-if="status_search === 'pending'" class="space-y-2">
338+
<div v-for="i in 4" :key="i" class="h-16 rounded-lg bg-elevated/30 animate-pulse" />
339+
</div>
340+
<div v-else-if="list_search_results.length > 0" class="space-y-1">
341+
<p class="text-[10px] text-dimmed/50 px-3 py-1">
342+
{{ list_search_results.length }} content match{{ list_search_results.length === 1 ? '' : 'es' }}
343+
</p>
344+
<div
345+
v-for="result in list_search_results"
346+
:key="result.session_id"
347+
class="w-full flex flex-col gap-1 px-3 py-2.5 rounded-lg text-left text-sm hover:bg-elevated/50 transition-colors cursor-pointer group"
348+
@click="handleResumeSession({ id: result.session_id, cwd: result.cwd })"
349+
>
350+
<div class="flex items-center gap-3 min-w-0">
351+
<UIcon name="i-lucide-search" class="size-3.5 text-dimmed shrink-0 group-hover:text-primary transition-colors" />
352+
<span class="flex-1 truncate">{{ result.title || 'Untitled session' }}</span>
353+
<UBadge color="neutral" variant="subtle" size="xs" class="shrink-0">
354+
{{ result.project }}
355+
</UBadge>
356+
<span class="text-[10px] text-dimmed/50 shrink-0">{{ formatTimeAgo(result.time_modified) }}</span>
357+
</div>
358+
<!-- eslint-disable-next-line vue/no-v-html -->
359+
<p class="text-xs text-dimmed/70 pl-6.5 line-clamp-2 [&>mark]:bg-primary/20 [&>mark]:text-primary [&>mark]:rounded-sm [&>mark]:px-0.5" v-html="result.snippet" />
360+
</div>
361+
</div>
362+
<div v-else class="flex flex-col items-center justify-center py-16 text-center">
363+
<UIcon name="i-lucide-search-x" class="size-10 text-dimmed/30 mb-3" />
364+
<p class="text-sm text-dimmed">
365+
No content matches
366+
</p>
367+
<p class="text-xs text-dimmed/50 mt-1">
368+
Try different keywords or a shorter query
369+
</p>
370+
</div>
371+
</div>
372+
319373
<!-- Session list -->
320374
<div v-else-if="list_sessions.length > 0" class="px-4 pb-4 space-y-1">
321375
<!-- Select all row (when no selection yet) -->

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export default defineNuxtConfig({
4444
anthropicApiKey: ''
4545
},
4646

47+
nitro: {
48+
externals: {
49+
inline: ['better-sqlite3']
50+
}
51+
},
52+
4753
compatibilityDate: '2025-01-01',
4854

4955
eslint: {

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@unovis/vue": "^1.6.2",
2323
"@vueuse/core": "^14.1.0",
2424
"@vueuse/nuxt": "^14.1.0",
25+
"better-sqlite3": "^12.6.2",
2526
"date-fns": "^4.1.0",
2627
"fuse.js": "^7.1.0",
2728
"gray-matter": "^4.0.3",
@@ -32,10 +33,16 @@
3233
},
3334
"devDependencies": {
3435
"@nuxt/eslint": "^1.13.0",
36+
"@types/better-sqlite3": "^7.6.13",
3537
"@types/node": "^25.2.1",
3638
"eslint": "^9.39.2",
3739
"typescript": "^5.9.3",
3840
"vue-tsc": "^3.2.4"
3941
},
42+
"pnpm": {
43+
"onlyBuiltDependencies": [
44+
"better-sqlite3"
45+
]
46+
},
4047
"packageManager": "pnpm@10.28.1"
4148
}

pnpm-lock.yaml

Lines changed: 245 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/ai/sessions/[id].get.ts

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { readFile, readdir } from 'node:fs/promises'
22

3-
interface ConversationMessage {
4-
role: 'user' | 'assistant'
5-
content: string
6-
timestamp: string
7-
}
8-
93
export default defineApiHandler(async (event) => {
104
const id = getRouterParam(event, 'id')
115
if (!id) {
@@ -40,65 +34,7 @@ export default defineApiHandler(async (event) => {
4034
}
4135

4236
const lines = raw.split('\n').filter(l => l.trim())
43-
44-
let cwd = ''
45-
const messages: ConversationMessage[] = []
46-
47-
for (const line of lines) {
48-
let entry: Record<string, unknown>
49-
try {
50-
entry = JSON.parse(line)
51-
}
52-
catch {
53-
continue
54-
}
55-
56-
if (!cwd && typeof entry.cwd === 'string') {
57-
cwd = entry.cwd
58-
}
59-
60-
if (entry.type === 'user') {
61-
if (entry.isMeta) continue
62-
63-
const msg = entry.message as { role?: string; content?: unknown } | undefined
64-
if (!msg || msg.role !== 'user') continue
65-
66-
// Skip tool_result messages (tool outputs, not user text)
67-
if (Array.isArray(msg.content) && msg.content.length > 0 && msg.content[0].type === 'tool_result') {
68-
continue
69-
}
70-
71-
const text = extractTextContent(msg.content)
72-
if (text) {
73-
messages.push({
74-
role: 'user',
75-
content: text,
76-
timestamp: (entry.timestamp as string) || ''
77-
})
78-
}
79-
}
80-
else if (entry.type === 'assistant') {
81-
const msg = entry.message as { role?: string; content?: unknown } | undefined
82-
if (!msg || msg.role !== 'assistant') continue
83-
if (!Array.isArray(msg.content)) continue
84-
85-
const text_parts: string[] = []
86-
for (const block of msg.content as Array<{ type: string; text?: string }>) {
87-
if (block.type === 'text' && block.text) {
88-
text_parts.push(block.text)
89-
}
90-
}
91-
92-
const text = text_parts.join('\n')
93-
if (text) {
94-
messages.push({
95-
role: 'assistant',
96-
content: text,
97-
timestamp: (entry.timestamp as string) || ''
98-
})
99-
}
100-
}
101-
}
37+
const { cwd, messages } = parseSessionMessages(lines)
10238

10339
return { id, cwd, messages }
10440
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default defineApiHandler(async (event) => {
2+
const query = getQuery(event)
3+
const q = ((query.q as string) || '').trim()
4+
const project = (query.project as string) || ''
5+
const limit = Math.min(Number(query.limit) || 20, 100)
6+
7+
if (!q || q.length < 2) {
8+
return { results: [], query: q }
9+
}
10+
11+
ensureIndexFresh()
12+
13+
const results = searchMessages(q, { limit, project: project || undefined })
14+
15+
return { results, query: q }
16+
})

0 commit comments

Comments
 (0)