From adaba17c3c9b3e75944f7e77c5203d3add2091c5 Mon Sep 17 00:00:00 2001 From: Techie5879 Date: Fri, 19 Jun 2026 02:12:14 +0530 Subject: [PATCH 1/6] Fix search index refresh performance --- src/search/dependencies.ts | 4 +- src/search/embedding.ts | 28 +- src/search/extractor.ts | 49 +- src/search/fzf.ts | 9 +- src/search/logging.ts | 16 +- src/search/opencode-api.ts | 24 +- src/search/preview.ts | 16 +- src/search/ranking.ts | 7 +- src/search/search.ts | 461 ++++++++++------- src/search/sidecar.ts | 523 +++++++++++++++----- src/search/source-db.ts | 17 +- src/search/status.ts | 7 +- src/tui.tsx | 14 +- test/integration/search.integration.test.ts | 331 ++++++++++++- 14 files changed, 1099 insertions(+), 407 deletions(-) diff --git a/src/search/dependencies.ts b/src/search/dependencies.ts index a972b4c..948a390 100644 --- a/src/search/dependencies.ts +++ b/src/search/dependencies.ts @@ -82,7 +82,9 @@ export async function checkEmbeddingServer(config: SearchConfig) { const healthUrls = ["/health", "/v1/health"] for (const healthUrl of healthUrls) { try { - const response = await fetch(new URL(healthUrl, config.embedBaseUrl)) + const response = await fetch(new URL(healthUrl, config.embedBaseUrl), { + signal: AbortSignal.timeout(1_500), + }) if (response.ok) return { state: "available" as const } } catch { continue diff --git a/src/search/embedding.ts b/src/search/embedding.ts index a036a31..3b1f6db 100644 --- a/src/search/embedding.ts +++ b/src/search/embedding.ts @@ -8,13 +8,30 @@ type EmbeddingResponse = { model?: string } +const HEALTH_TIMEOUT_MS = 1_500 +const EMBED_TIMEOUT_MS = 30_000 +const EMBED_BATCH_SIZE = 64 +const HEALTH_CACHE_TTL_MS = 10_000 + +const healthCache = new Map() + export class LlamaEmbeddingClient { constructor(private readonly config: SearchConfig) {} async health() { + const cached = healthCache.get(this.config.embedBaseUrl) + if (cached && Date.now() - cached.checkedAt < HEALTH_CACHE_TTL_MS) return cached.healthy + const healthy = await this.checkHealth() + healthCache.set(this.config.embedBaseUrl, { healthy, checkedAt: Date.now() }) + return healthy + } + + private async checkHealth() { for (const endpoint of ["/health", "/v1/health"]) { try { - const response = await fetch(new URL(endpoint, this.config.embedBaseUrl)) + const response = await fetch(new URL(endpoint, this.config.embedBaseUrl), { + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + }) if (response.ok) return true } catch { continue @@ -28,10 +45,16 @@ export class LlamaEmbeddingClient { } async embedDocuments(documents: string[]) { - return this.embed(documents.map((document) => this.config.documentPrefix + document)) + const inputs = documents.map((document) => this.config.documentPrefix + document) + const out: Float32Array[] = [] + for (let index = 0; index < inputs.length; index += EMBED_BATCH_SIZE) { + out.push(...(await this.embed(inputs.slice(index, index + EMBED_BATCH_SIZE)))) + } + return out } private async embed(inputs: string[]) { + if (!inputs.length) return [] const response = await fetch(new URL("/v1/embeddings", this.config.embedBaseUrl), { method: "POST", headers: { "content-type": "application/json" }, @@ -40,6 +63,7 @@ export class LlamaEmbeddingClient { input: inputs, encoding_format: "float", }), + signal: AbortSignal.timeout(EMBED_TIMEOUT_MS), }) if (!response.ok) throw new Error(`Embedding request failed: ${response.status}`) diff --git a/src/search/extractor.ts b/src/search/extractor.ts index 24bee2d..1341629 100644 --- a/src/search/extractor.ts +++ b/src/search/extractor.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto" import type { Message, Part, Session } from "@opencode-ai/sdk/v2" import type { SearchDocument } from "./types" +export const SEARCH_EXTRACTOR_VERSION = "3" + function hash(value: unknown) { return createHash("sha256").update(JSON.stringify(value)).digest("hex") } @@ -21,12 +23,7 @@ function sourceText(part: Part) { case "reasoning": return case "tool": - return joinText([ - part.tool, - part.state.status === "completed" ? part.state.title : undefined, - part.state.status === "completed" ? part.state.output : undefined, - part.state.status === "error" ? part.state.error : undefined, - ]) + return case "file": return joinText([ part.filename, @@ -39,15 +36,16 @@ function sourceText(part: Part) { case "patch": return part.files.join("\n") case "subtask": - return joinText([part.prompt, part.description, part.agent, part.command]) + return case "agent": - return joinText([part.name, part.source?.value]) + return default: return } } export function extractSearchDocuments(session: Session, message: Message, part: Part): SearchDocument[] { + if (message.role !== "user") return [] const text = sourceText(part) if (!text) return [] @@ -61,12 +59,7 @@ export function extractSearchDocuments(session: Session, message: Message, part: sessionTimeUpdated: session.time.updated, messageTimeCreated: message.time.created, } - const indexedText = joinText([ - `Title: ${session.title}`, - `Role: ${message.role}`, - session.path ? `Path: ${session.path}` : session.directory ? `Directory: ${session.directory}` : undefined, - text, - ]) + const indexedText = text return [ { @@ -93,7 +86,31 @@ export function extractSessionDocuments( parts: Part[] }>, ) { - return messages.flatMap((message) => + const title = session.title?.trim() + const titleDocument: SearchDocument[] = title + ? [ + { + docID: `opencode:${session.id}:title:0`, + sessionID: session.id, + chunkIndex: 0, + synthetic: true, + ignored: false, + text: title, + metadata: { + title: session.title, + directory: session.directory, + path: session.path, + projectID: session.projectID, + workspaceID: session.workspaceID, + parentID: session.parentID, + sessionTimeUpdated: session.time.updated, + }, + sourceHash: hash({ session: session.id, title, time: session.time.updated }), + }, + ] + : [] + + return titleDocument.concat(messages.flatMap((message) => message.parts.flatMap((part) => extractSearchDocuments(session, message.info, part)), - ) + )) } diff --git a/src/search/fzf.ts b/src/search/fzf.ts index 1b35cde..fe6980d 100644 --- a/src/search/fzf.ts +++ b/src/search/fzf.ts @@ -11,12 +11,7 @@ export type FzfResult = | { status: "error"; sessionIDs: []; message: string } function candidateLine(candidate: FzfCandidate) { - const parts = [ - candidate.session.title, - candidate.session.path, - candidate.session.directory, - candidate.snippet?.replace(/\s+/g, " "), - ].filter(Boolean) + const parts = [candidate.session.title, candidate.snippet?.replace(/\s+/g, " ")].filter(Boolean) return `${candidate.session.id}\t${parts.join(" ")}` } @@ -31,7 +26,7 @@ export async function runFzfSearch(input: { bin: string; query: string; candidat "--scheme=history", "--delimiter", "\t", - "--with-nth", + "--nth", "2..", "--accept-nth", "1", diff --git a/src/search/logging.ts b/src/search/logging.ts index 5748247..c154446 100644 --- a/src/search/logging.ts +++ b/src/search/logging.ts @@ -7,6 +7,14 @@ const SERVICE = "smart-session-picker" let sequence = 0 +function boolEnv(value: string | undefined) { + return value === "1" || value === "true" || value === "yes" +} + +function debugLoggingEnabled() { + return boolEnv(process.env.OPENCODE_SMART_PICKER_DEBUG) || boolEnv(process.env.OPENCODE_SMART_PICKER_PERF) +} + export function nextLogID(prefix: string) { sequence += 1 return `${prefix}-${sequence}` @@ -75,12 +83,16 @@ export function logEvent( message: string, extra: Record = {}, ) { + // Debug events fire per keystroke; only ship them over HTTP when debug + // logging is explicitly enabled. + if (level === "debug" && !debugLoggingEnabled()) return + const effectiveLevel = level === "debug" && debugLoggingEnabled() ? "info" : level void api.client.app .log({ service: SERVICE, - level, + level: effectiveLevel, message, - extra, + extra: effectiveLevel === level ? extra : { ...extra, originalLevel: level }, }) .catch(() => { // Logging must never break the picker. diff --git a/src/search/opencode-api.ts b/src/search/opencode-api.ts index a1d11ee..f5f54fa 100644 --- a/src/search/opencode-api.ts +++ b/src/search/opencode-api.ts @@ -2,6 +2,8 @@ import type { Session } from "@opencode-ai/sdk/v2" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { SourceSessionCorpus } from "./types" +const CORPUS_FETCH_CONCURRENCY = 8 + export async function listOpenCodeSessions(api: TuiPluginApi, query?: string) { const response = await api.client.session.list({ roots: true, @@ -12,11 +14,21 @@ export async function listOpenCodeSessions(api: TuiPluginApi, query?: string) { } export async function loadCorpusFromOpenCodeApi(api: TuiPluginApi, sessions: Session[]): Promise { - const corpus: SourceSessionCorpus[] = [] - for (const session of sessions) { - const response = await api.client.session.messages({ sessionID: session.id }) - if (response.error || !response.data) continue - corpus.push({ session, messages: response.data }) + const corpus: (SourceSessionCorpus | undefined)[] = new Array(sessions.length) + let cursor = 0 + + async function worker() { + while (cursor < sessions.length) { + const index = cursor++ + const session = sessions[index]! + const response = await api.client.session.messages({ sessionID: session.id }) + if (response.error || !response.data) continue + corpus[index] = { session, messages: response.data } + } } - return corpus + + await Promise.all( + Array.from({ length: Math.min(CORPUS_FETCH_CONCURRENCY, Math.max(sessions.length, 1)) }, () => worker()), + ) + return corpus.filter((entry): entry is SourceSessionCorpus => Boolean(entry)) } diff --git a/src/search/preview.ts b/src/search/preview.ts index 7b9c4a0..544dc5f 100644 --- a/src/search/preview.ts +++ b/src/search/preview.ts @@ -2,7 +2,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { Message, Part } from "@opencode-ai/sdk/v2" import { resolveSearchConfig } from "./config" import { elapsedMs, errorFields, logEvent, nextLogID, nowMs, queryStats, timePhase } from "./logging" -import { SearchSidecar } from "./sidecar" +import { openSharedSidecar } from "./sidecar" /** Number of lines of context shown in the preview pane. */ export const PREVIEW_CONTEXT_LINES = 30 @@ -87,10 +87,9 @@ export async function loadSessionPreview( contextLines, }) - let sidecar: SearchSidecar | undefined try { await timePhase(phases, "sidecarPreviewMs", async () => { - sidecar = await SearchSidecar.open(config) + const sidecar = await openSharedSidecar(config) if (sidecar.hasDocuments()) { const rows = sidecar.getSessionDocumentTexts(sessionID) if (rows.length) { @@ -106,8 +105,6 @@ export async function loadSessionPreview( ...errorFields(err), }) /* sidecar unavailable */ - } finally { - sidecar?.close() } if (!rawLines) { @@ -150,13 +147,8 @@ export async function loadSessionPreview( function rowsToLines(rows: Array<{ role: string | null; text: string }>): PreviewLine[] { const out: PreviewLine[] = [] for (const row of rows) { - const cleaned = row.text - .replace(/^Title: .*\n?/m, "") - .replace(/^Role: .*\n?/m, "") - .replace(/^(?:Path|Directory): .*\n?/m, "") - .trim() - if (row.role) out.push({ text: `[${row.role}]`, kind: "role", isMatch: false }) - for (const l of sanitizePreviewTextLines(cleaned)) out.push({ text: l, kind: "text", isMatch: false }) + out.push({ text: row.role ? `[${row.role}]` : "[title]", kind: "role", isMatch: false }) + for (const l of sanitizePreviewTextLines(row.text.trim())) out.push({ text: l, kind: "text", isMatch: false }) out.push({ text: "", kind: "separator", isMatch: false }) } return out diff --git a/src/search/ranking.ts b/src/search/ranking.ts index 7ef6c9d..5e8b46f 100644 --- a/src/search/ranking.ts +++ b/src/search/ranking.ts @@ -4,7 +4,12 @@ function normalize(rows: RankedCandidate[], key: "keywordScore" | "vectorScore") const values = rows.map((row) => row[key] ?? 0) const min = Math.min(...values) const max = Math.max(...values) - return new Map(rows.map((row) => [row.sessionID, max === min ? (values.length ? 1 : 0) : ((row[key] ?? 0) - min) / (max - min)])) + // All-equal scores normalize to 1 only when there is real signal; an + // all-zero column (e.g. no keyword hits at all) must stay at 0 so it does + // not dilute the other component. + return new Map( + rows.map((row) => [row.sessionID, max === min ? (max > 0 ? 1 : 0) : ((row[key] ?? 0) - min) / (max - min)]), + ) } export function blendHybridScores(input: { diff --git a/src/search/search.ts b/src/search/search.ts index eb332d0..518c1d7 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -2,8 +2,10 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { Session } from "@opencode-ai/sdk/v2" import { resolveSearchConfig } from "./config" import { checkFzf } from "./dependencies" +import type { FzfHealth } from "./dependencies" import { LlamaEmbeddingClient } from "./embedding" import { runFzfSearch } from "./fzf" +import type { FzfCandidate } from "./fzf" import { diagnosticKinds, elapsedMs, @@ -17,14 +19,17 @@ import { } from "./logging" import { listOpenCodeSessions, loadCorpusFromOpenCodeApi } from "./opencode-api" import { blendHybridScores } from "./ranking" -import { SearchSidecar } from "./sidecar" +import { SearchSidecar, closeSharedSidecar, openSharedSidecar, resetSharedSidecar } from "./sidecar" +import type { SessionIndexDelta } from "./sidecar" import type { RankedCandidate, SearchConfig, SearchDiagnostic, SearchMode, SearchResponse } from "./types" import { extractSessionDocuments } from "./extractor" +const FZF_HEALTH_TTL_MS = 30_000 + let indexing: Promise | undefined let indexedOnce = false -let indexedDbPath: string | undefined let indexGeneration = 0 +let cachedFzfHealth: { key: string; checkedAt: number; health: Promise } | undefined export function invalidateSearchIndex(reason = "manual") { indexedOnce = false @@ -35,7 +40,6 @@ export function invalidateSearchIndex(reason = "manual") { export function searchIndexDebugState() { return { indexedOnce, - indexedDbPath, indexing: Boolean(indexing), generation: indexGeneration, } @@ -52,83 +56,163 @@ const INDEX_INVALIDATION_EVENTS = [ ] as const export function registerSearchIndexInvalidation(api: TuiPluginApi) { + // Intentionally no logging here: part-update events fire per streamed + // token, and a log call per event floods the OpenCode log endpoint. const disposers = INDEX_INVALIDATION_EVENTS.map((type) => - api.event.on(type, (event) => { - const invalidation = invalidateSearchIndex(type) - logEvent(api, "debug", "search.index_invalidated", { - component: "search", - reason: type, - generation: invalidation.generation, - eventType: event.type, - }) + api.event.on(type, () => { + invalidateSearchIndex(type) }), ) const dispose = () => { for (const item of disposers.splice(0)) item() + closeSharedSidecar() } api.lifecycle.onDispose(dispose) return dispose } +function byRecency(sessions: Session[]) { + return [...sessions].sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) +} + function orderByIDs(sessions: Session[], ids: string[]) { const byID = new Map(sessions.map((session) => [session.id, session])) return ids.map((id) => byID.get(id)).filter((session): session is Session => Boolean(session)) } +function fzfHealthCacheKey(config: SearchConfig) { + return config.fzfBin ?? "__PATH__" +} + +async function checkFzfCached(config: SearchConfig) { + const key = fzfHealthCacheKey(config) + const expired = cachedFzfHealth && Date.now() - cachedFzfHealth.checkedAt > FZF_HEALTH_TTL_MS + if (cachedFzfHealth?.key !== key || expired) { + cachedFzfHealth = { + key, + checkedAt: Date.now(), + health: checkFzf(config).catch((err) => { + if (cachedFzfHealth?.key === key) cachedFzfHealth = undefined + throw err + }), + } + } + return cachedFzfHealth!.health +} + +/** + * Fetch messages for the sessions named by the delta and apply them to the + * sidecar. Incremental deltas only touch changed/removed sessions; full + * deltas rebuild everything (first build or extractor upgrades). + */ +async function runIndexTask( + api: TuiPluginApi, + config: SearchConfig, + sessions: Session[], + delta: Exclude, +) { + const byID = new Map(sessions.map((session) => [session.id, session])) + const targets = + delta.kind === "full" + ? sessions + : delta.changed.map((id) => byID.get(id)).filter((session): session is Session => Boolean(session)) + const corpus = targets.length ? await loadCorpusFromOpenCodeApi(api, targets) : [] + if (api.lifecycle.signal.aborted) return + + let sidecar = await openSharedSidecar(config) + let vectorLoaded = config.disableVector ? false : await sidecar.loadVectorExtension() + + const applyWrite = (target: SearchSidecar, loaded: boolean) => { + if (delta.kind === "full") target.rebuildCorpus(corpus) + else target.upsertSessions(corpus, delta.removed, loaded) + } + + try { + applyWrite(sidecar, vectorLoaded) + } catch (err) { + if (!SearchSidecar.isRecoverableCacheError(err)) throw err + sidecar = await resetSharedSidecar(config) + vectorLoaded = config.disableVector ? false : await sidecar.loadVectorExtension() + applyWrite(sidecar, vectorLoaded) + } + + // Embeddings are only maintained for hybrid mode with vectors enabled. + if (config.mode !== "hybrid" || config.disableVector || config.alpha <= 0 || !vectorLoaded) return + if (api.lifecycle.signal.aborted) return + + const client = new LlamaEmbeddingClient(config) + if (!(await client.health())) return + + const state = sidecar.getMeta("vector_state")?.value + if (delta.kind === "full" || state === "unavailable" || !state) { + // Build the whole vector index from the already-indexed document texts. + const documents = sidecar.allDocumentTexts() + if (!documents.length) return + const embeddings = await client.embedDocuments(documents.map((document) => document.text)) + if (api.lifecycle.signal.aborted) return + await sidecar.replaceVectorEmbeddings(documents, embeddings) + return + } + + // Incremental: embed only documents whose vectors are missing. This also + // self-heals documents whose embedding failed in an earlier pass. + const pending = sidecar.documentsMissingEmbeddings() + const embeddings = pending.length ? await client.embedDocuments(pending.map((document) => document.text)) : [] + if (api.lifecycle.signal.aborted) return + await sidecar.upsertVectorEmbeddings(pending, embeddings) +} + +/** + * Keep the sidecar index in sync. Searches only block on indexing when the + * index is completely empty (nothing usable to serve); otherwise stale-but- + * usable results are returned while the index updates in the background. + */ async function ensureBackgroundIndex( api: TuiPluginApi, + config: SearchConfig, sessions: Session[], sidecar: SearchSidecar, diagnostics: SearchDiagnostic[], - blocking = false, + allowBlocking: boolean, ) { if (api.lifecycle.signal.aborted) return - if (indexedDbPath !== sidecar.config.searchDbPath) { - indexedDbPath = sidecar.config.searchDbPath - indexedOnce = false - } - if (indexedOnce && !sidecar.needsReindex(sessions)) return if (indexing) { - if (blocking) await indexing + if (allowBlocking && !sidecar.hasDocuments()) await indexing return } - if (!sidecar.needsReindex(sessions)) { + + const delta = sidecar.indexDelta(sessions) + if (delta.kind === "none") { indexedOnce = true return } - diagnostics.push({ kind: "indexing", message: "Building the local search index in the background." }) + const blocking = allowBlocking && !sidecar.hasDocuments() + diagnostics.push({ + kind: "indexing", + message: blocking + ? "Building the local search index." + : "Updating the local search index in the background.", + }) + const generation = indexGeneration - const indexTask = loadCorpusFromOpenCodeApi(api, sessions) - .then(async (corpus) => { - if (api.lifecycle.signal.aborted || generation !== indexGeneration) return - const workerSidecar = await SearchSidecar.open(sidecar.config) - try { - if (api.lifecycle.signal.aborted || generation !== indexGeneration) return - workerSidecar.rebuildCorpus(corpus) - if (!workerSidecar.config.disableVector) { - const documents = corpus.flatMap((entry) => extractSessionDocuments(entry.session, entry.messages)) - const client = new LlamaEmbeddingClient(workerSidecar.config) - if (await client.health()) { - if (api.lifecycle.signal.aborted || generation !== indexGeneration) return - const embeddings = await client.embedDocuments(documents.map((document) => document.text)) - if (api.lifecycle.signal.aborted || generation !== indexGeneration) return - await workerSidecar.replaceVectorEmbeddings(documents, embeddings) - } - } - } finally { - workerSidecar.close() - } - }) - .then(() => { - if (!api.lifecycle.signal.aborted && generation === indexGeneration) indexedOnce = true + const task = runIndexTask(api, config, sessions, delta).then(() => { + if (!api.lifecycle.signal.aborted && generation === indexGeneration) indexedOnce = true + }) + indexing = task + .catch((err) => { + logEvent(api, "error", "search.index_failed", { + component: "search", + deltaKind: delta.kind, + sessionCount: sessions.length, + ...errorFields(err), + }) }) .finally(() => { - if (indexing === indexTask) indexing = undefined + indexing = undefined }) - indexing = indexTask - if (blocking) await indexing + if (blocking) await task } export async function searchSessions( @@ -168,185 +252,182 @@ export async function searchSessions( try { const allSessions = await timePhase(phases, "sessionListMs", () => listOpenCodeSessions(api)) + // Empty query: list all sessions ordered by most recent activity, + // matching the built-in picker. Index warmup stays non-blocking. + if (!query.trim()) { + try { + await timePhase(phases, "indexWarmupMs", async () => { + const sidecar = await openSharedSidecar(config) + await ensureBackgroundIndex(api, config, allSessions, sidecar, diagnostics, false) + }) + } catch { + /* sidecar not needed for empty-query listing */ + } + return completed({ sessions: byRecency(allSessions), diagnostics }, { candidateCount: allSessions.length }) + } + let sidecar: SearchSidecar | undefined try { - // Empty query: return all sessions regardless of mode or dependency availability. - if (!query.trim()) { - try { - await timePhase(phases, "indexWarmupMs", async () => { - sidecar = await SearchSidecar.open(config) - await ensureBackgroundIndex(api, allSessions, sidecar, diagnostics) - }) - } catch { - /* sidecar not needed for empty-query listing */ - } finally { - sidecar?.close() - sidecar = undefined - } - return completed({ sessions: allSessions, diagnostics }, { candidateCount: allSessions.length }) - } + sidecar = await timePhase(phases, "sidecarOpenMs", () => openSharedSidecar(config)) + } catch (err) { + const message = err instanceof Error ? err.message : "Sidecar search database could not be opened." + logModeUnavailable(api, config.mode, message, { searchID, dependency: "sidecar-index" }) + diagnostics.push({ kind: "sidecar-unavailable", message }) + } + if (sidecar) { try { - await timePhase(phases, "sidecarOpenIndexMs", async () => { - sidecar = await SearchSidecar.open(config) - await ensureBackgroundIndex(api, allSessions, sidecar, diagnostics, true) - }) - } catch (err) { - logModeUnavailable( - api, - config.mode, - err instanceof Error ? err.message : "Sidecar search database could not be opened.", - { - searchID, - dependency: "sidecar-index", - }, + await timePhase(phases, "indexEnsureMs", () => + ensureBackgroundIndex(api, config, allSessions, sidecar!, diagnostics, true), ) + } catch (err) { diagnostics.push({ - kind: "sidecar-unavailable", - message: err instanceof Error ? err.message : "Sidecar search database could not be opened.", + kind: "sidecar-stale", + message: err instanceof Error ? err.message : "Search index build failed; results may be incomplete.", }) } + } - if (config.mode === "fzf") { - const fzf = await timePhase(phases, "fzfCheckMs", () => checkFzf(config)) - if (fzf.state !== "available" || !fzf.bin) { - diagnostics.push({ kind: "fzf-unavailable", message: fzf.message ?? "fzf is unavailable." }) - const modeUnavailable = "fzf is not installed - install fzf to use this mode." - logModeUnavailable(api, "fzf", modeUnavailable, { - searchID, - dependency: "fzf", - dependencyState: fzf.state, - }) - return completed( - { - sessions: [], - diagnostics, - modeUnavailable, - }, - { candidateCount: allSessions.length }, - ) - } - const fzfBin = fzf.bin + if (!sidecar) { + const modeUnavailable = `Search index is unavailable - ${config.mode} search requires the sidecar database.` + return completed( + { sessions: [], diagnostics, modeUnavailable }, + { candidateCount: allSessions.length }, + ) + } - const snippets = await timePhase( - phases, - "snippetLoadMs", - async () => sidecar?.snippetsForSessions(allSessions.map((session) => session.id)) ?? new Map(), - ) - const result = await timePhase(phases, "fzfSearchMs", () => - runFzfSearch({ - bin: fzfBin, - query, - candidates: allSessions.map((session) => ({ session, snippet: snippets.get(session.id) })), - }), - ) - if (result.status === "error") { - diagnostics.push({ kind: "fzf-unavailable", message: result.message }) - logModeUnavailable(api, "fzf", result.message, { - searchID, - dependency: "fzf", - }) - return completed( - { - sessions: [], - diagnostics, - modeUnavailable: `fzf error: ${result.message}`, - }, - { candidateCount: allSessions.length, fzfStatus: result.status }, - ) - } - if (result.status === "no-match") { - return completed( - { sessions: [], diagnostics }, - { candidateCount: allSessions.length, fzfStatus: result.status }, - ) - } + if (config.mode === "fzf") { + const fzfSidecar = sidecar + const fzf = await timePhase(phases, "fzfCheckMs", () => checkFzfCached(config)) + if (fzf.state !== "available" || !fzf.bin) { + diagnostics.push({ kind: "fzf-unavailable", message: fzf.message ?? "fzf is unavailable." }) + const modeUnavailable = "fzf is not installed - install fzf to use this mode." + logModeUnavailable(api, "fzf", modeUnavailable, { + searchID, + dependency: "fzf", + dependencyState: fzf.state, + }) return completed( - { sessions: orderByIDs(allSessions, result.sessionIDs), diagnostics }, - { candidateCount: allSessions.length, fzfStatus: result.status }, + { sessions: [], diagnostics, modeUnavailable }, + { candidateCount: allSessions.length }, ) } + const fzfBin = fzf.bin - // Hybrid mode - if (!sidecar) { - const modeUnavailable = "Search index is unavailable - hybrid search requires the sidecar database." + const keyword = await timePhase(phases, "keywordSearchMs", async () => fzfSidecar.searchFts(query)) + if (!keyword.length) { + return completed( + { sessions: [], diagnostics }, + { candidateCount: allSessions.length, keywordCandidateCount: 0 }, + ) + } + const byID = new Map(allSessions.map((session) => [session.id, session])) + const candidates: FzfCandidate[] = [] + for (const row of keyword) { + const session = byID.get(row.sessionID) + if (session) candidates.push({ session, snippet: row.snippet }) + } + const result = await timePhase(phases, "fzfSearchMs", () => + runFzfSearch({ + bin: fzfBin, + query, + candidates, + }), + ) + if (result.status === "error") { + diagnostics.push({ kind: "fzf-unavailable", message: result.message }) + logModeUnavailable(api, "fzf", result.message, { + searchID, + dependency: "fzf", + }) return completed( { sessions: [], diagnostics, - modeUnavailable, + modeUnavailable: `fzf error: ${result.message}`, }, - { candidateCount: allSessions.length }, + { candidateCount: allSessions.length, fzfStatus: result.status }, ) } - const hybridSidecar = sidecar - - const keyword = await timePhase(phases, "keywordSearchMs", async () => hybridSidecar.searchFts(query)) - if (!keyword.length) { + if (result.status === "no-match") { return completed( { sessions: [], diagnostics }, - { candidateCount: allSessions.length, keywordCandidateCount: 0 }, + { candidateCount: allSessions.length, fzfStatus: result.status }, ) } + return completed( + { sessions: orderByIDs(allSessions, result.sessionIDs), diagnostics }, + { candidateCount: allSessions.length, keywordCandidateCount: keyword.length, fzfStatus: result.status }, + ) + } - let vector: RankedCandidate[] = [] - if (!config.disableVector && config.alpha > 0) { - const client = new LlamaEmbeddingClient(config) - try { - const healthy = await timePhase(phases, "embeddingHealthMs", () => client.health()) - if (healthy) { - const embedding = await timePhase(phases, "embeddingQueryMs", () => client.embedQuery(query)) - vector = await timePhase(phases, "vectorSearchMs", () => hybridSidecar.searchVector(embedding)) - } else { - diagnostics.push({ - kind: "embedding-unavailable", - message: "Embedding server is unavailable; using keyword search.", - }) - logModeUnavailable(api, "hybrid", "Embedding server is unavailable; using keyword search.", { - searchID, - dependency: "llama-server", - }) - } - } catch (err) { + // Hybrid mode: keyword and vector search run independently so purely + // semantic matches surface even when no keyword candidate exists. + const hybridSidecar = sidecar + const keyword = await timePhase(phases, "keywordSearchMs", async () => hybridSidecar.searchFts(query)) + + let vector: RankedCandidate[] = [] + if (!config.disableVector && config.alpha > 0) { + const client = new LlamaEmbeddingClient(config) + try { + const healthy = await timePhase(phases, "embeddingHealthMs", () => client.health()) + if (healthy) { + const embedding = await timePhase(phases, "embeddingQueryMs", () => client.embedQuery(query)) + vector = await timePhase(phases, "vectorSearchMs", () => hybridSidecar.searchVector(embedding)) + } else { diagnostics.push({ kind: "embedding-unavailable", - message: err instanceof Error ? err.message : "Embedding query failed; using keyword search.", + message: "Embedding server is unavailable; using keyword search.", + }) + logModeUnavailable(api, "hybrid", "Embedding server is unavailable; using keyword search.", { + searchID, + dependency: "llama-server", }) - logModeUnavailable( - api, - "hybrid", - err instanceof Error ? err.message : "Embedding query failed; using keyword search.", - { - searchID, - dependency: "llama-server", - }, - ) } + } catch (err) { + diagnostics.push({ + kind: "embedding-unavailable", + message: err instanceof Error ? err.message : "Embedding query failed; using keyword search.", + }) + logModeUnavailable( + api, + "hybrid", + err instanceof Error ? err.message : "Embedding query failed; using keyword search.", + { + searchID, + dependency: "llama-server", + }, + ) } + } - const { ranked, diagnostics: rankingDiagnostics } = await timePhase(phases, "rankingMs", async () => - blendHybridScores({ - keyword, - vector, - alpha: config.alpha, - vectorAvailable: vector.length > 0, - }), - ) - diagnostics.push(...rankingDiagnostics) + if (!keyword.length && !vector.length) { return completed( - { sessions: orderByIDs(allSessions, ranked.map((row) => row.sessionID)), diagnostics }, - { - candidateCount: allSessions.length, - keywordCandidateCount: keyword.length, - vectorCandidateCount: vector.length, - rankedCandidateCount: ranked.length, - alpha: config.alpha, - vectorEnabled: !config.disableVector, - }, + { sessions: [], diagnostics }, + { candidateCount: allSessions.length, keywordCandidateCount: 0, vectorCandidateCount: 0 }, ) - } finally { - sidecar?.close() } + + const { ranked, diagnostics: rankingDiagnostics } = await timePhase(phases, "rankingMs", async () => + blendHybridScores({ + keyword, + vector, + alpha: config.alpha, + vectorAvailable: vector.length > 0, + }), + ) + diagnostics.push(...rankingDiagnostics) + return completed( + { sessions: orderByIDs(allSessions, ranked.map((row) => row.sessionID)), diagnostics }, + { + candidateCount: allSessions.length, + keywordCandidateCount: keyword.length, + vectorCandidateCount: vector.length, + rankedCandidateCount: ranked.length, + alpha: config.alpha, + vectorEnabled: !config.disableVector, + }, + ) } catch (err) { logEvent(api, "error", "search.failed", { component: "search", diff --git a/src/search/sidecar.ts b/src/search/sidecar.ts index 2089ba6..ba1f7e7 100644 --- a/src/search/sidecar.ts +++ b/src/search/sidecar.ts @@ -1,16 +1,31 @@ -import { mkdir } from "node:fs/promises" +import { mkdir, unlink } from "node:fs/promises" import path from "node:path" import { Database } from "bun:sqlite" import type { RankedCandidate, SearchConfig, SearchDocument, SourceSessionCorpus } from "./types" -import { extractSessionDocuments } from "./extractor" +import { SEARCH_EXTRACTOR_VERSION, extractSessionDocuments } from "./extractor" import { fileExists } from "./config" +/** + * Bump when the sidecar layout changes incompatibly. A mismatch deletes the + * plugin-owned cache database and rebuilds it from scratch, which is always + * safe because the sidecar is a derived index over OpenCode's own data. + */ +export const SIDECAR_SCHEMA_VERSION = "2" + +const SQL_VARIABLE_CHUNK = 500 + let customSQLiteAttempted = false function now() { return Date.now() } +function chunk(items: T[], size: number) { + const out: T[][] = [] + for (let index = 0; index < items.length; index += size) out.push(items.slice(index, index + size)) + return out +} + async function candidateSQLiteLibraries(config: SearchConfig) { return [ config.sqliteLibPath, @@ -51,26 +66,98 @@ function normalizeScores(rows: T[]) { return new Map(rows.map((row) => [row, max === min ? 1 : (row.raw - min) / (max - min)])) } +function isCorruptDatabaseError(error: unknown) { + return error instanceof Error && /database disk image is malformed|database corruption|file is not a database/i.test(error.message) +} + +class SchemaResetRequired extends Error { + constructor() { + super("Sidecar schema version mismatch; cache reset required.") + } +} + +async function removeSidecarDatabase(file: string) { + await Promise.all( + [file, `${file}-wal`, `${file}-shm`].map((candidate) => + unlink(candidate).catch((err) => { + if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return + throw err + }), + ), + ) +} + +export type SessionIndexDelta = + | { kind: "none" } + | { kind: "full"; removed: string[] } + | { kind: "incremental"; changed: string[]; removed: string[] } + +export type VectorEmbeddableDocument = Pick + export class SearchSidecar { readonly db: Database private vectorExtensionLoaded = false constructor(readonly config: SearchConfig) { this.db = new Database(config.searchDbPath) + this.db.exec("pragma journal_mode = WAL") + this.db.exec("pragma synchronous = NORMAL") + this.db.exec("pragma busy_timeout = 2000") } static async open(config: SearchConfig) { await mkdir(path.dirname(config.searchDbPath), { recursive: true }) await configureSQLiteForExtensions(config) - const sidecar = new SearchSidecar(config) - sidecar.migrate() - return sidecar + return SearchSidecar.openMigrated(config, true) + } + + static isRecoverableCacheError(error: unknown) { + return isCorruptDatabaseError(error) + } + + static async resetCache(config: SearchConfig) { + if (config.searchDbPath === ":memory:") return + await removeSidecarDatabase(config.searchDbPath) + } + + private static async openMigrated(config: SearchConfig, recover: boolean): Promise { + let sidecar: SearchSidecar | undefined + try { + sidecar = new SearchSidecar(config) + if (sidecar.schemaVersionMismatch()) throw new SchemaResetRequired() + sidecar.migrate() + return sidecar + } catch (err) { + try { + sidecar?.close() + } catch { + /* ignore close errors while recovering */ + } + const recoverable = err instanceof SchemaResetRequired || isCorruptDatabaseError(err) + if (!recover || !recoverable || config.searchDbPath === ":memory:") throw err + await removeSidecarDatabase(config.searchDbPath) + return SearchSidecar.openMigrated(config, false) + } } close() { this.db.close() } + private tableExists(name: string) { + const row = this.db + .prepare("select 1 as found from sqlite_master where type in ('table', 'view') and name = ?") + .get(name) as { found: number } | undefined + return Boolean(row) + } + + private schemaVersionMismatch() { + if (this.config.searchDbPath === ":memory:") return false + if (!this.tableExists("index_meta")) return false + const version = this.getMeta("schema_version")?.value + return version !== SIDECAR_SCHEMA_VERSION + } + migrate() { this.db.exec(` create table if not exists index_meta( @@ -92,6 +179,7 @@ export class SearchSidecar { directory text, path text, source_hash text not null, + session_updated integer not null default 0, indexed_at integer not null ); @@ -116,37 +204,33 @@ export class SearchSidecar { indexed_at integer not null ); + create index if not exists document_session_idx on document(session_id); + create virtual table if not exists document_fts using fts5( - title, - directory, - path, - role, - part_type, text, content='document', content_rowid='rowid' ); `) - this.ensureColumn("document", "title", "text") - this.ensureColumn("document", "directory", "text") - this.ensureColumn("document", "path", "text") - - this.setMeta("schema_version", "1") - this.setMeta("extractor_version", "1") + this.setMeta("schema_version", SIDECAR_SCHEMA_VERSION) + this.setMeta("extractor_version", SEARCH_EXTRACTOR_VERSION) this.setMeta("ranking_version", "1") - this.setMeta("supported_search_modes", "hybrid") - this.setMeta("vector_state", this.config.disableVector ? "disabled" : "unavailable") + this.setMeta("supported_search_modes", "hybrid,fzf") this.setMeta("document_prefix", this.config.documentPrefix) this.setMeta("query_prefix", this.config.queryPrefix) this.setMeta("embedding_base_url", this.config.embedBaseUrl) if (this.config.embedModel) this.setMeta("embedding_model", this.config.embedModel) - } - private ensureColumn(table: string, column: string, declaration: string) { - const rows = this.db.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }> - if (rows.some((row) => row.name === column)) return - this.db.exec(`alter table ${table} add column ${column} ${declaration}`) + // vector_state reflects whether embeddings in document_vec are usable. + // Never downgrade an "enabled"/"stale" state on open: that is owned by + // the indexing pipeline, not by connection setup. + const vectorState = this.getMeta("vector_state")?.value + if (this.config.disableVector) { + this.setMeta("vector_state", "disabled") + } else if (!vectorState || vectorState === "disabled") { + this.setMeta("vector_state", "unavailable") + } } getMeta(key: string) { @@ -154,6 +238,7 @@ export class SearchSidecar { } setMeta(key: string, value: string) { + if (this.getMeta(key)?.value === value) return this.db.prepare(` insert into index_meta(key, value) values (?, ?) @@ -163,10 +248,7 @@ export class SearchSidecar { async loadVectorExtension() { if (this.vectorExtensionLoaded) return true - if (this.config.disableVector) { - this.setMeta("vector_state", "disabled") - return false - } + if (this.config.disableVector) return false try { if (this.config.sqliteVecExtension && (await fileExists(this.config.sqliteVecExtension))) { @@ -192,31 +274,38 @@ export class SearchSidecar { return true } } catch { - this.setMeta("vector_state", "unavailable") return false } - this.setMeta("vector_state", "unavailable") return false } - async replaceVectorEmbeddings(documents: SearchDocument[], embeddings: Float32Array[]) { + private ensureVectorTable(dimensions: number) { + this.db.exec(`create virtual table if not exists document_vec using vec0(embedding float[${dimensions}])`) + } + + private insertEmbeddingRows(documents: VectorEmbeddableDocument[], embeddings: Float32Array[]) { + const selectRowID = this.db.prepare("select rowid from document where doc_id = ?") + const insert = this.db.prepare("insert or replace into document_vec(rowid, embedding) values (?, vec_f32(?))") + for (const [index, document] of documents.entries()) { + const row = selectRowID.get(document.docID) as { rowid: number } | undefined + if (!row) continue + insert.run(row.rowid, embeddings[index]) + } + } + + /** Replace the entire vector index. Used for initial/full embedding builds. */ + async replaceVectorEmbeddings(documents: VectorEmbeddableDocument[], embeddings: Float32Array[]) { if (!documents.length || documents.length !== embeddings.length) return false if (!(await this.loadVectorExtension())) return false const dimensions = embeddings[0]?.length if (!dimensions) return false - this.db.exec(`create virtual table if not exists document_vec using vec0(embedding float[${dimensions}])`) - const selectRowID = this.db.prepare("select rowid from document where doc_id = ?") - const insert = this.db.prepare("insert into document_vec(rowid, embedding) values (?, vec_f32(?))") + this.ensureVectorTable(dimensions) const transaction = this.db.transaction(() => { this.db.prepare("delete from document_vec").run() - for (const [index, document] of documents.entries()) { - const row = selectRowID.get(document.docID) as { rowid: number } | undefined - if (!row) continue - insert.run(row.rowid, embeddings[index]) - } + this.insertEmbeddingRows(documents, embeddings) this.setMeta("embedding_dimensions", String(dimensions)) this.setMeta("vector_state", "enabled") }) @@ -224,10 +313,30 @@ export class SearchSidecar { return true } + /** + * Upsert embeddings for changed documents only. Valid when the rest of the + * vector index is already populated (state "enabled" or "stale"). + */ + async upsertVectorEmbeddings(documents: VectorEmbeddableDocument[], embeddings: Float32Array[]) { + if (documents.length !== embeddings.length) return false + if (!(await this.loadVectorExtension())) return false + const dimensions = embeddings[0]?.length + if (documents.length && !dimensions) return false + + if (dimensions) this.ensureVectorTable(dimensions) + const transaction = this.db.transaction(() => { + if (dimensions) this.insertEmbeddingRows(documents, embeddings) + this.setMeta("vector_state", "enabled") + }) + transaction() + return true + } + async searchVector(queryEmbedding: Float32Array): Promise { if (!(await this.loadVectorExtension())) return [] const state = this.getMeta("vector_state")?.value if (state !== "enabled") return [] + if (!this.tableExists("document_vec")) return [] const count = (this.db.prepare("select count(*) as count from document_vec").get() as { count: number }).count if (!count) return [] @@ -264,111 +373,210 @@ export class SearchSidecar { return row.found === 1 } + allDocumentTexts(): VectorEmbeddableDocument[] { + return this.db.prepare("select doc_id as docID, text from document order by rowid asc").all() as Array<{ + docID: string + text: string + }> + } + + /** + * Documents that have no row in document_vec. Requires the vector + * extension to be loaded when document_vec exists. + */ + documentsMissingEmbeddings(): VectorEmbeddableDocument[] { + if (!this.tableExists("document_vec")) return this.allDocumentTexts() + return this.db + .prepare(` + select doc_id as docID, text from document + where rowid not in (select rowid from document_vec) + order by rowid asc + `) + .all() as Array<{ docID: string; text: string }> + } + + /** + * Compare the live session list against the indexed state and report what + * needs work. Reads the whole indexed_session table to avoid IN-clause + * variable limits. + */ + indexDelta(sessions: Array<{ id: string; time?: { updated: number } }>): SessionIndexDelta { + const indexedRows = this.db + .prepare("select session_id as sessionID, session_updated as sessionUpdated from indexed_session") + .all() as Array<{ sessionID: string; sessionUpdated: number }> + const indexed = new Map(indexedRows.map((row) => [row.sessionID, row.sessionUpdated])) + const liveIDs = new Set(sessions.map((session) => session.id)) + const removed = indexedRows.map((row) => row.sessionID).filter((id) => !liveIDs.has(id)) + + const staleExtractor = this.getMeta("extractor_version")?.value !== SEARCH_EXTRACTOR_VERSION || + (this.db + .prepare("select exists(select 1 from document where extractor_version != ?) as found") + .get(SEARCH_EXTRACTOR_VERSION) as { found: number }).found === 1 + if (staleExtractor) return { kind: "full", removed } + + const changed = sessions + .filter((session) => { + const sessionUpdated = indexed.get(session.id) + if (sessionUpdated === undefined) return true + return (session.time?.updated ?? 0) > sessionUpdated + }) + .map((session) => session.id) + + if (!changed.length && !removed.length) return { kind: "none" } + return { kind: "incremental", changed, removed } + } + + /** Back-compat convenience over indexDelta. */ needsReindex(sessions: Array<{ id: string; time?: { updated: number } }>) { - if (!this.hasDocuments()) return true - if (!sessions.length) return false + return this.indexDelta(sessions).kind !== "none" + } + + private markVectorStale() { + if (this.getMeta("vector_state")?.value === "enabled") this.setMeta("vector_state", "stale") + } + + private upsertIndexedSession(entry: SourceSessionCorpus, sessionDocuments: SearchDocument[], indexedAt: number) { + this.db + .prepare(` + insert into indexed_session(session_id, parent_id, project_id, workspace_id, directory, path, source_hash, session_updated, indexed_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(session_id) do update set + parent_id = excluded.parent_id, + project_id = excluded.project_id, + workspace_id = excluded.workspace_id, + directory = excluded.directory, + path = excluded.path, + source_hash = excluded.source_hash, + session_updated = excluded.session_updated, + indexed_at = excluded.indexed_at + `) + .run( + entry.session.id, + entry.session.parentID ?? null, + entry.session.projectID, + entry.session.workspaceID ?? null, + entry.session.directory, + entry.session.path ?? null, + sessionDocuments.map((document) => document.sourceHash).join(":") || entry.session.id, + entry.session.time?.updated ?? 0, + indexedAt, + ) + } - const placeholders = sessions.map(() => "?").join(",") - const indexed = this.db - .prepare(`select count(distinct session_id) as count from indexed_session where session_id in (${placeholders})`) - .get(...sessions.map((session) => session.id)) as { count: number } - if (indexed.count < sessions.length) return true + private insertDocumentRows(documents: SearchDocument[], indexedAt: number) { + const insertDocument = this.db.prepare(` + insert into document( + doc_id, session_id, message_id, part_id, chunk_index, role, part_type, + synthetic, ignored, title, directory, path, text, metadata_json, source_hash, extractor_version, indexed_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + const insertFts = this.db.prepare("insert into document_fts(rowid, text) values (?, ?)") + for (const document of documents) { + const metadata = document.metadata + const result = insertDocument.run( + document.docID, + document.sessionID, + document.messageID ?? null, + document.partID ?? null, + document.chunkIndex, + document.role ?? null, + document.partType ?? null, + document.synthetic ? 1 : 0, + document.ignored ? 1 : 0, + typeof metadata.title === "string" ? metadata.title : "", + typeof metadata.directory === "string" ? metadata.directory : "", + typeof metadata.path === "string" ? metadata.path : "", + document.text, + JSON.stringify(document.metadata), + document.sourceHash, + SEARCH_EXTRACTOR_VERSION, + indexedAt, + ) + // The FTS row must mirror the content table exactly: external-content + // FTS5 resolves deletes against the content table, and mismatched + // values corrupt the index. + insertFts.run(result.lastInsertRowid, document.text) + } + } - const lastIndexed = Number(this.getMeta("last_indexed_at")?.value ?? 0) - const latestSessionUpdate = Math.max(...sessions.map((session) => session.time?.updated ?? 0)) - return Number.isFinite(latestSessionUpdate) && latestSessionUpdate > lastIndexed + private deleteSessionRows(sessionIDs: string[], vectorLoaded: boolean) { + if (!sessionIDs.length) return + const selectRowIDs = this.db.prepare("select rowid from document where session_id = ?") + const deleteFts = this.db.prepare( + "delete from document_fts where rowid in (select rowid from document where session_id = ?)", + ) + const deleteDocuments = this.db.prepare("delete from document where session_id = ?") + const deleteIndexed = this.db.prepare("delete from indexed_session where session_id = ?") + for (const sessionID of sessionIDs) { + if (vectorLoaded && this.tableExists("document_vec")) { + const rowids = (selectRowIDs.all(sessionID) as Array<{ rowid: number }>).map((row) => row.rowid) + for (const batch of chunk(rowids, SQL_VARIABLE_CHUNK)) { + this.db + .prepare(`delete from document_vec where rowid in (${batch.map(() => "?").join(",")})`) + .run(...batch) + } + } + // Delete FTS rows before the content rows so FTS5 can resolve values. + deleteFts.run(sessionID) + deleteDocuments.run(sessionID) + deleteIndexed.run(sessionID) + } } + /** Full rebuild of the corpus. Used on first build and extractor upgrades. */ rebuildCorpus(corpus: SourceSessionCorpus[]) { - const documents = corpus.flatMap((entry) => extractSessionDocuments(entry.session, entry.messages)) - this.replaceDocuments(documents) + const indexedAt = now() + const transaction = this.db.transaction(() => { + // delete-all is the FTS5-supported way to clear an external-content table. + this.db.prepare("insert into document_fts(document_fts) values ('delete-all')").run() + this.db.prepare("delete from document").run() + this.db.prepare("delete from indexed_session").run() + for (const entry of corpus) { + const documents = extractSessionDocuments(entry.session, entry.messages) + this.insertDocumentRows(documents, indexedAt) + this.upsertIndexedSession(entry, documents, indexedAt) + } + this.markVectorStale() + this.setMeta("extractor_version", SEARCH_EXTRACTOR_VERSION) + this.setMeta("last_indexed_at", String(indexedAt)) + }) + transaction() + } + /** + * Incrementally replace documents for changed sessions and drop removed + * sessions. Keeps indexed_session rows even for sessions that produce no + * documents so they are not perpetually re-fetched. + */ + upsertSessions(corpus: SourceSessionCorpus[], removedSessionIDs: string[], vectorLoaded: boolean) { + if (!corpus.length && !removedSessionIDs.length) return const indexedAt = now() - const insertSession = this.db.prepare(` - insert into indexed_session(session_id, parent_id, project_id, workspace_id, directory, path, source_hash, indexed_at) - values (?, ?, ?, ?, ?, ?, ?, ?) - on conflict(session_id) do update set - parent_id = excluded.parent_id, - project_id = excluded.project_id, - workspace_id = excluded.workspace_id, - directory = excluded.directory, - path = excluded.path, - source_hash = excluded.source_hash, - indexed_at = excluded.indexed_at - `) const transaction = this.db.transaction(() => { + this.deleteSessionRows( + [...corpus.map((entry) => entry.session.id), ...removedSessionIDs], + vectorLoaded, + ) for (const entry of corpus) { - insertSession.run( - entry.session.id, - entry.session.parentID ?? null, - entry.session.projectID, - entry.session.workspaceID ?? null, - entry.session.directory, - entry.session.path ?? null, - documents - .filter((document) => document.sessionID === entry.session.id) - .map((document) => document.sourceHash) - .join(":") || entry.session.id, - indexedAt, - ) + const documents = extractSessionDocuments(entry.session, entry.messages) + this.insertDocumentRows(documents, indexedAt) + this.upsertIndexedSession(entry, documents, indexedAt) } - this.db.prepare("delete from indexed_session where session_id not in (select distinct session_id from document)").run() + if (corpus.length) this.markVectorStale() + this.setMeta("last_indexed_at", String(indexedAt)) }) transaction() } + /** Back-compat full replacement of all documents without session bookkeeping. */ replaceDocuments(documents: SearchDocument[]) { - const clear = this.db.prepare("delete from document") - const clearFts = this.db.prepare("delete from document_fts") - const insertDocument = this.db.prepare(` - insert into document( - doc_id, session_id, message_id, part_id, chunk_index, role, part_type, - synthetic, ignored, title, directory, path, text, metadata_json, source_hash, extractor_version, indexed_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - const insertFts = this.db.prepare(` - insert into document_fts(rowid, title, directory, path, role, part_type, text) - values (?, ?, ?, ?, ?, ?, ?) - `) + const indexedAt = now() const transaction = this.db.transaction(() => { - clearFts.run() - clear.run() - for (const document of documents) { - const metadata = document.metadata - const title = typeof metadata.title === "string" ? metadata.title : "" - const directory = typeof metadata.directory === "string" ? metadata.directory : "" - const documentPath = typeof metadata.path === "string" ? metadata.path : "" - const result = insertDocument.run( - document.docID, - document.sessionID, - document.messageID ?? null, - document.partID ?? null, - document.chunkIndex, - document.role ?? null, - document.partType ?? null, - document.synthetic ? 1 : 0, - document.ignored ? 1 : 0, - title, - directory, - documentPath, - document.text, - JSON.stringify(document.metadata), - document.sourceHash, - "1", - now(), - ) - insertFts.run( - result.lastInsertRowid, - title, - directory, - documentPath, - document.role ?? "", - document.partType ?? "", - document.text, - ) - } - this.setMeta("last_indexed_at", String(now())) + this.db.prepare("insert into document_fts(document_fts) values ('delete-all')").run() + this.db.prepare("delete from document").run() + this.insertDocumentRows(documents, indexedAt) + this.markVectorStale() + this.setMeta("last_indexed_at", String(indexedAt)) }) transaction() } @@ -381,7 +589,7 @@ export class SearchSidecar { select d.session_id as sessionID, bm25(document_fts) as rank, - snippet(document_fts, 5, '[', ']', ' ... ', 12) as snippet + snippet(document_fts, 0, '[', ']', ' ... ', 12) as snippet from document_fts join document d on d.rowid = document_fts.rowid where document_fts match ? @@ -413,15 +621,56 @@ export class SearchSidecar { snippetsForSessions(sessionIDs: string[]) { if (!sessionIDs.length) return new Map() - const placeholders = sessionIDs.map(() => "?").join(",") - const rows = this.db - .prepare(` - select session_id as sessionID, group_concat(text, ' ') as snippet - from document - where session_id in (${placeholders}) - group by session_id - `) - .all(...sessionIDs) as Array<{ sessionID: string; snippet: string }> - return new Map(rows.map((row) => [row.sessionID, row.snippet])) + const result = new Map() + for (const batch of chunk(sessionIDs, SQL_VARIABLE_CHUNK)) { + const rows = this.db + .prepare(` + select session_id as sessionID, group_concat(text, ' ') as snippet + from document + where session_id in (${batch.map(() => "?").join(",")}) + group by session_id + `) + .all(...batch) as Array<{ sessionID: string; snippet: string }> + for (const row of rows) result.set(row.sessionID, row.snippet) + } + return result } } + +/** + * Shared sidecar connection cache. Opening the sidecar previously ran the + * full migration (including meta writes) on every keystroke search and every + * preview load; reusing one connection removes that cost and the write + * contention between the search, preview, and indexing paths. + */ +let sharedSidecar: { key: string; sidecar: SearchSidecar } | undefined + +function sharedSidecarKey(config: SearchConfig) { + return [config.searchDbPath, config.disableVector ? "novec" : "vec", config.sqliteVecExtension ?? ""].join("|") +} + +export async function openSharedSidecar(config: SearchConfig): Promise { + const key = sharedSidecarKey(config) + if (sharedSidecar?.key === key) return sharedSidecar.sidecar + closeSharedSidecar() + const sidecar = await SearchSidecar.open(config) + sharedSidecar = { key, sidecar } + return sidecar +} + +export function closeSharedSidecar() { + if (!sharedSidecar) return + try { + sharedSidecar.sidecar.close() + } catch { + /* ignore close failures */ + } + sharedSidecar = undefined +} + +/** Close, delete, and reopen the shared sidecar cache after corruption. */ +export async function resetSharedSidecar(config: SearchConfig): Promise { + closeSharedSidecar() + await SearchSidecar.resetCache(config) + return openSharedSidecar(config) +} diff --git a/src/search/source-db.ts b/src/search/source-db.ts index 747a024..760002d 100644 --- a/src/search/source-db.ts +++ b/src/search/source-db.ts @@ -15,9 +15,20 @@ function numberValue(value: unknown) { return typeof value === "number" ? value : Number(value) || 0 } -export function readSourceCorpusFromDb(sourceDbPath: string): SourceSessionCorpus[] { +export type SourceCorpusFilter = { + directory?: string +} + +export function readSourceCorpusFromDb(sourceDbPath: string, filter: SourceCorpusFilter = {}): SourceSessionCorpus[] { const db = new Database(sourceDbPath, { readonly: true }) try { + const where = ["s.time_archived is null"] + const params: string[] = [] + if (filter.directory) { + where.push("s.directory = ?") + params.push(filter.directory) + } + const rows = db .prepare(` select @@ -40,10 +51,10 @@ export function readSourceCorpusFromDb(sourceDbPath: string): SourceSessionCorpu from session s join message m on m.session_id = s.id join part p on p.message_id = m.id and p.session_id = s.id - where s.time_archived is null + where ${where.join(" and ")} order by s.time_updated desc, m.time_created asc, m.id asc, p.time_created asc, p.id asc `) - .all() as Array> + .all(...params) as Array> const bySession = new Map() const byMessage = new Map() diff --git a/src/search/status.ts b/src/search/status.ts index 8bd0487..1780a22 100644 --- a/src/search/status.ts +++ b/src/search/status.ts @@ -1,6 +1,6 @@ import { resolveSearchConfig } from "./config" import { checkEmbeddingServer, checkFzf } from "./dependencies" -import { SearchSidecar } from "./sidecar" +import { openSharedSidecar } from "./sidecar" import type { SearchConfig, SearchDependencyStatus, SearchEnvironmentStatus, SearchMode } from "./types" async function sourceDbStatus(config: SearchConfig): Promise { @@ -21,9 +21,8 @@ async function sidecarStatus(config: SearchConfig): Promise<{ sidecar: SearchDependencyStatus sqliteVec: SearchDependencyStatus }> { - let sidecar: SearchSidecar | undefined try { - sidecar = await SearchSidecar.open(config) + const sidecar = await openSharedSidecar(config) const documents = sidecar.hasDocuments() ? "indexed" : "empty" if (config.disableVector) { return { @@ -48,8 +47,6 @@ async function sidecarStatus(config: SearchConfig): Promise<{ }, sqliteVec: { name: "sqlite-vec", state: "unavailable", message: "Sidecar index is unavailable." }, } - } finally { - sidecar?.close() } } diff --git a/src/tui.tsx b/src/tui.tsx index 0efcf21..54c20b0 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -11,7 +11,7 @@ import { checkSearchEnvironment } from "./search/status" import type { DependencyState, SearchDependencyStatus, SearchEnvironmentStatus, SearchMode } from "./search/types" const PLUGIN_ID = "local.smart-session-picker" -const SEARCH_DEBOUNCE_MS = 300 +const SEARCH_DEBOUNCE_MS = 150 function dateCategory(updated: number) { const date = new Date(updated) @@ -390,6 +390,12 @@ function SmartSessionDialog(props: { api: TuiPluginApi }) { const q = query() const m = mode() if (timer) clearTimeout(timer) + // Empty queries are the recency listing - refresh immediately so opening + // the picker and clearing a search both feel instant. + if (!q.trim()) { + void refresh(q, m) + return + } timer = setTimeout(() => void refresh(q, m), SEARCH_DEBOUNCE_MS) }) @@ -536,6 +542,7 @@ const tui: TuiPlugin = async (api) => { } api.keymap.registerLayer({ + priority: 1000, commands: [ { namespace: "palette", @@ -548,6 +555,11 @@ const tui: TuiPlugin = async (api) => { run: openSmartSessionDialog, }, ], + bindings: api.tuiConfig.keybinds.get("session.list").map((binding) => ({ + ...binding, + cmd: openSmartSessionDialog, + desc: binding.desc ?? "Switch session", + })), }) } diff --git a/test/integration/search.integration.test.ts b/test/integration/search.integration.test.ts index f121af1..71462ec 100644 --- a/test/integration/search.integration.test.ts +++ b/test/integration/search.integration.test.ts @@ -4,14 +4,15 @@ import path from "node:path" import { Database } from "bun:sqlite" import { afterEach, describe, expect, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { SearchConfig } from "../../src/search/types" +import type { Part } from "@opencode-ai/sdk/v2" +import type { SearchConfig, SourceSessionCorpus } from "../../src/search/types" import { resolveSourceDbPath } from "../../src/search/config" import { checkFzf } from "../../src/search/dependencies" import { LlamaEmbeddingClient } from "../../src/search/embedding" import { runFzfSearch } from "../../src/search/fzf" import { blendHybridScores } from "../../src/search/ranking" import { registerSearchIndexInvalidation, searchIndexDebugState } from "../../src/search/search" -import { SearchSidecar } from "../../src/search/sidecar" +import { SIDECAR_SCHEMA_VERSION, SearchSidecar } from "../../src/search/sidecar" import { readSourceCorpusFromDb } from "../../src/search/source-db" import { checkSearchEnvironment } from "../../src/search/status" @@ -21,6 +22,19 @@ afterEach(() => { for (const server of servers.splice(0)) server.stop() }) +function serveOnAvailablePort(fetch: (request: Request) => Response | Promise) { + let lastError: unknown + for (let attempt = 0; attempt < 20; attempt += 1) { + const port = 30_000 + Math.floor(Math.random() * 20_000) + try { + return Bun.serve({ port, fetch }) + } catch (err) { + lastError = err + } + } + throw lastError +} + async function tempPath(name: string) { return path.join(await mkdtemp(path.join(tmpdir(), "opencode-smart-picker-")), name) } @@ -107,6 +121,33 @@ function createSourceDb(file: string) { 4, JSON.stringify({ type: "text", text: "Later attachment mention: resume.pdf should stay searchable" }), ) + insertMessage.run( + "msg_one_assistant", + "ses_one", + 5, + 5, + JSON.stringify({ role: "assistant", agent: "build", model: { providerID: "local", modelID: "test" } }), + ) + insertPart.run( + "prt_one_assistant_text", + "msg_one_assistant", + "ses_one", + 6, + 6, + JSON.stringify({ type: "text", text: "assistant-only-token should not be searchable" }), + ) + insertPart.run( + "prt_one_assistant_tool", + "msg_one_assistant", + "ses_one", + 7, + 7, + JSON.stringify({ + type: "tool", + tool: "bash", + state: { status: "completed", title: "tool-only-token", output: "tool output should not be searchable" }, + }), + ) insertSession.run("ses_two", "two", "proj", "/repo", "packages/cli", "Another title", 1, 9) insertMessage.run( @@ -197,6 +238,10 @@ describe("search integration", () => { const sidecar = await SearchSidecar.open(config(searchDb)) sidecar.rebuildCorpus(corpus) const results = sidecar.searchFts("subsetgeneratefamilies") + const assistantResults = sidecar.searchFts("assistant-only-token") + const toolResults = sidecar.searchFts("tool-only-token") + const titleResults = sidecar.searchFts("Boring") + const pathResults = sidecar.searchFts("packages") const snippets = sidecar.snippetsForSessions(["ses_one"]) const needsCurrentReindex = sidecar.needsReindex(corpus.map((entry) => entry.session)) const needsMissingReindex = sidecar.needsReindex([ @@ -214,11 +259,199 @@ describe("search integration", () => { sidecar.close() expect(results[0]?.sessionID).toBe("ses_one") + expect(assistantResults).toEqual([]) + expect(toolResults).toEqual([]) + expect(titleResults[0]?.sessionID).toBe("ses_one") + expect(pathResults).toEqual([]) + expect(snippets.get("ses_one")).toContain("Boring title") expect(snippets.get("ses_one")).toContain("resume.pdf") + expect(snippets.get("ses_one")).not.toContain("assistant-only-token") + expect(snippets.get("ses_one")).not.toContain("tool-only-token") expect(needsCurrentReindex).toBe(false) expect(needsMissingReindex).toBe(true) }) + test("rebuilds the same sidecar repeatedly without corrupting the FTS index", async () => { + const sourceDb = await tempPath("opencode.db") + const searchDb = await tempPath("opencode-search.db") + createSourceDb(sourceDb) + const corpus = readSourceCorpusFromDb(sourceDb) + + // Regression: external-content FTS rows must mirror the content table. + // Mismatched values previously made the second rebuild throw + // SQLITE_CORRUPT_VTAB and forced a destructive cache reset. + const sidecar = await SearchSidecar.open(config(searchDb)) + try { + sidecar.rebuildCorpus(corpus) + sidecar.rebuildCorpus(corpus) + expect(sidecar.searchFts("subsetgeneratefamilies")[0]?.sessionID).toBe("ses_one") + } finally { + sidecar.close() + } + + const reopened = await SearchSidecar.open(config(searchDb)) + try { + reopened.rebuildCorpus(corpus) + expect(reopened.searchFts("subsetgeneratefamilies")[0]?.sessionID).toBe("ses_one") + reopened.db.prepare("insert into document_fts(document_fts) values ('integrity-check')").run() + } finally { + reopened.close() + } + }) + + test("applies incremental session updates and removals through indexDelta", async () => { + const sourceDb = await tempPath("opencode.db") + const searchDb = await tempPath("opencode-search.db") + createSourceDb(sourceDb) + const corpus = readSourceCorpusFromDb(sourceDb) + + const sidecar = await SearchSidecar.open(config(searchDb)) + try { + sidecar.rebuildCorpus(corpus) + expect(sidecar.indexDelta(corpus.map((entry) => entry.session)).kind).toBe("none") + + const two = corpus.find((entry) => entry.session.id === "ses_two")! + const updatedTwo: SourceSessionCorpus = { + session: { ...two.session, time: { created: two.session.time.created, updated: 99 } }, + messages: [ + { + info: two.messages[0]!.info, + parts: [{ ...two.messages[0]!.parts[0]!, text: "Fresh incremental zebrastripe token" } as Part], + }, + ], + } + + const delta = sidecar.indexDelta([corpus[0]!.session, updatedTwo.session]) + expect(delta).toEqual({ kind: "incremental", changed: ["ses_two"], removed: [] }) + + sidecar.upsertSessions([updatedTwo], [], false) + expect(sidecar.searchFts("zebrastripe")[0]?.sessionID).toBe("ses_two") + expect(sidecar.searchFts("Unrelated")).toEqual([]) + expect(sidecar.indexDelta([corpus[0]!.session, updatedTwo.session]).kind).toBe("none") + + const removalDelta = sidecar.indexDelta([updatedTwo.session]) + expect(removalDelta).toEqual({ kind: "incremental", changed: [], removed: ["ses_one"] }) + sidecar.upsertSessions([], ["ses_one"], false) + expect(sidecar.searchFts("subsetgeneratefamilies")).toEqual([]) + expect(sidecar.indexDelta([updatedTwo.session]).kind).toBe("none") + + // Row-level FTS deletes against external content must leave a + // consistent index. + sidecar.db.prepare("insert into document_fts(document_fts) values ('integrity-check')").run() + } finally { + sidecar.close() + } + }) + + test("does not perpetually reindex sessions that produce no documents", async () => { + const searchDb = await tempPath("opencode-search.db") + const sidecar = await SearchSidecar.open(config(searchDb)) + try { + const bare: SourceSessionCorpus = { + session: { + id: "ses_bare", + slug: "bare", + projectID: "proj", + version: "test", + directory: "/repo", + title: "", + time: { created: 1, updated: 5 }, + }, + messages: [], + } + sidecar.upsertSessions([bare], [], false) + expect(sidecar.hasDocuments()).toBe(false) + expect(sidecar.indexDelta([bare.session]).kind).toBe("none") + } finally { + sidecar.close() + } + }) + + test("preserves vector_state across reopen and marks it stale after rebuilds", async () => { + const searchDb = await tempPath("opencode-search.db") + const cfg = config(searchDb, { disableVector: false }) + + let sidecar = await SearchSidecar.open(cfg) + expect(sidecar.getMeta("vector_state")?.value).toBe("unavailable") + sidecar.setMeta("vector_state", "enabled") + sidecar.close() + + // Regression: migrate() previously reset vector_state to "unavailable" + // on every open, killing vector search after the first query. + sidecar = await SearchSidecar.open(cfg) + try { + expect(sidecar.getMeta("vector_state")?.value).toBe("enabled") + + const sourceDb = await tempPath("opencode.db") + createSourceDb(sourceDb) + sidecar.rebuildCorpus(readSourceCorpusFromDb(sourceDb)) + expect(sidecar.getMeta("vector_state")?.value).toBe("stale") + } finally { + sidecar.close() + } + }) + + test("resets legacy schema-version sidecar caches on open", async () => { + const searchDb = await tempPath("opencode-search.db") + const legacy = new Database(searchDb) + legacy.exec(` + create table index_meta(key text primary key, value text not null); + create table document(rowid integer primary key, doc_id text unique not null, session_id text not null, text text not null); + create virtual table document_fts using fts5(title, directory, path, role, part_type, text, content='document', content_rowid='rowid'); + insert into index_meta(key, value) values ('schema_version', '1'); + `) + legacy.close() + + const sidecar = await SearchSidecar.open(config(searchDb)) + try { + expect(sidecar.getMeta("schema_version")?.value).toBe(SIDECAR_SCHEMA_VERSION) + expect(sidecar.hasDocuments()).toBe(false) + } finally { + sidecar.close() + } + }) + + test("ranks purely semantic candidates when keyword search has no hits", () => { + const result = blendHybridScores({ + alpha: 0.5, + vectorAvailable: true, + keyword: [], + vector: [ + { sessionID: "ses_sem_strong", score: 0, vectorScore: 0.9 }, + { sessionID: "ses_sem_weak", score: 0, vectorScore: 0.2 }, + ], + }) + + expect(result.ranked.map((row) => row.sessionID)).toEqual(["ses_sem_strong", "ses_sem_weak"]) + expect(result.ranked[0]!.score).toBeGreaterThan(result.ranked[1]!.score) + expect(result.diagnostics).toEqual([]) + }) + + test("recovers a corrupt plugin-owned sidecar cache", async () => { + const searchDb = await tempPath("opencode-search.db") + await writeFile(searchDb, "not sqlite") + + const sidecar = await SearchSidecar.open(config(searchDb)) + try { + expect(sidecar.hasDocuments()).toBe(false) + sidecar.replaceDocuments([ + { + docID: "doc:one", + sessionID: "ses_one", + chunkIndex: 0, + synthetic: true, + ignored: false, + text: "Recovered title", + metadata: {}, + sourceHash: "hash", + }, + ]) + expect(sidecar.searchFts("Recovered")[0]?.sessionID).toBe("ses_one") + } finally { + sidecar.close() + } + }) + test("runs fzf mode through an executable and parses NUL-delimited session IDs", async () => { const fzf = await tempPath("fake-fzf") await writeFile( @@ -258,6 +491,7 @@ process.stdout.write(output + (input.includes("\\0") ? "\\0" : "\\n")) title: "Semantic session search", time: { created: 1, updated: 2 }, }, + snippet: "user message mentions semantic lookup", }, { session: { @@ -270,10 +504,62 @@ process.stdout.write(output + (input.includes("\\0") ? "\\0" : "\\n")) time: { created: 1, updated: 2 }, }, }, + { + session: { + id: "ses_three", + slug: "three", + projectID: "proj", + version: "test", + directory: "/repo", + title: "Semantic title only", + time: { created: 1, updated: 2 }, + }, + }, + ], + }) + + expect(result).toEqual({ status: "ok", sessionIDs: ["ses_one", "ses_three"] }) + }) + + test("runs installed fzf against real tab-delimited picker candidates", async () => { + const health = await checkFzf(config(await tempPath("search.db"), { mode: "fzf" })) + if (health.state !== "available" || !health.bin) { + console.warn(`skipping installed fzf candidate test: ${health.message ?? health.state}`) + return + } + + const result = await runFzfSearch({ + bin: health.bin, + query: "occurred", + candidates: [ + { + session: { + id: "ses_real_match", + slug: "real-match", + projectID: "proj", + version: "test", + directory: "/repo", + title: "Title should not be searched", + time: { created: 1, updated: 3 }, + }, + snippet: "user text says the error occurred in the polling pipeline", + }, + { + session: { + id: "ses_title_only", + slug: "title-only", + projectID: "proj", + version: "test", + directory: "/repo", + title: "occurred only in title", + time: { created: 1, updated: 2 }, + }, + snippet: "user text talks about a different thing", + }, ], }) - expect(result).toEqual({ status: "ok", sessionIDs: ["ses_one"] }) + expect(result).toEqual({ status: "ok", sessionIDs: ["ses_real_match", "ses_title_only"] }) }) test("reports TUI search modes and dependency readiness from real local checks", async () => { @@ -326,27 +612,24 @@ process.stdout.write(input.split(/\\n/).filter((line) => line.includes("a")).joi }) test("validates llama.cpp-compatible embedding responses from a real local HTTP server", async () => { - const server = Bun.serve({ - port: 0, - fetch: async (request) => { - const url = new URL(request.url) - if (url.pathname === "/health" || url.pathname === "/v1/health") { - return Response.json({ status: "ok" }) - } - if (url.pathname === "/v1/embeddings") { - const body = (await request.json()) as { input: string[] } - return Response.json({ - object: "list", - model: "fake", - data: body.input.map((_, index) => ({ - object: "embedding", - index, - embedding: [index + 0.1, index + 0.2, index + 0.3], - })), - }) - } - return new Response("not found", { status: 404 }) - }, + const server = serveOnAvailablePort(async (request) => { + const url = new URL(request.url) + if (url.pathname === "/health" || url.pathname === "/v1/health") { + return Response.json({ status: "ok" }) + } + if (url.pathname === "/v1/embeddings") { + const body = (await request.json()) as { input: string[] } + return Response.json({ + object: "list", + model: "fake", + data: body.input.map((_, index) => ({ + object: "embedding", + index, + embedding: [index + 0.1, index + 0.2, index + 0.3], + })), + }) + } + return new Response("not found", { status: 404 }) }) servers.push(server) From 5a62b201edc196a5cc06d0e6634e3624135f5236 Mon Sep 17 00:00:00 2001 From: Techie5879 Date: Fri, 19 Jun 2026 02:12:21 +0530 Subject: [PATCH 2/6] Add fuzzy search performance benchmarks --- AGENTS.md | 18 +- docs/upstream-verification-2026-06-13.md | 60 ++++++ package.json | 4 +- test/performance/fuzzy.performance.test.ts | 240 +++++++++++++++++++++ 4 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 docs/upstream-verification-2026-06-13.md create mode 100644 test/performance/fuzzy.performance.test.ts diff --git a/AGENTS.md b/AGENTS.md index a3a029a..d4572a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,11 @@ This repo is a prototype OpenCode TUI plugin that replaces the built-in session - Use `bun install` for dependencies. - Use `bun run typecheck` for TypeScript validation. -- Use `bun run test` for the repo check suite. Right now this intentionally runs `tsc --noEmit`. +- Use `bun run test` for the repo check suite: `tsc --noEmit` plus the integration tests under `test/integration`. - `bunfig.toml` ignores `upstream/**` for bare Bun test discovery so submodule tests are not collected accidentally. - Use `bun run dev:opencode -- ` to launch the plugin in upstream OpenCode without touching the user's real OpenCode config/state. +- Use `bun run test:perf` for deterministic fuzzy-search performance checks. +- Use `bun run test:perf:live` for opt-in readonly fuzzy-search benchmarks against the real local OpenCode database. ## Disposable OpenCode Plugin Testing @@ -42,6 +44,18 @@ Current one-time local disposable theme copied for this machine: `lucent-orng` i Because the dev run isolates XDG data/state too, it will not show the user's real OpenCode sessions. That is intentional for safe plugin testing. Do not remove that isolation unless a task explicitly asks to test against real local OpenCode data. +## Real Local OpenCode Performance Testing + +Only use the user's real OpenCode install when a task explicitly asks for real local database or live TUI comparison. Treat the user's global OpenCode configuration and status/state as read-only: do not edit `~/.config/opencode`, do not change installed plugin config, and do not mutate real session data to set up a test. + +Allowed explicit workflows: + +- Run readonly performance tests with `bun run test:perf:live`. The live benchmark requires `OPENCODE_SMART_PICKER_PERF_WORKSPACE` to point at a busy local workspace; it reads the real OpenCode SQLite database in readonly mode, filters to that workspace, and writes benchmark sidecar DBs only under the system temp directory. +- Use an existing tmux session/window or create a new tmux window to launch real `opencode ` for manual TUI timing when the plugin is already installed. Keep any manual interaction limited to opening the session picker and running searches needed for performance measurement. +- For disposable plugin isolation, prefer `bun run dev:opencode -- `. For real installed-plugin comparison, do not copy or rewrite config; launch the existing real OpenCode setup as-is. + +The live fuzzy-search benchmark should choose terms from the local OpenCode DB at runtime to cover high-hit and low-hit behavior. Do not commit real workspace paths, record IDs, session IDs, customer/project identifiers, or sampled query terms into this public repo. + ## Boundaries - Do not run tests inside `upstream/opencode` or `upstream/opentui` as part of this repo's normal checks. @@ -60,4 +74,6 @@ Because the dev run isolates XDG data/state too, it will not show the user's rea - Search implementation lives under `src/search/`. Keep `src/tui.tsx` as a thin OpenTUI dialog wrapper around `searchSessions`, the mode selector, dependency status chips, and the session result list. - The only OpenCode override is the built-in session picker command path and its related dialog. Do not add extra command palette entries, routes, keybind overrides, storage mutations, or config reads unless a task explicitly asks for them. - Use OpenCode SDK/plugin types for OpenCode-owned data. Local types are allowed only for plugin-owned sidecar documents, ranking diagnostics, dependency health, and search configuration. +- The sidecar cache uses one shared connection (`openSharedSidecar`) with WAL + busy_timeout, and incremental session-level reindexing through `indexDelta`/`upsertSessions`. Do not reintroduce per-search `SearchSidecar.open` calls, per-keystroke meta writes, or full-corpus rebuilds on every invalidation event. +- External-content FTS5 rows must mirror the `document` content table exactly; clearing goes through the FTS5 `delete-all` command. Mismatched values corrupt the index (see the rebuild regression test). - Keep `src/tui.tsx` lean: local code should exist only where OpenCode does not expose the native picker internals through the plugin API, or where the semantic search feature needs plugin-owned sidecar behavior. diff --git a/docs/upstream-verification-2026-06-13.md b/docs/upstream-verification-2026-06-13.md new file mode 100644 index 0000000..93b63fb --- /dev/null +++ b/docs/upstream-verification-2026-06-13.md @@ -0,0 +1,60 @@ +# Upstream Verification - 2026-06-13 + +Verified this plugin's API usage against a fresh shallow clone of +`anomalyco/opencode` (HEAD `73dbd8a`, 2026-06-12) and current OpenCode/OpenTUI +docs. Clone lives under the system temp directory only; do not vendor it. + +## Plugin API surface + +Every member this plugin uses exists on current main with the same shape: + +- `api.keymap.registerLayer` with `commands` (namespace/name/title/category/ + suggested/slashName/slashAliases/run are open-ended command props read by + the palette and slash handling) and `bindings`. +- `api.ui.DialogSelect` props: `title`, `placeholder`, `options`, `skipFilter`, + `onFilter`, `onMove`, `onSelect` (plus `flat`, `current` we do not use). + Internal-only props (`actions`, `footerHints`, `gutter`) are stripped by the + plugin adapter, so per-row actions like the native picker's rename/delete + are not expressible through the public `DialogSelect`. +- `api.ui.dialog.replace/clear/setSize`, `api.ui.toast`. +- `api.event.on`: all seven invalidation event names used here exist exactly + (`session.updated`, `session.deleted`, `message.updated`, `message.removed`, + `message.part.updated`, `message.part.removed`, `session.compacted`). + Handlers also receive an undocumented second `{ directory, workspace }` + metadata argument at runtime. +- `api.client.app.log({ service, level, message, extra })`. +- `api.client.session.list({ roots, search })` and + `api.client.session.messages({ sessionID })`. +- `api.state.ready/session.count/session.messages/part`, + `api.lifecycle.signal/onDispose`, `api.tuiConfig.keybinds.get`, + `api.route.navigate("session", { sessionID })`, and all theme tokens used in + `src/tui.tsx`. + +The legacy `api.command` path is deprecated ("Remove in v2"); +`keymap.registerLayer` is the current recommended path. `@opencode-ai/plugin` +versions in lockstep with opencode. + +## Storage facts + +- OpenCode core has no FTS5 anywhere. The sidecar FTS index is plugin-owned + with no upstream pattern to mirror. +- `session.list`'s `search` parameter is a `LIKE %term%` match on the session + title only, ordered by `time_updated desc`. Message/part content is never + searched server-side, which is why this plugin keeps its own index. +- Session/message/part SQLite schema matches the assumptions in + `src/search/source-db.ts` (the real `session` table has more columns than we + read; all the ones we read exist). +- Channel DB naming: `opencode.db` for `latest`/`beta`/`prod` or when + `OPENCODE_DISABLE_CHANNEL_DB` is set, else `opencode-.db`. + Caveat: `OPENCODE_CHANNEL` is a compile-time define in real builds; setting + it at runtime only matters for source runs, which default to `local` anyway. + +## OpenTUI notes + +- All OpenTUI usages in `src/tui.tsx` are valid on current versions, but + `scrollbarOptions={{ visible: false }}`, `wrapMode`, span `style={{ fg, bg }}`, + and `KeyEvent.preventDefault/stopPropagation` are source-supported yet + undocumented surfaces - re-verify them on OpenTUI upgrades. +- 0.2.x -> 0.4.x: no breaking changes flagged for scrollbox/box/text/ + useKeyboard; 0.4.0 swapped to native yoga-layout, so do a visual smoke test + when bumping past 0.3.x. `viewportCulling` defaults to true since ~0.3.2. diff --git a/package.json b/package.json index 6d36de3..216e880 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "scripts": { "dev:opencode": "bun run scripts/dev-opencode.ts", "typecheck": "tsc --noEmit", - "test": "bun run typecheck && bun test test/integration" + "test": "bun run typecheck && bun test test/integration", + "test:perf": "bun test test/performance", + "test:perf:live": "OPENCODE_SMART_PICKER_LIVE_PERF=1 bun test test/performance" }, "dependencies": { "@opencode-ai/plugin": "^1.14.50", diff --git a/test/performance/fuzzy.performance.test.ts b/test/performance/fuzzy.performance.test.ts new file mode 100644 index 0000000..accfa3b --- /dev/null +++ b/test/performance/fuzzy.performance.test.ts @@ -0,0 +1,240 @@ +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, expect, test } from "bun:test" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2" +import { resolveSourceDbPath } from "../../src/search/config" +import { extractSessionDocuments } from "../../src/search/extractor" +import { SearchSidecar } from "../../src/search/sidecar" +import type { SearchConfig, SourceSessionCorpus } from "../../src/search/types" +import { readSourceCorpusFromDb } from "../../src/search/source-db" + +const SYNTHETIC_WORKSPACE = "/tmp/opencode-smart-picker-perf-workspace" + +type FuzzyCase = { + query: string + expectedSessionMatches: number + expectedPartMentions: number +} + +type BenchmarkQuery = { + label: string + query: string +} + +const syntheticCases: FuzzyCase[] = [ + { query: "firestore", expectedSessionMatches: 360, expectedPartMentions: 2_400 }, + { query: "record_id", expectedSessionMatches: 260, expectedPartMentions: 1_300 }, + { query: "project-alpha-123456", expectedSessionMatches: 180, expectedPartMentions: 1_440 }, + { query: "11111111-2222-4333-8444-555555555555", expectedSessionMatches: 2, expectedPartMentions: 12 }, + { query: "zzzzzzzzzzzzzzzzzzzzzzzz", expectedSessionMatches: 1, expectedPartMentions: 1 }, +] + +async function tempDb(name: string) { + return path.join(await mkdtemp(path.join(tmpdir(), "opencode-smart-picker-perf-")), name) +} + +function config(searchDbPath: string): SearchConfig { + return { + mode: "hybrid", + alpha: 0.5, + searchDbPath, + embedBaseUrl: "http://127.0.0.1:8081", + disableVector: true, + documentPrefix: "search_document: ", + queryPrefix: "search_query: ", + } +} + +function syntheticCorpus(cases: FuzzyCase[]): SourceSessionCorpus[] { + const totalSessions = 420 + const corpus: SourceSessionCorpus[] = [] + for (let index = 0; index < totalSessions; index += 1) { + const sessionID = `ses_perf_${String(index).padStart(4, "0")}` + const session: Session = { + id: sessionID, + slug: sessionID, + projectID: "perf-project", + version: "test", + directory: SYNTHETIC_WORKSPACE, + title: `Performance fixture ${index}`, + time: { created: index, updated: 10_000 + index }, + } + const messages: SourceSessionCorpus["messages"] = [] + for (const [caseIndex, item] of cases.entries()) { + if (index >= item.expectedSessionMatches) continue + const parts: Part[] = [] + const mentions = Math.max(1, Math.floor(item.expectedPartMentions / item.expectedSessionMatches)) + for (let mention = 0; mention < mentions; mention += 1) { + parts.push({ + id: `prt_${caseIndex}_${mention}`, + sessionID, + messageID: `msg_${caseIndex}`, + type: "text", + text: `OpenCode benchmark ${item.query} Firestore record behavior ${mention}`, + } as Part) + } + messages.push({ + info: { + id: `msg_${caseIndex}`, + sessionID, + role: "user", + time: { created: caseIndex }, + } as Message, + parts, + }) + } + if (!messages.length) { + messages.push({ + info: { id: "msg_control", sessionID, role: "user", time: { created: 0 } } as Message, + parts: [ + { + id: "prt_control", + sessionID, + messageID: "msg_control", + type: "text", + text: "control conversation with unrelated terminal cleanup", + } as Part, + ], + }) + } + corpus.push({ session, messages }) + } + return corpus +} + +function textForTokenSampling(corpus: SourceSessionCorpus[]) { + return corpus.map((entry) => ({ + sessionID: entry.session.id, + text: extractSessionDocuments(entry.session, entry.messages) + .map((document) => document.text) + .join("\n"), + })) +} + +function discoverLiveBenchmarkQueries(corpus: SourceSessionCorpus[]): BenchmarkQuery[] { + const byToken = new Map; mentions: number }>() + const stopWords = new Set([ + "assistant", + "directory", + "master", + "opencode", + "prompt", + "role", + "session", + "text", + "title", + "users", + ]) + for (const entry of textForTokenSampling(corpus)) { + const seenInSession = new Set() + for (const match of entry.text.matchAll(/[A-Za-z0-9]{6,80}/g)) { + const token = match[0] + const key = token.toLowerCase() + if (stopWords.has(key) || /^gA{4,}/i.test(token) || /^ses/.test(key) || /^msg/.test(key) || /^prt/.test(key)) { + continue + } + const stat = byToken.get(key) ?? { sessions: new Set(), mentions: 0 } + stat.mentions += 1 + if (!seenInSession.has(key)) { + stat.sessions.add(entry.sessionID) + seenInSession.add(key) + } + byToken.set(key, stat) + } + } + + const rows = [...byToken.entries()].map(([query, stat]) => ({ + query, + sessions: stat.sessions.size, + mentions: stat.mentions, + })) + const highHit = rows + .filter((row) => row.sessions >= Math.max(20, corpus.length * 0.2)) + .sort((a, b) => b.sessions - a.sessions || b.mentions - a.mentions) + .slice(0, 4) + const lowHit = rows + .filter((row) => row.sessions <= 2 && row.mentions <= 10 && row.query.length >= 12) + .sort((a, b) => a.sessions - b.sessions || a.mentions - b.mentions || b.query.length - a.query.length) + .slice(0, 4) + + return [ + ...highHit.map((row, index) => ({ label: `live-high-hit-${index + 1}`, query: row.query })), + ...lowHit.map((row, index) => ({ label: `live-low-hit-${index + 1}`, query: row.query })), + ] +} + +async function benchmarkCorpus(label: string, corpus: SourceSessionCorpus[], queries: BenchmarkQuery[]) { + const sidecar = await SearchSidecar.open(config(await tempDb(`${label}.db`))) + try { + const rebuildStarted = performance.now() + sidecar.rebuildCorpus(corpus) + const rebuildMs = performance.now() - rebuildStarted + + const results = queries.map(({ label: queryLabel, query }) => { + const started = performance.now() + const matches = sidecar.searchFts(query) + return { + label: queryLabel, + matches: matches.length, + durationMs: performance.now() - started, + } + }) + + console.info( + JSON.stringify({ + label, + sessions: corpus.length, + rebuildMs: Math.round(rebuildMs), + queries: results.map((result) => ({ + ...result, + durationMs: Number(result.durationMs.toFixed(2)), + })), + }), + ) + + return { rebuildMs, results } + } finally { + sidecar.close() + } +} + +describe("fuzzy search performance", () => { + test("keeps deterministic heavy and rare keyword FTS searches bounded", async () => { + const corpus = syntheticCorpus(syntheticCases) + const { results } = await benchmarkCorpus( + "synthetic-busy-workspace", + corpus, + syntheticCases.map((item) => ({ label: item.query, query: item.query })), + ) + + const maxQueryMs = Number(process.env.OPENCODE_SMART_PICKER_SYNTHETIC_FUZZY_MAX_MS ?? 150) + for (const result of results) { + expect(result.matches).toBeGreaterThan(0) + expect(result.durationMs).toBeLessThan(maxQueryMs) + } + }) + + const liveWorkspace = process.env.OPENCODE_SMART_PICKER_PERF_WORKSPACE + const liveTest = process.env.OPENCODE_SMART_PICKER_LIVE_PERF === "1" && liveWorkspace ? test : test.skip + liveTest( + "measures live local OpenCode fuzzy search behavior", + async () => { + const sourceDb = process.env.OPENCODE_SMART_PICKER_SOURCE_DB ?? resolveSourceDbPath() + const corpus = readSourceCorpusFromDb(sourceDb, { + directory: liveWorkspace, + }) + expect(corpus.length).toBeGreaterThan(0) + + const liveQueries = discoverLiveBenchmarkQueries(corpus) + expect(liveQueries.length).toBeGreaterThanOrEqual(2) + + const { results } = await benchmarkCorpus("live-local-workspace", corpus, liveQueries) + const maxQueryMs = Number(process.env.OPENCODE_SMART_PICKER_LIVE_FUZZY_MAX_MS ?? 1_000) + for (const result of results) { + expect(result.durationMs).toBeLessThan(maxQueryMs) + } + }, + 30_000, + ) +}) From b1d9a7d925c2649fda849864428481a503b94f47 Mon Sep 17 00:00:00 2001 From: Techie5879 Date: Fri, 19 Jun 2026 03:08:16 +0530 Subject: [PATCH 3/6] Add session status indicators to picker --- bun.lock | 67 +++++++++++++++++++++----------------- package.json | 11 ++++--- src/search/opencode-api.ts | 8 ++++- src/tui.tsx | 51 ++++++++++++++++++++++++----- upstream/fzf | 2 +- upstream/llama.cpp | 2 +- upstream/opencode | 2 +- upstream/opentui | 2 +- upstream/sqlite-vec | 2 +- 9 files changed, 99 insertions(+), 48 deletions(-) diff --git a/bun.lock b/bun.lock index a4f24f4..1cf55ef 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,12 @@ "": { "name": "opencode-smart-session-picker", "dependencies": { - "@opencode-ai/plugin": "^1.14.50", - "@opencode-ai/sdk": "^1.14.50", - "@opentui/core": "^0.2.9", - "@opentui/keymap": "^0.2.9", - "@opentui/solid": "^0.2.9", + "@opencode-ai/plugin": "^1.17.8", + "@opencode-ai/sdk": "^1.17.8", + "@opentui/core": "^0.3.4", + "@opentui/keymap": "^0.3.4", + "@opentui/solid": "^0.3.4", + "opentui-spinner": "0.0.7", "solid-js": "^1.9.12", "sqlite-vec": "^0.1.9", }, @@ -86,39 +87,43 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.50", "", { "dependencies": { "@opencode-ai/sdk": "1.14.50", "effect": "4.0.0-beta.65", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.9", "@opentui/keymap": ">=0.2.9", "@opentui/solid": ">=0.2.9" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2D4k6r8IFaAajEmezOZ3UKmRRGqEw2YzqotH5zLGT434yKtabduEsQgXa0fWlASut4FVT3JURhq5LqeBUV/k4g=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.17.8", "", { "dependencies": { "@opencode-ai/sdk": "1.17.8", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.4", "@opentui/keymap": ">=0.3.4", "@opentui/solid": ">=0.3.4" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-pkmnYQz5d+xf0h6fAjgplSSJKLqgYKOXr+x6y40GRPdW+/IfndFkMGq7CDsG2SieGD84qv4zYDMyolGo06IMpw=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.50", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-IrTKTFviR4Tj+u0BI8h8XgXIvEpxwkkHqBj6E0aEc4DBErpS3qh2Lkp1xt0OdtCYiLE3wZ1bAIiHOiO66H/7TA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.8", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw=="], - "@opentui/core": ["@opentui/core@0.2.9", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.9", "@opentui/core-darwin-x64": "0.2.9", "@opentui/core-linux-arm64": "0.2.9", "@opentui/core-linux-x64": "0.2.9", "@opentui/core-win32-arm64": "0.2.9", "@opentui/core-win32-x64": "0.2.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Kmeqi+yiDau+P45xDeX08GS50FK917qVwuPTN7HGxsQ9Byt7Iifq/6OMiSnFULBzoZtECdKLgQF1XwLsNm1wig=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-D2ne8Xgyrg71L/9lF7vPh30Sxz6+3yAqpT0m87WiI+040J7sQEyK3YM/7w5JKuVemQ4H54HSPjofrUHjfibjoQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ymbbt/wN/vgB8g+kbHospJclVKHq6cdgfEYg9qgsSHp2vqMFBqlQQ692MS3BcZfX9jrKROK7NvC6Hj37X5K/7Q=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0RIrVe4+42oELHtSJBaaYhngUeMKwSeqfdtKeSwEFwCzrqrNXxCpXQdOo8QvjOKGgng4Smn6O6KM8sgCj4SSPQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fjCZP1IOLWm68FYl2PRzFg1vfu226FPfiJsdNtLbhaYF2uEZOB/v1BQph21OKnB7GC7X8GQatvhM5sS3DQ2MSQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-u8SP3u2QEJqcGIULYZ7Lkht9ss7wcN4/LnMuqt9rPOiCduFn/VW4r8lQCftZ6DRSqyoP9mJ1xLzOSFl98UYyEw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-un7iSy9XHLwa6ouVpUj3eEGnXfPG50OMUJ2Dt30Jvn2vhNwIU2VO4RGx06l5OUD6GGVpHb0RqmG/384oo9i+HA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/keymap": ["@opentui/keymap@0.2.9", "", { "dependencies": { "@opentui/core": "0.2.9" }, "peerDependencies": { "@opentui/react": "0.2.9", "@opentui/solid": "0.2.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yCc6L0Jqa8aVaNAVniTV5bNygJayUE6mxWfaBQY5VV5QwsZemXSeQQc4vP2eetH4Rrm1gGA59gLP+zh6+s5fvw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/solid": ["@opentui/solid@0.2.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qpNSCELxRvBAx8Zneqz46FYYTvJNFjDvhqzAAZRNoaHathfU6X6iPxWMUqP/9ls5VcHFW1TDJdgtpsq1N/nHMQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], + + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], + + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -148,6 +153,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -160,7 +167,7 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], + "effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], "electron-to-chromium": ["electron-to-chromium@1.5.349", "", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="], @@ -172,7 +179,7 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], @@ -194,7 +201,7 @@ "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], - "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], @@ -220,9 +227,9 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], @@ -230,6 +237,8 @@ "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -296,7 +305,7 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "uuid": ["uuid@13.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], @@ -304,7 +313,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], diff --git a/package.json b/package.json index 216e880..18445f5 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,12 @@ "test:perf:live": "OPENCODE_SMART_PICKER_LIVE_PERF=1 bun test test/performance" }, "dependencies": { - "@opencode-ai/plugin": "^1.14.50", - "@opencode-ai/sdk": "^1.14.50", - "@opentui/core": "^0.2.9", - "@opentui/keymap": "^0.2.9", - "@opentui/solid": "^0.2.9", + "@opencode-ai/plugin": "^1.17.8", + "@opencode-ai/sdk": "^1.17.8", + "@opentui/core": "^0.3.4", + "@opentui/keymap": "^0.3.4", + "@opentui/solid": "^0.3.4", + "opentui-spinner": "0.0.7", "solid-js": "^1.9.12", "sqlite-vec": "^0.1.9" }, diff --git a/src/search/opencode-api.ts b/src/search/opencode-api.ts index f5f54fa..23ddf48 100644 --- a/src/search/opencode-api.ts +++ b/src/search/opencode-api.ts @@ -4,12 +4,18 @@ import type { SourceSessionCorpus } from "./types" const CORPUS_FETCH_CONCURRENCY = 8 +function clientErrorMessage(error: unknown, fallback: string) { + if (typeof error === "string") return error + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message + return fallback +} + export async function listOpenCodeSessions(api: TuiPluginApi, query?: string) { const response = await api.client.session.list({ roots: true, search: query?.trim() || undefined, }) - if (response.error) throw new Error(typeof response.error === "string" ? response.error : "Failed to list sessions") + if (response.error) throw new Error(clientErrorMessage(response.error, "Failed to list sessions")) return response.data ?? [] } diff --git a/src/tui.tsx b/src/tui.tsx index 54c20b0..8d03b11 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,8 +1,9 @@ /** @jsxImportSource @opentui/solid */ import { TextAttributes, type RGBA, type ScrollBoxRenderable } from "@opentui/core" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js" -import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import "opentui-spinner/solid" import { resolveSearchConfig } from "./search/config" import { dependencySnapshot, elapsedMs, errorFields, logEvent, nextLogID, nowMs, queryStats } from "./search/logging" import { PREVIEW_CONTEXT_LINES, loadSessionPreview, type SessionPreview } from "./search/preview" @@ -12,6 +13,12 @@ import type { DependencyState, SearchDependencyStatus, SearchEnvironmentStatus, const PLUGIN_ID = "local.smart-session-picker" const SEARCH_DEBOUNCE_MS = 150 +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + +type SessionStatus = ReturnType +type SessionOption = TuiDialogSelectOption & { + gutter?: () => JSX.Element +} function dateCategory(updated: number) { const date = new Date(updated) @@ -50,6 +57,24 @@ function shortName(name: SearchDependencyStatus["name"]) { return name } +function isWorkingStatus(status: SessionStatus) { + return status?.type === "busy" || status?.type === "retry" +} + +function Spinner(props: { api: TuiPluginApi; children?: JSX.Element; color?: RGBA }) { + const theme = props.api.theme.current + const color = () => props.color ?? theme.textMuted + return ( + ⋯ {props.children}}> + + + + {props.children} + + + + ) +} /** Derive a summary color for a mode based on its dependency health. */ function modeLabelColor( @@ -479,12 +504,21 @@ function SmartSessionDialog(props: { api: TuiPluginApi }) { }) const options = () => - sessions().map((s) => ({ - title: s.title, - value: s.id, - category: dateCategory(s.updated), - footer: timeFooter(s.updated), - })) + sessions().map((s): SessionOption => { + const status = props.api.state.session.status(s.id) + return { + title: s.title, + value: s.id, + category: dateCategory(s.updated), + footer: timeFooter(s.updated), + gutter: isWorkingStatus(status) ? () => : undefined, + } + }) + + const currentSessionID = () => { + const route = props.api.route.current + return route.name === "session" && typeof route.params?.sessionID === "string" ? route.params.sessionID : undefined + } const { DialogSelect } = props.api.ui @@ -498,6 +532,7 @@ function SmartSessionDialog(props: { api: TuiPluginApi }) { options={options()} skipFilter={true} onFilter={updateQuery} + current={currentSessionID()} onMove={(opt: { value: string }) => { setSelectedSessionID(opt.value) loadPreview(opt.value) diff --git a/upstream/fzf b/upstream/fzf index 263eb47..3c9965a 160000 --- a/upstream/fzf +++ b/upstream/fzf @@ -1 +1 @@ -Subproject commit 263eb4732fc6268f9fb35cffb634903ea8e2a26b +Subproject commit 3c9965a61a842ef54e976c7195b985ee43a3e776 diff --git a/upstream/llama.cpp b/upstream/llama.cpp index 320a6a4..3a3edc9 160000 --- a/upstream/llama.cpp +++ b/upstream/llama.cpp @@ -1 +1 @@ -Subproject commit 320a6a44a5b1de6a074ba781e65f5fd79fb4051a +Subproject commit 3a3edc9ac65cca79584ca497be41d70c75a58ba8 diff --git a/upstream/opencode b/upstream/opencode index 27ac53a..355a0bc 160000 --- a/upstream/opencode +++ b/upstream/opencode @@ -1 +1 @@ -Subproject commit 27ac53aaacc677b1401c4e75ca7a7dadf8b2c349 +Subproject commit 355a0bcf5bb5e6c7baa271a4b2439a40f286e55d diff --git a/upstream/opentui b/upstream/opentui index d0ef809..71b129a 160000 --- a/upstream/opentui +++ b/upstream/opentui @@ -1 +1 @@ -Subproject commit d0ef8094784326ff5af8e990c2eddc73a234f96e +Subproject commit 71b129abdc0854ba5153486b5f29356488223006 diff --git a/upstream/sqlite-vec b/upstream/sqlite-vec index 5778fec..04d28bd 160000 --- a/upstream/sqlite-vec +++ b/upstream/sqlite-vec @@ -1 +1 @@ -Subproject commit 5778fecfebaddafc23b69a3a4b91a8ee80e37a92 +Subproject commit 04d28bd21773981e2d266bbf6aa4efbd011eb4f6 From e707269e252b0dc37f9d440a402e098a266a3afd Mon Sep 17 00:00:00 2001 From: Techie5879 Date: Fri, 19 Jun 2026 03:47:30 +0530 Subject: [PATCH 4/6] Reduce picker content transparency --- src/tui.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index 8d03b11..644852d 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import { TextAttributes, type RGBA, type ScrollBoxRenderable } from "@opentui/core" +import { RGBA, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js" import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" @@ -57,6 +57,11 @@ function shortName(name: SearchDependencyStatus["name"]) { return name } +function opaque(color: RGBA) { + const [r, g, b] = color.toInts() + return RGBA.fromInts(r, g, b, 255) +} + function isWorkingStatus(status: SessionStatus) { return status?.type === "busy" || status?.type === "retry" } @@ -134,9 +139,10 @@ function StatusBar(props: { modeError: string | undefined }) { const theme = props.api.theme.current + const panel = opaque(theme.backgroundPanel) return ( - + Math.max(6, props.height - 3) let scrollbox: ScrollBoxRenderable | undefined @@ -215,6 +222,7 @@ function PreviewPane(props: { border={true} borderStyle="rounded" borderColor={theme.borderSubtle} + backgroundColor={panel} title="Preview" titleAlignment="left" paddingLeft={1} @@ -521,11 +529,12 @@ function SmartSessionDialog(props: { api: TuiPluginApi }) { } const { DialogSelect } = props.api.ui + const panel = () => opaque(props.api.theme.current.backgroundPanel) return ( - - - + + + Date: Fri, 19 Jun 2026 03:49:21 +0530 Subject: [PATCH 5/6] Improve picker panel padding --- src/tui.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index 644852d..6cd74ec 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -13,6 +13,9 @@ import type { DependencyState, SearchDependencyStatus, SearchEnvironmentStatus, const PLUGIN_ID = "local.smart-session-picker" const SEARCH_DEBOUNCE_MS = 150 +const DIALOG_PADDING_X = 2 +const DIALOG_PADDING_Y = 1 +const DIALOG_PANE_GAP = 2 const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] type SessionStatus = ReturnType @@ -204,7 +207,7 @@ function PreviewPane(props: { }) { const theme = props.api.theme.current const panel = opaque(theme.backgroundPanel) - const scrollHeight = () => Math.max(6, props.height - 3) + const scrollHeight = () => Math.max(6, props.height - 5) let scrollbox: ScrollBoxRenderable | undefined createEffect(() => { @@ -227,6 +230,8 @@ function PreviewPane(props: { titleAlignment="left" paddingLeft={1} paddingRight={1} + paddingTop={1} + paddingBottom={1} > opaque(props.api.theme.current.backgroundPanel) return ( - - + + Date: Fri, 19 Jun 2026 04:14:08 +0530 Subject: [PATCH 6/6] Improve session preview fidelity --- docs/session-indexing-notes.md | 3 +- src/search/extractor.ts | 10 +- src/search/preview.ts | 197 ++++++++++++++++++- src/search/search.ts | 2 +- src/search/sidecar.ts | 31 ++- src/tui.tsx | 116 ++++++++--- test/integration/preview.integration.test.ts | 75 ++++++- test/integration/search.integration.test.ts | 31 ++- 8 files changed, 407 insertions(+), 58 deletions(-) diff --git a/docs/session-indexing-notes.md b/docs/session-indexing-notes.md index 225c711..30701ea 100644 --- a/docs/session-indexing-notes.md +++ b/docs/session-indexing-notes.md @@ -59,7 +59,8 @@ Start conservative: to search reasoning. - `part.data.type = "tool"`: index the tool name, completed title, completed output, and error text if present. Down-weight verbose outputs. -- `part.data.type = "file"`: index filename, URL, MIME type, and source fields. +- `part.data.type = "file"`: index filename, non-`data:` URL, MIME type, and + source fields. Do not index base64 payloads from pasted image/PDF data URLs. - `part.data.type = "patch"`: index patch summary/content, but chunk carefully. - `part.data.type = "subtask"`: index task description/prompt if present. diff --git a/src/search/extractor.ts b/src/search/extractor.ts index 1341629..9d4374a 100644 --- a/src/search/extractor.ts +++ b/src/search/extractor.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto" import type { Message, Part, Session } from "@opencode-ai/sdk/v2" import type { SearchDocument } from "./types" -export const SEARCH_EXTRACTOR_VERSION = "3" +export const SEARCH_EXTRACTOR_VERSION = "4" function hash(value: unknown) { return createHash("sha256").update(JSON.stringify(value)).digest("hex") @@ -27,7 +27,7 @@ function sourceText(part: Part) { case "file": return joinText([ part.filename, - part.url, + searchableFileUrl(part.url), part.mime, part.source?.type === "file" || part.source?.type === "symbol" ? part.source.path : undefined, part.source?.type === "symbol" ? part.source.name : undefined, @@ -44,6 +44,12 @@ function sourceText(part: Part) { } } +function searchableFileUrl(url: string | undefined) { + if (!url) return + if (/^data:/i.test(url)) return + return url +} + export function extractSearchDocuments(session: Session, message: Message, part: Part): SearchDocument[] { if (message.role !== "user") return [] const text = sourceText(part) diff --git a/src/search/preview.ts b/src/search/preview.ts index 544dc5f..b50f3fa 100644 --- a/src/search/preview.ts +++ b/src/search/preview.ts @@ -9,8 +9,14 @@ export const PREVIEW_CONTEXT_LINES = 30 export type PreviewLine = { text: string - kind: "role" | "text" | "separator" + kind: "role" | "title" | "text" | "attachment" | "separator" isMatch: boolean + highlights?: Array<{ start: number; end: number }> + attachment?: { + badge: string + label: string + mime?: string + } } export type SessionPreview = { @@ -22,6 +28,25 @@ export type SessionPreview = { const IMAGE_DATA_URL_START = /data:image\/[a-z0-9.+-]+(?:;[a-z0-9.+-]+=[^;,\s]+)*;base64,/i +const MIME_BADGE: Record = { + "text/plain": "txt", + "image/png": "img", + "image/jpeg": "img", + "image/gif": "img", + "image/webp": "img", + "application/pdf": "pdf", + "application/x-directory": "dir", +} + +type PreviewDocumentRow = { + messageID: string | null + partID?: string | null + role: string | null + partType: string | null + text: string + metadataJson?: string | null +} + export function sanitizePreviewTextLines(text: string): string[] { const lines: string[] = [] let skippingImagePayload = false @@ -93,7 +118,7 @@ export async function loadSessionPreview( if (sidecar.hasDocuments()) { const rows = sidecar.getSessionDocumentTexts(sessionID) if (rows.length) { - rawLines = rowsToLines(rows) + rawLines = previewLinesFromDocumentRows(rows) source = "sidecar" } } @@ -129,7 +154,7 @@ export async function loadSessionPreview( return undefined } - const result = applyWindow(sessionID, rawLines, query, contextLines) + const result = applyPreviewWindow(sessionID, rawLines, query, contextLines) logEvent(api, "debug", "preview.loaded", { component: "preview", previewID, @@ -144,25 +169,115 @@ export async function loadSessionPreview( return result } -function rowsToLines(rows: Array<{ role: string | null; text: string }>): PreviewLine[] { +export function previewLinesFromDocumentRows(rows: PreviewDocumentRow[]): PreviewLine[] { const out: PreviewLine[] = [] + let current: + | { + messageID: string + role: string | null + lines: PreviewLine[] + } + | undefined + + function flushMessage() { + if (!current) return + if (current.lines.length) { + out.push({ text: current.role ?? "message", kind: "role", isMatch: false }) + out.push(...current.lines) + out.push({ text: "", kind: "separator", isMatch: false }) + } + current = undefined + } + for (const row of rows) { - out.push({ text: row.role ? `[${row.role}]` : "[title]", kind: "role", isMatch: false }) - for (const l of sanitizePreviewTextLines(row.text.trim())) out.push({ text: l, kind: "text", isMatch: false }) - out.push({ text: "", kind: "separator", isMatch: false }) + if (!row.messageID) { + flushMessage() + for (const l of sanitizePreviewTextLines(row.text.trim())) out.push({ text: l, kind: "title", isMatch: false }) + out.push({ text: "", kind: "separator", isMatch: false }) + continue + } + + if (!current || current.messageID !== row.messageID) { + flushMessage() + current = { messageID: row.messageID, role: row.role, lines: [] } + } + + if (row.partType === "file") { + const attachment = fileAttachment(row) + current.lines.push({ + text: `${attachment.badge} ${attachment.label}`.trim(), + kind: "attachment", + isMatch: false, + attachment, + }) + } else { + for (const l of sanitizePreviewTextLines(row.text.trim())) current.lines.push({ text: l, kind: "text", isMatch: false }) + } } + flushMessage() return out } +function fileAttachment(row: PreviewDocumentRow) { + const metadata = parseMetadata(row.metadataJson) + const lines = sanitizePreviewTextLines(row.text.trim()) + .map((line) => line.trim()) + .filter(Boolean) + const mime = stringValue(metadata.mime) ?? lines.find(isMimeType) + const label = + stringValue(metadata.filename) ?? + lines.find((line) => line !== "[image]" && !isMimeType(line) && !line.startsWith("data:")) ?? + mime ?? + "attachment" + return { + badge: mime ? MIME_BADGE[mime] ?? mime.split("/").at(-1) ?? "file" : "file", + label, + mime, + } +} + +function parseMetadata(value: string | null | undefined) { + if (!value) return {} + try { + return JSON.parse(value) as Record + } catch { + return {} + } +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function isMimeType(value: string) { + return /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/i.test(value) +} + function linesFromMessages(messages: Message[], partsForMessage: (messageID: string) => readonly Part[]): PreviewLine[] { const out: PreviewLine[] = [] const sorted = [...messages].sort((a, b) => a.time.created - b.time.created) for (const msg of sorted) { - out.push({ text: `[${msg.role}]`, kind: "role", isMatch: false }) + out.push({ text: msg.role, kind: "role", isMatch: false }) for (const part of partsForMessage(msg.id)) { if (part.type === "text" && !part.ignored) { for (const l of sanitizePreviewTextLines(part.text)) out.push({ text: l, kind: "text", isMatch: false }) } + if (part.type === "file") { + const attachment = fileAttachment({ + messageID: msg.id, + partID: part.id, + role: msg.role, + partType: part.type, + text: [part.filename, part.mime].filter(Boolean).join("\n"), + metadataJson: JSON.stringify({ filename: part.filename, mime: part.mime }), + }) + out.push({ + text: `${attachment.badge} ${attachment.label}`.trim(), + kind: "attachment", + isMatch: false, + attachment, + }) + } } out.push({ text: "", kind: "separator", isMatch: false }) } @@ -181,7 +296,7 @@ export async function linesFromSyncedState(api: TuiPluginApi, sessionID: string) } } -function applyWindow( +export function applyPreviewWindow( sessionID: string, all: PreviewLine[], query: string, @@ -196,8 +311,12 @@ function applyWindow( if (terms.length) { for (const line of all) { - if (line.kind === "text" && terms.some((t) => line.text.toLowerCase().includes(t))) { + if (line.kind !== "text" && line.kind !== "title" && line.kind !== "attachment") continue + + const highlights = previewHighlights(line.text, terms) + if (highlights.length) { line.isMatch = true + line.highlights = highlights matchCount++ } } @@ -213,6 +332,64 @@ function applyWindow( return { sessionID, lines: all.slice(0, contextLines), matchCount: 0, totalLines: all.length } } +function previewHighlights(text: string, terms: string[]) { + const exact = exactHighlights(text, terms) + if (exact.length) return exact + return fuzzyHighlights(text, terms) +} + +function exactHighlights(text: string, terms: string[]) { + const lower = text.toLowerCase() + const ranges: Array<{ start: number; end: number }> = [] + for (const term of terms) { + let from = 0 + while (from < lower.length) { + const start = lower.indexOf(term, from) + if (start < 0) break + ranges.push({ start, end: start + term.length }) + from = start + Math.max(1, term.length) + } + } + return mergeRanges(ranges) +} + +function fuzzyHighlights(text: string, terms: string[]) { + const lower = text.toLowerCase() + const ranges: Array<{ start: number; end: number }> = [] + for (const term of terms) { + const matched = fuzzyTermHighlights(lower, term) + if (matched.length) ranges.push(...matched) + } + return mergeRanges(ranges) +} + +function fuzzyTermHighlights(lower: string, term: string) { + if (!term) return [] + const ranges: Array<{ start: number; end: number }> = [] + let from = 0 + for (const char of term) { + const index = lower.indexOf(char, from) + if (index < 0) return [] + ranges.push({ start: index, end: index + 1 }) + from = index + 1 + } + return ranges +} + +function mergeRanges(input: Array<{ start: number; end: number }>) { + if (!input.length) return [] + const ranges = input + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start || b.end - a.end) + const merged: Array<{ start: number; end: number }> = [] + for (const range of ranges) { + const previous = merged.at(-1) + if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end) + else merged.push({ ...range }) + } + return merged +} + async function linesFromApi(api: TuiPluginApi, sessionID: string): Promise { try { const res = await api.client.session.messages({ sessionID }) diff --git a/src/search/search.ts b/src/search/search.ts index 518c1d7..9c0fae3 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -188,7 +188,7 @@ async function ensureBackgroundIndex( return } - const blocking = allowBlocking && !sidecar.hasDocuments() + const blocking = allowBlocking && (delta.kind === "full" || !sidecar.hasDocuments()) diagnostics.push({ kind: "indexing", message: blocking diff --git a/src/search/sidecar.ts b/src/search/sidecar.ts index ba1f7e7..c873fbe 100644 --- a/src/search/sidecar.ts +++ b/src/search/sidecar.ts @@ -613,10 +613,35 @@ export class SearchSidecar { })) } - getSessionDocumentTexts(sessionID: string): Array<{ role: string | null; text: string }> { + getSessionDocumentTexts(sessionID: string): Array<{ + messageID: string | null + partID: string | null + role: string | null + partType: string | null + text: string + metadataJson: string + }> { return this.db - .prepare("select role, text from document where session_id = ? order by rowid asc") - .all(sessionID) as Array<{ role: string | null; text: string }> + .prepare(` + select + message_id as messageID, + part_id as partID, + role, + part_type as partType, + text, + metadata_json as metadataJson + from document + where session_id = ? + order by rowid asc + `) + .all(sessionID) as Array<{ + messageID: string | null + partID: string | null + role: string | null + partType: string | null + text: string + metadataJson: string + }> } snippetsForSessions(sessionIDs: string[]) { diff --git a/src/tui.tsx b/src/tui.tsx index 6cd74ec..cdb4fdd 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -29,13 +29,6 @@ function dateCategory(updated: number) { return date.toDateString() } -function timeFooter(updated: number) { - return new Date(updated).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }) -} - function stateWord(state: DependencyState) { if (state === "available") return "ok" if (state === "disabled") return "off" @@ -100,7 +93,9 @@ function searchTerms(query: string) { return [...new Set(query.trim().toLowerCase().split(/\s+/).filter(Boolean))].sort((a, b) => b.length - a.length) } -function highlightSegments(text: string, query: string) { +function highlightSegments(text: string, query: string, highlights?: Array<{ start: number; end: number }>) { + if (highlights?.length) return segmentsFromRanges(text, highlights) + const terms = searchTerms(query) if (!terms.length) return [{ text, highlight: false }] @@ -136,6 +131,27 @@ function highlightSegments(text: string, query: string) { return segments } +function segmentsFromRanges(text: string, input: Array<{ start: number; end: number }>) { + const ranges = input + .map((range) => ({ + start: Math.max(0, Math.min(text.length, range.start)), + end: Math.max(0, Math.min(text.length, range.end)), + })) + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start || b.end - a.end) + if (!ranges.length) return [{ text, highlight: false }] + + const segments: Array<{ text: string; highlight: boolean }> = [] + let index = 0 + for (const range of ranges) { + if (range.start > index) segments.push({ text: text.slice(index, range.start), highlight: false }) + segments.push({ text: text.slice(range.start, range.end), highlight: true }) + index = Math.max(index, range.end) + } + if (index < text.length) segments.push({ text: text.slice(index), highlight: false }) + return segments +} + function StatusBar(props: { api: TuiPluginApi environment: SearchEnvironmentStatus | undefined @@ -198,6 +214,29 @@ function StatusBar(props: { ) } +function attachmentBadgeColor(theme: TuiPluginApi["theme"]["current"], mime: string | undefined) { + if (mime?.startsWith("image/")) return theme.accent + if (mime === "application/pdf") return theme.primary + return theme.secondary +} + +function previewRoleLabel(role: string) { + const normalized = role.replace(/^\[|\]$/g, "").toLowerCase() + if (normalized === "user") return "You" + if (normalized === "assistant") return "Assistant" + return normalized ? normalized[0]!.toUpperCase() + normalized.slice(1) : "Message" +} + +function previewLineText(line: SessionPreview["lines"][number]) { + return line.kind === "role" ? previewRoleLabel(line.text) : line.text +} + +function previewLineColor(theme: TuiPluginApi["theme"]["current"], line: SessionPreview["lines"][number]) { + if (line.isMatch) return theme.accent + if (line.kind !== "role") return theme.text + return line.text.replace(/^\[|\]$/g, "").toLowerCase() === "assistant" ? theme.secondary : theme.primary +} + function PreviewPane(props: { api: TuiPluginApi preview: SessionPreview | undefined @@ -256,31 +295,45 @@ function PreviewPane(props: { when={line.kind !== "separator"} fallback={} > - - - {(segment) => ( - + - {segment.text} - - )} - - + {(segment) => ( + + {segment.text} + + )} + + + } + > + + + {` ${line.attachment?.badge ?? "file"} `} + + + {` ${line.attachment?.label ?? "attachment"} `} + + + )} @@ -523,7 +576,6 @@ function SmartSessionDialog(props: { api: TuiPluginApi }) { title: s.title, value: s.id, category: dateCategory(s.updated), - footer: timeFooter(s.updated), gutter: isWorkingStatus(status) ? () => : undefined, } }) diff --git a/test/integration/preview.integration.test.ts b/test/integration/preview.integration.test.ts index 7c8b6bf..d11bc78 100644 --- a/test/integration/preview.integration.test.ts +++ b/test/integration/preview.integration.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { Message, Part } from "@opencode-ai/sdk/v2" -import { linesFromSyncedState, sanitizePreviewTextLines } from "../../src/search/preview" +import { + applyPreviewWindow, + linesFromSyncedState, + previewLinesFromDocumentRows, + sanitizePreviewTextLines, +} from "../../src/search/preview" describe("session preview", () => { test("collapses multiline image data URLs", () => { @@ -92,12 +97,76 @@ describe("session preview", () => { const lines = await linesFromSyncedState(api, "ses_one") expect(lines?.map((line) => line.text)).toEqual([ - "[user]", + "user", "find semantic sessions", "", - "[assistant]", + "assistant", "ranked results", "", ]) }) + + test("groups sidecar text and file rows into one preview message", () => { + const lines = previewLinesFromDocumentRows([ + { + messageID: null, + role: null, + partType: null, + text: "Pipeline preprocess failures investigation", + }, + { + messageID: "msg_user", + partID: "part_text", + role: "user", + partType: "text", + text: "[Image 1]\n\ncan you check what is going on here", + }, + { + messageID: "msg_user", + partID: "part_file", + role: "user", + partType: "file", + text: "clipboard\ndata:image/png;base64,abc\nimage/png\nclipboard", + }, + ]) + + expect(lines.map((line) => line.text)).toEqual([ + "Pipeline preprocess failures investigation", + "", + "user", + "[Image 1]", + "", + "can you check what is going on here", + "img clipboard", + "", + ]) + expect(lines.filter((line) => line.text === "user")).toHaveLength(1) + expect(lines.find((line) => line.kind === "attachment")?.attachment).toEqual({ + badge: "img", + label: "clipboard", + mime: "image/png", + }) + }) + + test("marks fuzzy preview highlights when no exact substring matches", () => { + const preview = applyPreviewWindow( + "ses_one", + [ + { text: "You", kind: "role", isMatch: false }, + { text: "Splitter-v3 Firestore document processing errors", kind: "text", isMatch: false }, + ], + "sfdpe", + 10, + ) + + expect(preview.matchCount).toBe(1) + expect(preview.lines[0]?.isMatch).toBe(true) + expect(preview.lines[0]?.highlights).toEqual([ + { start: 0, end: 1 }, + { start: 12, end: 13 }, + { start: 22, end: 23 }, + { start: 31, end: 32 }, + { start: 35, end: 36 }, + ]) + }) }) diff --git a/test/integration/search.integration.test.ts b/test/integration/search.integration.test.ts index 71462ec..4ed9c02 100644 --- a/test/integration/search.integration.test.ts +++ b/test/integration/search.integration.test.ts @@ -121,27 +121,40 @@ function createSourceDb(file: string) { 4, JSON.stringify({ type: "text", text: "Later attachment mention: resume.pdf should stay searchable" }), ) - insertMessage.run( - "msg_one_assistant", + insertPart.run( + "prt_one_image", + "msg_one", "ses_one", 5, 5, + JSON.stringify({ + type: "file", + filename: "clipboard", + mime: "image/png", + url: "data:image/png;base64,base64payloadtoken", + }), + ) + insertMessage.run( + "msg_one_assistant", + "ses_one", + 6, + 6, JSON.stringify({ role: "assistant", agent: "build", model: { providerID: "local", modelID: "test" } }), ) insertPart.run( "prt_one_assistant_text", "msg_one_assistant", "ses_one", - 6, - 6, + 7, + 7, JSON.stringify({ type: "text", text: "assistant-only-token should not be searchable" }), ) insertPart.run( "prt_one_assistant_tool", "msg_one_assistant", "ses_one", - 7, - 7, + 8, + 8, JSON.stringify({ type: "tool", tool: "bash", @@ -242,6 +255,8 @@ describe("search integration", () => { const toolResults = sidecar.searchFts("tool-only-token") const titleResults = sidecar.searchFts("Boring") const pathResults = sidecar.searchFts("packages") + const dataUrlResults = sidecar.searchFts("base64payloadtoken") + const fileNameResults = sidecar.searchFts("clipboard") const snippets = sidecar.snippetsForSessions(["ses_one"]) const needsCurrentReindex = sidecar.needsReindex(corpus.map((entry) => entry.session)) const needsMissingReindex = sidecar.needsReindex([ @@ -263,8 +278,12 @@ describe("search integration", () => { expect(toolResults).toEqual([]) expect(titleResults[0]?.sessionID).toBe("ses_one") expect(pathResults).toEqual([]) + expect(dataUrlResults).toEqual([]) + expect(fileNameResults[0]?.sessionID).toBe("ses_one") expect(snippets.get("ses_one")).toContain("Boring title") expect(snippets.get("ses_one")).toContain("resume.pdf") + expect(snippets.get("ses_one")).toContain("clipboard") + expect(snippets.get("ses_one")).not.toContain("base64payloadtoken") expect(snippets.get("ses_one")).not.toContain("assistant-only-token") expect(snippets.get("ses_one")).not.toContain("tool-only-token") expect(needsCurrentReindex).toBe(false)