diff --git a/packages/agent/docs/search.md b/packages/agent/docs/search.md new file mode 100644 index 000000000..97f94770f --- /dev/null +++ b/packages/agent/docs/search.md @@ -0,0 +1,276 @@ +# Session Search + +Pi search is a small query interface over committed session entries. The shared contract returns only stable hit identity; implementations may extend hits with backend-specific display data. + +## Core API + +```ts +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + + /** Maximum number of hits to return. Backends may return fewer, not more. */ + readonly limit?: number; + + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} +``` + +The base hit is intentionally minimal: `(sessionId, entryId)` is the portable identity across JSONL, memory, SQLite FTS, and remote indexes. Snippets, timestamps, scores, metadata, offsets, and ranking semantics belong to concrete implementations. + +## Why async iterable + +`AsyncIterable` lets consumers render early results, stop iteration when they have enough, and cancel in-flight work with `AbortSignal`. Debouncing remains a UI/caller concern; the API only provides the cancellation primitive. + +```ts +let currentAbortController: AbortController | undefined; + +async function updateResults(query: string) { + currentAbortController?.abort(); + const controller = new AbortController(); + currentAbortController = controller; + + try { + for await (const hit of search.search(query, { limit: 10, signal: controller.signal })) { + render(hit); + } + } catch (error) { + if (!(error instanceof Error) || error.name !== "AbortError") throw error; + } +} +``` + +## Default implementations + +### Scanning search + +The reusable scanner adapts session-like readables (`getMetadata`, `findEntries`, and `getLabel`) into projected entries: + +```ts +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} +``` + +`SessionSearchCandidate` is pre-match scanner input: it contains searchable text, type, sequence, and optional projected fields. The scanner turns matching candidates into public hits. + +Already-open sessions or storages can be scanned directly: + +```ts +const search = createScanningSessionSearch(sessions); + +for await (const hit of search.search("authentication", { limit: 10 })) { + const session = sessionsById.get(hit.sessionId)!; + const entry = await session.getEntry(hit.entryId); + console.log(entry); +} +``` + +JSONL does not need a separate public search adapter. JSONL-backed code can keep discovery/loading local, then pass the loaded storages to the same scanner: + +```ts +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, query)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +const search = createScanningSessionSearch((query) => jsonlReadables(jsonl, query)); +``` + +A scanning source must not call `SessionRepo.open()` on a harness-owned session if that operation may claim a writer lease. JSONL should use read-only loading helpers; already-open sessions/storages can be scanned directly. + +### SQLite FTS + +SQLite search exposes an extended hit: + +```ts +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; +} +``` + +```ts +const search = createSqliteSessionSearch({ env, sqlite, databasePath }); + +for await (const hit of search.search("auth", { + entryTypes: ["message", "compaction"], + limit: 20, +})) { + console.log(hit.sessionId, hit.entryId, hit.score); +} +``` + +The FTS table and triggers are created lazily on first non-blank search. When FTS is first created, SQLite performs a one-time rebuild from canonical `entries`; after that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, and payload updates. This makes SQLite search fresh after commit, but it also means FTS trigger failures can roll back canonical SQLite writes while search is enabled for that database. + +## Indexed backends + +Search indexing is backend-owned derived state. The shared package only exports the query API; applications or backend packages may define their own writer/feed contracts when they need explicit index maintenance. + +### JSONL sessions with Elasticsearch + +This is application-owned glue. Core provides the query contract and JSONL session discovery; the Elastic writer contract is local to this adapter. + +```ts +import { Client } from "@elastic/elasticsearch"; +import { + scanningEntries, + type JsonlSessionMetadata, + type JsonlSessionRepoOptions, + type SessionSearch, + type SessionSearchHit, + type SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; + +// JSONL-backed code can provide this locally from existing JSONL list/load helpers. +async function* jsonlReadables(jsonl: JsonlSessionRepoOptions, options: { cwd?: string } = {}) { + for (const metadata of await listJsonlSessionMetadata(jsonl, options)) { + yield loadJsonlSessionStorage(jsonl, metadata); + } +} + +interface SearchIndexWriter { + apply(items: TItem[]): Promise; + flush?(): Promise; +} + +interface IndexedSessionSearch + extends SessionSearch, SearchIndexWriter {} + +type ElasticSessionFeedItem = + | { type: "upsert"; id: string; body: ElasticSessionDoc } + | { type: "delete"; id: string }; + +interface ElasticSessionDoc { + sessionId: string; + entryId: string; + seq: number; + timestamp: number; + cwd: string; + text: string; + metadata: JsonlSessionMetadata; + fields?: Record; +} + +interface ElasticSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; + readonly score?: number; +} + +class ElasticSessionSearch + implements IndexedSessionSearch +{ + constructor( + private readonly client: Client, + private readonly index: string, + ) {} + + async apply(items: ElasticSessionFeedItem[]): Promise { + const operations = items.flatMap((item) => { + if (item.type === "delete") { + return [{ delete: { _index: this.index, _id: item.id } }]; + } + return [{ index: { _index: this.index, _id: item.id } }, item.body]; + }); + + if (operations.length > 0) await this.client.bulk({ operations }); + } + + async flush(): Promise { + await this.client.indices.refresh({ index: this.index }); + } + + async *search( + text: string, + options: SessionSearchOptions = {}, + ): AsyncIterable { + const result = await this.client.search({ + index: this.index, + size: options.limit ?? 20, + query: { + bool: { + must: [{ match: { text } }], + }, + }, + }); + + for (const hit of result.hits.hits) { + if (!hit._source) continue; + if (options.signal?.aborted) throw options.signal.reason; + yield { + sessionId: hit._source.sessionId, + entryId: hit._source.entryId, + timestamp: hit._source.timestamp, + snippet: hit._source.text, + score: hit._score ?? undefined, + }; + } + } +} +``` + +A catch-up/rebuild job can feed JSONL projections into Elasticsearch without taking a writer lease: + +```ts +async function indexJsonlSessionsIntoElastic( + jsonl: JsonlSessionRepoOptions, + elastic: ElasticSessionSearch, + options: { cwd?: string } = {}, +): Promise { + for await (const session of jsonlReadables(jsonl, { cwd: options.cwd })) { + const metadata = await session.getMetadata(); + for await (const candidate of scanningEntries(session)) { + await elastic.apply([{ + type: "upsert", + id: `${metadata.id}:${candidate.entryId}`, + body: { + sessionId: metadata.id, + entryId: candidate.entryId, + seq: candidate.seq, + timestamp: candidate.timestamp, + cwd: metadata.cwd, + text: candidate.text, + metadata, + fields: candidate.fields, + }, + }]); + } + } + + await elastic.flush(); +} +``` + +## Correctness and failure boundaries + +Search indexes are derived state for the shared API: applications can retry, rebuild, or mark search stale. Backend-specific choices may make different tradeoffs; SQLite FTS uses co-located triggers, so FTS failures can roll back canonical SQLite writes after search has initialized the triggers. + +Scanning sources should fail fast if they yield duplicate `sessionId` values, because base hit identity is `(sessionId, entryId)`. Indexed backends usually enforce uniqueness in their storage/index layer. + +Search opt-in still needs a sync/indexing layer. A follow-up should add a no-op-by-default search index sink (for example `NOOP_SEARCH_INDEX_SINK`) so canonical write sites can emit indexing events unconditionally, similar to how telemetry uses no-op implementations when telemetry is disabled. diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index 98bb12ddd..bf8bb4874 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -24,10 +24,83 @@ function validateSessionId(id: string): void { } } -function sessionDirectoryName(cwd: string): string { +function jsonlSessionDirectoryName(cwd: string): string { return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } +async function jsonlSessionsRoot(options: JsonlSessionRepoOptions): Promise { + return fileResult( + await options.fs.absolutePath(options.sessionsRoot), + `Failed to resolve sessions root ${options.sessionsRoot}`, + ); +} + +async function jsonlSessionDirectory( + fs: JsonlSessionRepoFileSystem, + sessionsRoot: string, + cwd: string, +): Promise { + return fileResult( + await fs.joinPath([sessionsRoot, jsonlSessionDirectoryName(cwd)]), + `Failed to resolve sessions directory for ${cwd}`, + ); +} + +async function jsonlSessionDirectories(options: JsonlSessionRepoOptions, cwd?: string): Promise { + const sessionsRoot = await jsonlSessionsRoot(options); + if (cwd !== undefined) { + const resolvedCwd = fileResult(await options.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); + const directory = await jsonlSessionDirectory(options.fs, sessionsRoot, resolvedCwd); + return fileResult(await options.fs.exists(directory), `Failed to check sessions directory ${directory}`) + ? [directory] + : []; + } + if (!fileResult(await options.fs.exists(sessionsRoot), `Failed to check sessions directory ${sessionsRoot}`)) + return []; + return fileResult(await options.fs.listDir(sessionsRoot), `Failed to list sessions directory ${sessionsRoot}`) + .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") + .map((entry) => entry.path); +} + +export async function listJsonlSessionMetadata( + options: JsonlSessionRepoOptions, + query: JsonlSessionListOptions = {}, +): Promise { + const metadata: JsonlSessionMetadata[] = []; + for (const directory of await jsonlSessionDirectories(options, query.cwd)) { + const files = fileResult( + await options.fs.listDir(directory), + `Failed to list sessions directory ${directory}`, + ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); + for (const file of files) { + const [firstLine] = fileResult( + await options.fs.readTextLines(file.path, { maxLines: 1 }), + `Failed to read session header ${file.path}`, + ); + if (!firstLine) continue; + const headerResult = parseHeader(firstLine); + if (!headerResult.ok) continue; + metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); + } + } + return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); +} + +export async function loadJsonlSessionStorage( + options: JsonlSessionRepoOptions, + metadata: JsonlSessionMetadata, +): Promise { + if (!fileResult(await options.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + const storage = await JsonlSessionStorage.load(options.fs, metadata.path); + const loadedMetadata = await storage.getMetadata(); + if (loadedMetadata.id !== metadata.id) { + throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); + } + return storage; +} + function sessionFileName(createdAt: number, id: string): string { const timestamp = new Date(createdAt).toISOString().replace(/[:.]/g, "-"); return `${timestamp}_${id}.jsonl`; @@ -83,15 +156,7 @@ export class JsonlSessionRepo } private async loadStorage(metadata: JsonlSessionMetadata): Promise { - if (!fileResult(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { - throw new SessionError("not_found", `Session not found: ${metadata.id}`); - } - const storage = await JsonlSessionStorage.load(this.fs, metadata.path); - const loadedMetadata = await storage.getMetadata(); - if (loadedMetadata.id !== metadata.id) { - throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); - } - return storage; + return loadJsonlSessionStorage({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, metadata); } private async resolveCreateDestination(options: JsonlSessionCreateOptions): Promise<{ id: string; cwd: string }> { @@ -155,25 +220,7 @@ export class JsonlSessionRepo } private async listDirect(options: JsonlSessionListOptions): Promise { - const directories = await this.sessionDirectories(options.cwd); - const metadata: JsonlSessionMetadata[] = []; - for (const directory of directories) { - const files = fileResult( - await this.fs.listDir(directory), - `Failed to list sessions directory ${directory}`, - ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); - for (const file of files) { - const [firstLine] = fileResult( - await this.fs.readTextLines(file.path, { maxLines: 1 }), - `Failed to read session header ${file.path}`, - ); - if (!firstLine) continue; - const headerResult = parseHeader(firstLine); - if (!headerResult.ok) continue; - metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); - } - } - return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); + return listJsonlSessionMetadata({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, options); } private async sessionIdExists(id: string, cwd: string): Promise { @@ -184,24 +231,9 @@ export class JsonlSessionRepo return files.some((entry) => entry.kind !== "directory" && entry.name.endsWith(suffix)); } - private async sessionDirectories(cwd?: string): Promise { - const root = await this.root(); - if (cwd !== undefined) { - const resolvedCwd = fileResult(await this.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); - const directory = await this.sessionDirectory(resolvedCwd); - return fileResult(await this.fs.exists(directory), `Failed to check sessions directory ${directory}`) - ? [directory] - : []; - } - if (!fileResult(await this.fs.exists(root), `Failed to check sessions directory ${root}`)) return []; - return fileResult(await this.fs.listDir(root), `Failed to list sessions directory ${root}`) - .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") - .map((entry) => entry.path); - } - private async sessionDirectory(cwd: string): Promise { return fileResult( - await this.fs.joinPath([await this.root(), sessionDirectoryName(cwd)]), + await this.fs.joinPath([await this.root(), jsonlSessionDirectoryName(cwd)]), `Failed to resolve sessions directory for ${cwd}`, ); } diff --git a/packages/agent/src/harness/session/search.ts b/packages/agent/src/harness/session/search.ts deleted file mode 100644 index d75a606eb..000000000 --- a/packages/agent/src/harness/session/search.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FileError, Result } from "../types.ts"; -import type { Session } from "./session.ts"; -import { type SessionCreateOptions, SessionError, type SessionMetadata, type SessionRepo } from "./types.ts"; - -export interface SessionSearchOptions { - text: string; - cwd?: string; -} - -export interface SessionSearchHit { - metadata: TMetadata; - entryId: string; - timestamp: number; - snippet?: string; - score?: number; -} - -export interface SessionSearch { - search(options: SessionSearchOptions): Promise[]>; -} - -export function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -type ScanningSessionSearchSource = { - list(): Promise; - open(metadata: TMetadata): Promise>; -}; - -class ScanningSessionSearch implements SessionSearch { - private readonly source: ScanningSessionSearchSource; - - constructor(source: ScanningSessionSearchSource) { - this.source = source; - } - - async search(options: SessionSearchOptions): Promise[]> { - const normalizedText = options.text.trim().toLowerCase(); - if (!normalizedText) return []; - const hits: SessionSearchHit[] = []; - for (const metadata of await this.source.list()) { - const cwd = (metadata as { cwd?: unknown }).cwd; - if (options.cwd !== undefined && cwd !== options.cwd) continue; - const session = await this.source.open(metadata); - for (const entry of await session.findEntries({ order: "oldestFirst" })) { - const payload = JSON.stringify(entry); - if (!payload.toLowerCase().includes(normalizedText)) continue; - hits.push({ - metadata, - entryId: entry.id, - timestamp: entry.timestamp, - snippet: payload, - }); - } - } - return hits; - } -} - -export function createScanningSessionSearch< - TMetadata extends SessionMetadata, - TCreateOptions extends SessionCreateOptions, - TListOptions, ->(source: Pick, "list" | "open">): SessionSearch { - return new ScanningSessionSearch(source); -} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index b1315b68b..109cb0500 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -78,7 +78,6 @@ export * from "./harness/prompt-templates.ts"; // Harness export * from "./harness/result.ts"; export * from "./harness/session/index.ts"; -export * from "./harness/session/search.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; export type { @@ -139,6 +138,7 @@ export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; // Proxy utilities export * from "./proxy.ts"; +export * from "./search/index.ts"; // Stream defaults export { setDefaultStreamFn } from "./stream-fn.ts"; // Types diff --git a/packages/agent/src/search/index.ts b/packages/agent/src/search/index.ts new file mode 100644 index 000000000..735847895 --- /dev/null +++ b/packages/agent/src/search/index.ts @@ -0,0 +1,32 @@ +import type { Entry } from "../harness/session/types.ts"; + +export type { + ScanningReadable, + ScanningReadableOptions, + ScanningReadableSource, + ScanningSearchTextProjector, + ScanningSessionSearchHit, + ScanningSessionSearchOptions, + SessionSearchCandidate, +} from "./scanning.ts"; +export { createScanningSessionSearch, scanningEntries } from "./scanning.ts"; + +export interface SessionSearchOptions { + /** Restrict results to specific canonical entry types. */ + readonly entryTypes?: readonly Entry["type"][]; + /** Maximum number of hits to return. */ + readonly limit?: number; + /** Abort signal for cancellation, e.g. search-as-you-type. */ + readonly signal?: AbortSignal; +} + +export interface SessionSearchHit { + /** Logical identifier of the session that owns the entry. */ + readonly sessionId: string; + /** Logical identifier of the entry within that session. */ + readonly entryId: string; +} + +export interface SessionSearch { + search(text: string, options?: SessionSearchOptions): AsyncIterable; +} diff --git a/packages/agent/src/search/scanning.ts b/packages/agent/src/search/scanning.ts new file mode 100644 index 000000000..14e6bfbf2 --- /dev/null +++ b/packages/agent/src/search/scanning.ts @@ -0,0 +1,176 @@ +import type { Entry, SessionMetadata, SessionStorage } from "../harness/session/types.ts"; +import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "./index.ts"; + +export interface SessionSearchCandidate { + readonly entryId: string; + readonly seq: number; + readonly type: Entry["type"]; + readonly timestamp: number; + readonly text: string; + readonly fields?: Record; +} + +export type ScanningReadable = Pick< + SessionStorage, + "getMetadata" | "findEntries" | "getLabel" +>; + +export type ScanningReadableSource = ( + options?: TOptions, +) => AsyncIterable>; + +export type ScanningSearchTextProjector = ( + metadata: TMetadata, + entry: Entry, + label: string | undefined, +) => string; + +export interface ScanningReadableOptions { + projectText?: ScanningSearchTextProjector; + pageSize?: number; +} + +export interface ScanningSessionSearchHit extends SessionSearchHit { + readonly timestamp: number; + readonly snippet: string; +} + +export interface ScanningSessionSearchOptions< + TMetadata extends SessionMetadata = SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +> extends ScanningReadableOptions { + sourceOptions?: (text: string, options: SessionSearchOptions) => TSourceOptions | undefined; + match?: (queryText: string, candidate: SessionSearchCandidate, metadata: TMetadata) => boolean; + createHit?: (metadata: TMetadata, candidate: SessionSearchCandidate) => THit; +} + +function defaultSearchText( + _metadata: TMetadata, + entry: Entry, + label: string | undefined, +): string { + return label === undefined ? JSON.stringify(entry) : `${JSON.stringify(entry)} ${label}`; +} + +async function* scanReadableEntries( + readable: ScanningReadable, + metadata: TMetadata, + options: ScanningReadableOptions, + query: { afterSeq?: number; limit?: number; entryTypes?: readonly Entry["type"][] } = {}, +): AsyncIterable { + const projectText = options.projectText ?? defaultSearchText; + const pageSize = query.limit ?? options.pageSize ?? 100; + let afterSeq = query.afterSeq ?? 0; + const entryTypes = query.entryTypes === undefined ? undefined : new Set(query.entryTypes); + while (true) { + const entries = await readable.findEntries({ + order: "oldestFirst", + limit: pageSize, + cursor: { afterSeq }, + type: query.entryTypes?.length === 1 ? query.entryTypes[0] : undefined, + }); + if (entries.length === 0) break; + for (const entry of entries) { + if (entryTypes !== undefined && !entryTypes.has(entry.type)) continue; + const label = await readable.getLabel(entry.id); + yield { + entryId: entry.id, + seq: entry.seq, + type: entry.type, + timestamp: entry.timestamp, + text: projectText(metadata, entry, label), + fields: label === undefined ? undefined : { label }, + }; + } + afterSeq = entries[entries.length - 1]?.seq ?? afterSeq; + if (entries.length < pageSize) break; + } +} + +export async function* scanningEntries( + readable: ScanningReadable, + options: ScanningReadableOptions = {}, +): AsyncIterable { + yield* scanReadableEntries(readable, await readable.getMetadata(), options); +} + +async function* arraySource( + readables: readonly ScanningReadable[], +): AsyncIterable> { + yield* readables; +} + +function readablesFor( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: TSourceOptions | undefined, +): AsyncIterable> { + return typeof source === "function" ? source(options) : arraySource(source); +} + +function defaultMatch(queryText: string, candidate: SessionSearchCandidate): boolean { + return candidate.text.toLowerCase().includes(queryText); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + +function createDefaultScanningHit( + metadata: TMetadata, + candidate: SessionSearchCandidate, +): ScanningSessionSearchHit { + return { + sessionId: metadata.id, + entryId: candidate.entryId, + timestamp: candidate.timestamp, + snippet: candidate.text, + }; +} + +export function createScanningSessionSearch< + TMetadata extends SessionMetadata, + TSourceOptions = unknown, + THit extends SessionSearchHit = ScanningSessionSearchHit, +>( + source: readonly ScanningReadable[] | ScanningReadableSource, + options: ScanningSessionSearchOptions = {}, +): SessionSearch { + const createHit = + options.createHit ?? + ((metadata: TMetadata, candidate: SessionSearchCandidate) => + createDefaultScanningHit(metadata, candidate) as unknown as THit); + return { + async *search(text: string, searchOptions: SessionSearchOptions = {}): AsyncIterable { + const normalizedText = text.trim().toLowerCase(); + if (!normalizedText || (searchOptions.limit !== undefined && searchOptions.limit <= 0)) return; + if (searchOptions.entryTypes?.length === 0) return; + let hitCount = 0; + const seenSessionIds = new Set(); + const entryTypes = searchOptions.entryTypes === undefined ? undefined : new Set(searchOptions.entryTypes); + const sourceOptions = options.sourceOptions?.(normalizedText, searchOptions); + for await (const readable of readablesFor(source, sourceOptions)) { + throwIfAborted(searchOptions.signal); + const metadata = await readable.getMetadata(); + if (seenSessionIds.has(metadata.id)) throw new Error(`Duplicate sessionId: ${metadata.id}`); + seenSessionIds.add(metadata.id); + for await (const candidate of scanReadableEntries(readable, metadata, options, { + entryTypes: searchOptions.entryTypes, + })) { + throwIfAborted(searchOptions.signal); + if (entryTypes !== undefined && !entryTypes.has(candidate.type)) continue; + const matches = + options.match?.(normalizedText, candidate, metadata) ?? defaultMatch(normalizedText, candidate); + if (!matches) continue; + yield createHit(metadata, candidate); + hitCount += 1; + if (searchOptions.limit !== undefined && hitCount >= searchOptions.limit) return; + } + } + }, + }; +} diff --git a/packages/agent/test/harness/session/search.test.ts b/packages/agent/test/harness/session/search.test.ts new file mode 100644 index 000000000..883f58396 --- /dev/null +++ b/packages/agent/test/harness/session/search.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; +import { + InMemorySessionStorage, + type JsonlSessionListOptions, + JsonlSessionRepo, + type JsonlSessionRepoOptions, + Session, + type SessionMetadata, + type SessionStorage, +} from "../../../src/harness/session/index.ts"; +import { listJsonlSessionMetadata, loadJsonlSessionStorage } from "../../../src/harness/session/jsonl/repo.ts"; +import { createScanningSessionSearch } from "../../../src/search/index.ts"; +import type { AgentMessage } from "../../../src/types.ts"; + +interface WorkspaceMetadata extends SessionMetadata { + cwd: string; +} + +const tempDirs: string[] = []; + +function createTempDir(): string { + const directory = mkdtempSync(join(tmpdir(), "pi-agent-search-")); + tempDirs.push(directory); + return directory; +} + +afterEach(() => { + while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +function message(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; +} + +function createMemorySession(metadata: WorkspaceMetadata): Session { + return new Session( + new InMemorySessionStorage(metadata) as unknown as SessionStorage, + ); +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + +async function* jsonlReadables(options: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { + for (const metadata of await listJsonlSessionMetadata(options, query)) { + yield loadJsonlSessionStorage(options, metadata); + } +} + +describe("session search", () => { + it("scans an arbitrary in-memory projected source", async () => { + const root = createMemorySession({ id: "root", createdAt: 1, cwd: "/repo" }); + await root.appendMessage(message("fix auth flow")); + const other = createMemorySession({ id: "other", createdAt: 2, cwd: "/other" }); + await other.appendMessage(message("auth in another workspace")); + const search = createScanningSessionSearch([root, other]); + + expect("apply" in search).toBe(false); + expect(await collect(search.search("auth"))).toMatchObject([{ sessionId: "root" }, { sessionId: "other" }]); + expect(await collect(search.search("missing"))).toEqual([]); + }); + + it("includes labels in memory scanning projections", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const entryId = await session.appendMessage(message("plain body")); + await session.setLabel(entryId, "important label"); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("important"))).toMatchObject([{ sessionId: "session", entryId }]); + }); + + it("honors entry type filters and abort signals in scanning search", async () => { + const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); + const messageEntryId = await session.appendMessage(message("auth message")); + await session.appendCustomEntry("note", { text: "auth custom" }); + const search = createScanningSessionSearch([session]); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toMatchObject([ + { sessionId: "session", entryId: messageEntryId }, + ]); + + const controller = new AbortController(); + controller.abort(); + await expect(collect(search.search("auth", { signal: controller.signal }))).rejects.toMatchObject({ + name: "AbortError", + }); + }); + + it("scans JSONL sessions from disk through the JSONL scanning source", async () => { + const root = createTempDir(); + const options = { fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }; + const repository = new JsonlSessionRepo(options); + const cwd = join(root, "workspace"); + const otherCwd = join(root, "other"); + const session = await repository.create({ id: "jsonl", cwd }); + const entryId = await session.appendMessage(message("jsonl backed auth entry")); + await session.setLabel(entryId, "disk label"); + const other = await repository.create({ id: "other", cwd: otherCwd }); + const otherEntryId = await other.appendMessage(message("jsonl backed auth entry in another cwd")); + const search = createScanningSessionSearch((query?: JsonlSessionListOptions) => jsonlReadables(options, query)); + + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "jsonl", + entryId, + }), + expect.objectContaining({ + sessionId: "other", + entryId: otherEntryId, + }), + ]), + ); + expect(await collect(search.search("disk"))).toMatchObject([{ sessionId: "jsonl", entryId }]); + }); +}); diff --git a/packages/session-backends/sqlite-node/README.md b/packages/session-backends/sqlite-node/README.md index 40de9f804..e56f205f3 100644 --- a/packages/session-backends/sqlite-node/README.md +++ b/packages/session-backends/sqlite-node/README.md @@ -8,8 +8,15 @@ migrations, materialized views, and optional FTS search. await using repository = new SqliteSessionRepository(options); const search = createSqliteSessionSearch(options); const session = await repository.create({ cwd }); -const hits = await search.search({ text: "needle" }); +await session.appendMessage(message); + +const hits = []; +for await (const hit of search.search("needle")) hits.push(hit); ``` -The repository lazily owns one shared database connection. Search is an independent, -query-only projection over the same canonical database. +The repository lazily owns one shared database connection. Search is an independent +service over the same canonical database: repositories do not expose `search()`. +The FTS table and triggers are created lazily on the first non-blank search; when +FTS is first created, search performs a one-time rebuild from canonical entries. +After that, SQLite triggers keep FTS in sync with canonical entry inserts, deletes, +and payload updates. diff --git a/packages/session-backends/sqlite-node/src/index.ts b/packages/session-backends/sqlite-node/src/index.ts index 83552d1cc..14bd887c9 100644 --- a/packages/session-backends/sqlite-node/src/index.ts +++ b/packages/session-backends/sqlite-node/src/index.ts @@ -48,6 +48,15 @@ class NodeSqliteStatement implements SqliteStatement { : this.statement.all(...(params as SQLInputValue[])) ) as TRow[]; } + + iterate(...params: unknown[]): Iterable { + const [first, ...rest] = params; + return ( + isNamedParameters(first) + ? this.statement.iterate(first, ...(rest as SQLInputValue[])) + : this.statement.iterate(...(params as SQLInputValue[])) + ) as Iterable; + } } class NodeSqliteDatabase implements SqliteDatabase { diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts index cf6976dfd..6e28bfefc 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts @@ -1,5 +1,11 @@ -import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "@earendil-works/pi-agent-core"; -import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core"; +import type { + FileError, + Result, + SessionSearch, + SessionSearchHit, + SessionSearchOptions, +} from "@earendil-works/pi-agent-core"; +import { SessionError } from "@earendil-works/pi-agent-core"; import { applyMigrations } from "./migrations.ts"; import { sql } from "./sql.ts"; import { decodeSessionMetadata, type SessionRow } from "./storage/sessions.ts"; @@ -10,6 +16,14 @@ import type { SqliteSessionRepositoryEnv, } from "./types.ts"; +function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + function getParentPath(path: string): string { const normalized = path.replace(/[\\/]+$/, ""); const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); @@ -18,6 +32,14 @@ function getParentPath(path: string): string { return normalized.slice(0, lastSlash); } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + throw error; +} + function configureSqliteDatabase(db: SqliteDatabase): void { sql`PRAGMA journal_mode=WAL`.exec(db); sql`PRAGMA synchronous=FULL`.exec(db); @@ -36,9 +58,15 @@ function tableExists(db: SqliteDatabase, name: string): boolean { }>(db); } +function rebuildSearchIndex(db: SqliteDatabase): void { + sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.run(db); +} + function ensureSearchSchema(db: SqliteDatabase): void { const ftsExists = tableExists(db, "session_search_fts"); - sql` + const entriesExist = tableExists(db, "entries"); + db.transaction(() => { + sql` CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( payload, content = 'entries', @@ -56,11 +84,18 @@ CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON en INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); END; `.exec(db); - if (!ftsExists) sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.exec(db); + if (!ftsExists && entriesExist) rebuildSearchIndex(db); + }); +} + +export interface SqliteSessionSearchHit extends SessionSearchHit { + readonly metadata: SqliteSessionMetadata; + readonly timestamp: number; + readonly score: number; } /** SQLite FTS search over a co-located canonical session database. */ -class SqliteSessionSearch implements SessionSearch { +class SqliteSessionSearch implements SessionSearch { private readonly options: SqliteSessionSearchOptions; private databasePath: string | undefined; @@ -97,44 +132,63 @@ class SqliteSessionSearch implements SessionSearch { } } - async search(options: SessionSearchOptions): Promise[]> { - const text = options.text.trim(); - if (!text) return []; + async *search(text: string, options: SessionSearchOptions = {}): AsyncIterable { + const queryText = text.trim(); + if (!queryText || (options.limit !== undefined && options.limit <= 0)) return; + if (options.entryTypes?.length === 0) return; + throwIfAborted(options.signal); const db = await this.openDatabase(); try { - const query = `"${text.replaceAll('"', '""')}"`; - const cwd = options.cwd ?? null; - const rows = sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name, - se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score - FROM session_search_fts - JOIN entries AS se ON se.rowid = session_search_fts.rowid - JOIN sessions AS s ON s.id = se.session_id - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + const query = `"${queryText.replaceAll('"', '""')}"`; + const predicates = ["session_search_fts MATCH ?"]; + const params: unknown[] = [query]; + if (options.entryTypes !== undefined) { + predicates.push(`se.type IN (${options.entryTypes.map(() => "?").join(", ")})`); + params.push(...options.entryTypes); + } + const rows = db + .prepare( + `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, + name_fact.seq IS NOT NULL AS has_session_name, + name_fact.value AS session_name, + se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score + FROM session_search_fts + JOIN entries AS se ON se.rowid = session_search_fts.rowid + JOIN sessions AS s ON s.id = se.session_id + LEFT JOIN facts AS name_fact + ON name_fact.session_id = s.id + AND name_fact.kind = 'name' + AND name_fact.key IS NULL + AND name_fact.seq = ( + SELECT MAX(f.seq) + FROM facts AS f + WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL + ) + WHERE ${predicates.join(" AND ")} + ORDER BY score + LIMIT ?`, ) - WHERE session_search_fts MATCH ${query} AND (${cwd} IS NULL OR s.cwd = ${cwd}) - ORDER BY score`.all(db); + .iterate( + ...params, + options.limit ?? -1, + ); const path = await this.getDatabasePath(); - return rows.map((row) => ({ - metadata: decodeSessionMetadata(row, path), - entryId: row.entry_id, - timestamp: row.timestamp, - score: row.score, - })); + for (const row of rows) { + throwIfAborted(options.signal); + yield { + sessionId: row.id, + metadata: decodeSessionMetadata(row, path), + entryId: row.entry_id, + timestamp: row.timestamp, + score: row.score, + }; + } } finally { db.close(); } } } -export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { +export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { return new SqliteSessionSearch(options); } diff --git a/packages/session-backends/sqlite-node/src/sqlite/sql.ts b/packages/session-backends/sqlite-node/src/sqlite/sql.ts index 4eaec7f44..82c1d44f2 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/sql.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/sql.ts @@ -28,6 +28,10 @@ export class SqlQuery { all(db: SqliteDatabase): TRow[] { return db.prepare(this.queryText).all(...this.params); } + + iterate(db: SqliteDatabase): Iterable { + return db.prepare(this.queryText).iterate(...this.params); + } } /** Builds a parameterized query. Nested queries are inlined; other interpolations become `?` parameters. */ diff --git a/packages/session-backends/sqlite-node/src/sqlite/types.ts b/packages/session-backends/sqlite-node/src/sqlite/types.ts index 349a09d3d..6afadfd67 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/types.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/types.ts @@ -13,6 +13,7 @@ export interface SqliteStatement { run(...params: unknown[]): SqliteRunResult; get(...params: unknown[]): TRow | undefined; all(...params: unknown[]): TRow[]; + iterate(...params: unknown[]): Iterable; } /** SQLite database capability used by the SQLite session backend. */ diff --git a/packages/session-backends/sqlite-node/test/repository.test.ts b/packages/session-backends/sqlite-node/test/repository.test.ts index 1d179c684..151c0a42a 100644 --- a/packages/session-backends/sqlite-node/test/repository.test.ts +++ b/packages/session-backends/sqlite-node/test/repository.test.ts @@ -37,6 +37,8 @@ class ThrowingStatement implements SqliteStatement { all(..._params: unknown[]): TRow[] { return []; } + + *iterate(..._params: unknown[]): Iterable {} } class CountingDatabase implements SqliteDatabase { diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts index 497a5582c..bebffd6af 100644 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ b/packages/session-backends/sqlite-node/test/search.test.ts @@ -13,8 +13,14 @@ function createSqliteFixture(options: ConstructorParameters(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} + describe("SQLite FTS5 session search", () => { - it("matches trigrams within one cwd", async () => { + it("matches trigrams", async () => { const root = createTempDir(); const env = new NodeExecutionEnv({ cwd: root }); const sqlite = createNodeSqliteFactory(); @@ -25,23 +31,36 @@ describe("SQLite FTS5 session search", () => { const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); await included.setName("Canonical name"); - await excluded.appendMessage(createUserMessage("Find the auth defect")); + const excludedEntryId = await excluded.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ - entryId, - timestamp: expect.any(Number), - metadata: expect.objectContaining({ - id: "included", - createdAt: expect.any(Number), - name: "Canonical name", - metadata: { name: "application-owned" }, + const authHits = await collect(search.search("auth")); + expect(authHits).toHaveLength(2); + expect(authHits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + timestamp: expect.any(Number), + metadata: expect.objectContaining({ + id: "included", + createdAt: expect.any(Number), + name: "Canonical name", + metadata: { name: "application-owned" }, + }), }), - }), - ]); - await expect(search.search({ text: "uth", cwd: root })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }), - ]); + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); + expect(await collect(search.search("uth"))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: "included", + entryId, + metadata: expect.objectContaining({ id: "included" }), + }), + expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), + ]), + ); }); it("omits a cleared session name from search metadata", async () => { @@ -56,8 +75,8 @@ describe("SQLite FTS5 session search", () => { await session.setName("Temporary"); await session.setName(undefined); - const [result] = await search.search({ text: "auth" }); - expect(result).toMatchObject({ entryId, metadata: { id: "session-1" } }); + const [result] = await collect(search.search("auth")); + expect(result).toMatchObject({ sessionId: "session-1", entryId, metadata: { id: "session-1" } }); expect(result?.metadata).not.toHaveProperty("name"); }); @@ -70,7 +89,63 @@ describe("SQLite FTS5 session search", () => { }); const { search } = fixture; - await expect(search.search({ text: 'missing "phrase"' })).resolves.toEqual([]); + expect(await collect(search.search('missing "phrase"'))).toEqual([]); + }); + + it("rebuilds existing entries when FTS is first initialized", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId }), + ]); + }); + + it("honors entry type filters", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const messageEntryId = await session.appendMessage(createUserMessage("Find the auth defect")); + await session.appendCustomEntry("note", { text: "Find the auth custom entry" }); + + expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toEqual([ + expect.objectContaining({ sessionId: "session-1", entryId: messageEntryId }), + ]); + }); + + it("honors result limits", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + const first = await repository.create({ cwd: root, id: "session-1" }); + const second = await repository.create({ cwd: root, id: "session-2" }); + await first.appendMessage(createUserMessage("Find the auth defect")); + await second.appendMessage(createUserMessage("Find the auth defect too")); + + expect(await collect(search.search("auth", { limit: 1 }))).toHaveLength(1); + expect(await collect(search.search("auth", { limit: 0 }))).toEqual([]); }); it("removes deleted session entries from the index", async () => { @@ -85,11 +160,52 @@ describe("SQLite FTS5 session search", () => { const { repository, search } = fixture; const session = await repository.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toHaveLength(1); + expect(await collect(search.search("auth"))).toHaveLength(1); await repository.delete(await session.getMetadata()); - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("indexes and removes session entries through triggers after FTS initialization", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ + env, + sqlite: createNodeSqliteFactory(), + databasePath, + }); + const { repository, search } = fixture; + expect(await collect(search.search("auth"))).toEqual([]); + const session = await repository.create({ cwd: root, id: "session-1" }); + await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); + + await repository.delete(await session.getMetadata()); + + expect(await collect(search.search("auth"))).toEqual([]); + }); + + it("removes deleted entries from FTS through triggers", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const databasePath = join(root, "sessions.sqlite"); + await using fixture = createSqliteFixture({ env, sqlite, databasePath }); + const { repository, search } = fixture; + const session = await repository.create({ cwd: root, id: "session-1" }); + const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); + expect(await collect(search.search("auth"))).toHaveLength(1); + + const db = await sqlite.open(databasePath); + try { + await db.prepare("DELETE FROM entries WHERE session_id = ? AND id = ?").run("session-1", entryId); + } finally { + await db.close(); + } + + expect(await collect(search.search("auth"))).toEqual([]); }); it("does not initialize FTS for canonical writes or blank searches", async () => { @@ -99,7 +215,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await expect(search.search({ text: " " })).resolves.toEqual([]); + expect(await collect(search.search(" "))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -121,7 +237,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); const db = await sqlite.open(databasePath); @@ -142,7 +258,7 @@ describe("SQLite FTS5 session search", () => { const databasePath = join(root, "sessions.sqlite"); await using fixture = createSqliteFixture({ env, sqlite, databasePath }); const { repository: repo, search } = fixture; - await search.search({ text: "initialize" }); + await collect(search.search("initialize")); const session = await repo.create({ cwd: root, id: "session-1" }); await session.appendMessage(createUserMessage("must remain")); const metadata = await session.getMetadata(); @@ -168,7 +284,7 @@ describe("SQLite FTS5 session search", () => { databasePath: join(root, "sessions.sqlite"), }); - await expect(search.search({ text: "auth" })).rejects.toThrow("setup failed"); + await expect(collect(search.search("auth"))).rejects.toThrow("setup failed"); expect(counts.closes).toBe(1); }); @@ -182,12 +298,16 @@ describe("SQLite FTS5 session search", () => { }); const { repository: repo, search } = fixture; - await expect(search.search({ text: "auth" })).resolves.toEqual([]); + expect(await collect(search.search("auth"))).toEqual([]); const session = await repo.create({ cwd: root, id: "session-1" }); const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - await expect(search.search({ text: "auth" })).resolves.toEqual([ - expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "session-1" }) }), + expect(await collect(search.search("auth"))).toEqual([ + expect.objectContaining({ + sessionId: "session-1", + entryId, + metadata: expect.objectContaining({ id: "session-1" }), + }), ]); await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string"); });