Merge remote-tracking branch 'origin/main' into brennanb2025/codex-goal-visible

This commit is contained in:
Merge Sim
2026-09-10 22:35:50 -07:00
195 changed files with 5525 additions and 5610 deletions
-1
View File
@@ -103,7 +103,6 @@ docs/**
!docs/agent-skill-sharing-implementation-checklist.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/agent-session-search-query-tuning.md
!docs/reference/agent-status-store.md
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
@@ -1,192 +0,0 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../../src/main/ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine'
import type {
SessionSearchRequest,
SessionSearchScope
} from '../../src/main/ai-vault-search/session-search-engine-types'
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import type SyncDatabase from '../../src/main/sqlite/sync-database'
import {
writeSyntheticTranscriptCorpus,
type SyntheticCorpus,
type SyntheticCorpusOptions
} from '../../src/main/ai-vault-search/session-search-synthetic-corpus'
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
// What a query costs, and what the session candidate limit buys. Everything
// runs through the real store and the real engine over a synthetic corpus;
// never point this at a real transcript tree.
const WARMUP = 5
const SAMPLES = 25
// One query per rung the ladder can take, plus the two shapes that skip it.
const QUERIES: { name: string; request: SessionSearchRequest }[] = [
{ name: 'phrase', request: { query: '"terminal reattach"' } },
{ name: 'identifier', request: { query: 'resolveTerminalPath' } },
{ name: 'path', request: { query: 'src/main/ai-vault/session-transcript-reader.ts' } },
{ name: 'prose', request: { query: 'why is the daemon snapshot stale' } },
{ name: 'typo', request: { query: 'reattahc worktre' } },
{ name: 'common-term', request: { query: 'index' } },
{ name: 'operator-only', request: { query: 'repo:app-3' } },
{ name: 'scoped', request: { query: 'worktree', filters: { scopePaths: ['/repo/app-3'] } } }
]
type Timing = { p50: number; p95: number }
function percentile(sorted: readonly number[], fraction: number): number {
const at = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))
return Math.round((sorted[at] ?? 0) * 100) / 100
}
function timing(samples: number[]): Timing {
const sorted = [...samples].sort((left, right) => left - right)
return { p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) }
}
function time(engine: SessionSearchEngine, request: SessionSearchRequest): number {
const started = performance.now()
engine.search(request)
return performance.now() - started
}
async function indexCorpus(
options: SyntheticCorpusOptions
): Promise<{ corpus: SyntheticCorpus; db: SyncDatabase; release: () => void }> {
resetSessionParseCacheForTests()
const corpus = await writeSyntheticTranscriptCorpus(options)
const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => {
throw error
})
const unregister = registerSessionSearchIndexConsumer(store)
const stats = createSessionParseStats()
for (const path of corpus.files) {
await parseAgentSessionFileCached(
await sessionCandidate('claude', path),
process.platform,
stats
)
}
return {
corpus,
// The handle a composed reader gets. Every read here is one synchronous
// statement, which is the contract that comes with it.
db: store.connection,
release: () => {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
}
}
/** Per-query and overall latency for one scope. */
function scopeReport(db: SyncDatabase, scope: SessionSearchScope): Record<string, unknown> {
const engine = new SessionSearchEngine(db)
const everything: number[] = []
const perQuery: Record<string, Timing & { hits: number; route: string }> = {}
for (const { name, request } of QUERIES) {
const scoped = { ...request, scope }
for (let run = 0; run < WARMUP; run++) {
engine.search(scoped)
}
const samples = Array.from({ length: SAMPLES }, () => time(engine, scoped))
everything.push(...samples)
const result = engine.search(scoped)
perQuery[name] = { ...timing(samples), hits: result.hits.length, route: result.planner.route }
}
return { ...timing(everything), perQuery }
}
/**
* The candidate limit only costs anything once there are more matching sessions
* than the limit, so this runs over many short sessions rather than the wide
* corpus above. Limits are interleaved sample by sample: run back to back, the
* first configuration pays for every page the OS cache had not seen yet and the
* ordering alone moves p95 by more than the limit does.
*/
function candidateSweep(db: SyncDatabase, limits: readonly number[]): Record<string, unknown> {
const request: SessionSearchRequest = { query: 'index', limit: 20 }
const engines = new Map(
limits.map((limit) => [limit, new SessionSearchEngine(db, { sessionCandidateLimit: limit })])
)
const samples = new Map(limits.map((limit) => [limit, [] as number[]]))
for (let run = 0; run < WARMUP; run++) {
for (const engine of engines.values()) {
engine.search(request)
}
}
for (let run = 0; run < SAMPLES; run++) {
for (const limit of limits) {
samples.get(limit)!.push(time(engines.get(limit)!, request))
}
}
const report: Record<string, unknown> = {}
for (const limit of limits) {
const result = engines.get(limit)!.search(request)
report[String(limit)] = {
...timing(samples.get(limit)!),
truncated: result.truncated.candidates,
// Pages a caller could walk before the limit stops handing out sessions.
reachablePages: Math.ceil(limit / (request.limit ?? 20))
}
}
return report
}
const wide = await indexCorpus({ sessions: Number(process.env.SESSIONS ?? 40) })
let report: string
try {
const scope = {
all: scopeReport(wide.db, 'all'),
conversation: scopeReport(wide.db, 'conversation')
}
wide.release()
await rm(wide.corpus.root, { recursive: true, force: true })
// Many short sessions: what makes the candidate limit binding is the session
// count, not the byte count.
const many = await indexCorpus({ sessions: 2500, turnsPerSession: 1, seed: 7 })
try {
report = JSON.stringify(
{
scopeCorpus: {
sessions: wide.corpus.files.length,
transcriptMb: Math.round((wide.corpus.transcriptBytes / 1024 / 1024) * 100) / 100,
messages: wide.corpus.messageCount
},
scope,
candidateCorpus: {
sessions: many.corpus.files.length,
transcriptMb: Math.round((many.corpus.transcriptBytes / 1024 / 1024) * 100) / 100
},
candidateSweep: candidateSweep(many.db, [200, 600, 1200, 2400])
},
null,
2
)
} finally {
many.release()
await rm(many.corpus.root, { recursive: true, force: true })
}
} catch (error) {
await rm(wide.corpus.root, { recursive: true, force: true })
throw error
}
// Why a file as well as stdout: a runner that intercepts console output
// (vitest does) would otherwise swallow the whole report.
const out = process.env.BENCH_OUT
if (out) {
await writeFile(out, `${report}\n`)
}
console.log(report)
@@ -1,205 +0,0 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../../src/main/ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine'
import type {
SessionSearchRequest,
SessionSearchScope
} from '../../src/main/ai-vault-search/session-search-engine-types'
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
import type SyncDatabase from '../../src/main/sqlite/sync-database'
import { writeToolHeavyCorpus, type ToolHeavyCorpus } from './session-search-tool-heavy-corpus'
// What each scope costs on an index the size of a real transcript tree.
//
// The 10.5 MB corpus in `session-search-query-benchmark.ts` sizes the route
// ladder; this one sizes the corpus. `conversation` is a column filter over the
// one FTS table rather than a second table of its own, and the whole cost of
// that decision is how much of `messages_fts` a conversation query has to read
// past — which is set by how much of a transcript is tool output.
//
// Synthetic, always: this must never be pointed at a real transcript.
const WARMUP = 5
/** Conversation-shaped queries; every term is one the prose actually uses. */
const QUERIES = [
'terminal reattach',
'stale snapshot',
'daemon cursor',
'worktree index',
'publish transaction',
'relay daemon',
'session cursor',
'because stale',
'terminal worktree',
'index snapshot',
'reattach cursor',
'transaction relay',
'snapshot session',
'daemon publish',
'worktree terminal',
'cursor index',
'stale relay',
'session transaction',
'publish snapshot',
'reattach daemon'
]
async function indexCorpus(
corpus: ToolHeavyCorpus
): Promise<{ db: SyncDatabase; release: () => void }> {
resetSessionParseCacheForTests()
const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => {
throw error
})
const unregister = registerSessionSearchIndexConsumer(store)
const stats = createSessionParseStats()
for (const path of corpus.files) {
await parseAgentSessionFileCached(
await sessionCandidate('claude', path),
process.platform,
stats
)
}
return {
// The store's own handle, which is what a composed reader gets: every
// retrieval is one synchronous statement, so nothing pins a WAL snapshot.
db: store.connection,
release: () => {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
}
}
type Timing = { p50: number; p95: number }
function timing(samples: readonly number[]): Timing {
const sorted = [...samples].sort((left, right) => left - right)
const at = (fraction: number): number => {
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))
return Math.round((sorted[index] ?? 0) * 100) / 100
}
return { p50: at(0.5), p95: at(0.95) }
}
/**
* The query sets, one per rung of the ladder the engine may take.
*
* Which rung each one reaches is not forced, it is observed: samples are
* bucketed by the route the engine reports, so the table says what was measured
* rather than what was intended, and a query that lands on a different rung
* than expected shows up as a bucket rather than as a wrong number.
*/
function queries(): string[] {
const run = (index: number, length: number): string =>
Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ')
return [
// Two terms, unquoted: not literal, so straight to OR.
...QUERIES,
// Two terms, quoted: literal, and on this corpus any two of fourteen words
// sit next to each other somewhere, so the phrase rung answers.
...QUERIES.map((query) => `"${query}"`),
// Eight terms, quoted: an ordered run that long does not occur in 105 MB of
// draws from fourteen words, so the phrase rung misses and AND answers.
...QUERIES.map((_query, index) => `"${run(index, 4)}"`)
]
}
type Bucket = { samples: number[]; hits: number }
/**
* Both scopes over the same queries, interleaved scope by scope: run back to
* back, the first one pays for every page the OS cache had not seen and the
* ordering moves p95 more than the scope does.
*/
function scopeReport(db: SyncDatabase): Record<string, unknown> {
const engine = new SessionSearchEngine(db)
const scopes: SessionSearchScope[] = ['all', 'conversation']
const requests: SessionSearchRequest[] = queries().map((query) => ({ query }))
const buckets = new Map<string, Bucket>()
for (let run = 0; run < WARMUP; run++) {
for (const scope of scopes) {
for (const request of requests) {
engine.search({ ...request, scope })
}
}
}
for (const request of requests) {
for (const scope of scopes) {
const started = performance.now()
const result = engine.search({ ...request, scope })
const elapsed = performance.now() - started
const key = `${result.planner.route}/${scope}`
const bucket = buckets.get(key) ?? { samples: [], hits: 0 }
bucket.samples.push(elapsed)
bucket.hits += result.hits.length
buckets.set(key, bucket)
}
}
const report: Record<string, unknown> = {}
for (const [key, bucket] of [...buckets].sort(([left], [right]) => left.localeCompare(right))) {
report[key] = { ...timing(bucket.samples), samples: bucket.samples.length, hits: bucket.hits }
}
return report
}
/** Bytes the FTS table occupies, which is the cost the deleted second table saved. */
function indexBytes(db: SyncDatabase): Record<string, number> | { unavailable: string } {
try {
const sum = (where: string, ...values: string[]): number =>
Number(
(
db
.prepare(`SELECT COALESCE(SUM(pgsize),0) AS bytes FROM dbstat ${where}`)
.get(...values) as { bytes: number }
).bytes
)
return { total: sum(''), messagesFts: sum('WHERE name LIKE ?', 'messages_fts%') }
} catch {
// dbstat is a compile-time option; the latency numbers stand without it.
return { unavailable: 'no dbstat' }
}
}
const corpus = await writeToolHeavyCorpus({
targetBytes: Number(process.env.CORPUS_MB ?? 100) * 1024 * 1024,
toolShare: Number(process.env.TOOL_SHARE ?? 0.9)
})
let report: string
const indexed = await indexCorpus(corpus)
try {
report = JSON.stringify(
{
corpus: {
sessions: corpus.files.length,
transcriptMb: Math.round((corpus.transcriptBytes / 1024 / 1024) * 100) / 100,
toolShareOfMessageText:
Math.round((corpus.toolBytes / (corpus.toolBytes + corpus.proseBytes)) * 1000) / 1000
},
indexBytes: indexBytes(indexed.db),
route: scopeReport(indexed.db)
},
null,
2
)
} finally {
indexed.release()
await rm(corpus.root, { recursive: true, force: true })
}
const out = process.env.BENCH_OUT
if (out) {
await writeFile(out, `${report}\n`)
}
console.log(report)
@@ -1,152 +0,0 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// The corpus the scope benchmark runs over. Written here rather than by
// `session-search-synthetic-corpus.ts` because what it costs to answer a
// conversation query out of the one FTS table turns on the property that
// generator fixes: how much of a transcript is tool output.
//
// Synthetic, always. This must never be pointed at a real transcript.
const PROSE = [
'terminal',
'reattach',
'worktree',
'the',
'index',
'cursor',
'publish',
'transaction',
'relay',
'daemon',
'snapshot',
'because',
'stale',
'session'
]
// Tool output is paths, hashes and log lines — and the same words the
// conversation uses, because a `rg` over this repository prints them. That
// overlap is what the benchmark turns on: it is what makes a conversation
// term's posting list carry rows the column filter then has to discard. A tool
// vocabulary disjoint from the prose would leave nothing to discard and measure
// the wrong thing.
const TOOL_ONLY = [
'src/main/ai-vault/session-transcript-reader.ts',
'node_modules/.pnpm/typescript@5.9.2',
'0x00007ff8',
'ENOENT',
'drwxr-xr-x',
'2026-09-10T00:00:00.000Z',
'sha256:9f2c1a',
'chunk-VHQ4NWQK.js',
'warning:',
'resolveTerminalPath',
'byteOffset',
'MAX_RETRIES'
]
// Half the tool tokens are conversation words. Deliberately pessimistic: the
// more of a query term lives in `tool_text`, the more the column filter costs,
// so a number measured here holds on a real transcript tree.
const TOOL = [...PROSE, ...TOOL_ONLY]
function mulberry32(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = Math.imul(state ^ (state >>> 15), 1 | state)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function words(random: () => number, vocabulary: readonly string[], count: number): string {
const out: string[] = []
for (let index = 0; index < count; index++) {
out.push(vocabulary[Math.floor(random() * vocabulary.length)]!)
}
return out.join(' ')
}
export type ToolHeavyCorpus = {
root: string
files: string[]
transcriptBytes: number
toolBytes: number
proseBytes: number
}
/**
* Claude JSONL transcripts whose tool output is `toolShare` of the message text.
* One turn is a user question, an assistant answer, a tool call and its output;
* only the last one grows with the share.
*/
export async function writeToolHeavyCorpus(args: {
targetBytes: number
toolShare: number
seed?: number
}): Promise<ToolHeavyCorpus> {
const random = mulberry32(args.seed ?? 11)
const root = await mkdtemp(join(tmpdir(), 'orca-search-convfts-'))
const files: string[] = []
const proseWordsPerTurn = 160
// Tool and prose words are not the same length, so the share is over bytes.
const proseBytesPerTurn = proseWordsPerTurn * 6
const toolWordCount = Math.max(
1,
Math.round((proseBytesPerTurn * args.toolShare) / (1 - args.toolShare) / 22)
)
let transcriptBytes = 0
let toolBytes = 0
let proseBytes = 0
for (let session = 0; transcriptBytes < args.targetBytes; session++) {
const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}`
const lines: string[] = []
for (let turn = 0; turn < 40; turn++) {
const at = new Date(1740000000000 + turn * 60_000).toISOString()
const question = words(random, PROSE, 40)
const answer = words(random, PROSE, proseWordsPerTurn - 40)
const output = words(random, TOOL, toolWordCount)
proseBytes += Buffer.byteLength(question) + Buffer.byteLength(answer)
toolBytes += Buffer.byteLength(output)
lines.push(
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
cwd: `/repo/app-${session % 7}`,
gitBranch: 'main',
message: { role: 'user', content: question }
}),
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: at,
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [
{ type: 'text', text: answer },
{ type: 'tool_use', name: 'Bash', input: { command: 'rg needle' } }
]
}
}),
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: output }]
}
})
)
}
const path = join(root, `${sessionId}.jsonl`)
const body = `${lines.join('\n')}\n`
await writeFile(path, body)
transcriptBytes += Buffer.byteLength(body)
files.push(path)
}
return { root, files, transcriptBytes, toolBytes, proseBytes }
}
@@ -1,218 +0,0 @@
# Agent session search: query tuning
What a search costs, and what the knobs in `src/main/ai-vault-search/session-search-engine.ts`
buy. Every number here comes from `config/scripts/session-search-query-benchmark.ts`
over the synthetic corpus in `session-search-synthetic-corpus.ts`, except the
`conversation_fts` shoot-out, which writes its own corpus because the answer
turns on how much of a transcript is tool output. Nothing in this file was
measured against a real transcript, and neither benchmark must ever be pointed
at one.
## Running it
The benchmark is a top-level-await module that imports the main-process tree by
extensionless path, so it needs a bundler-backed runner rather than bare `node`:
```sh
cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF'
import { it } from 'vitest'
it('runs', { timeout: 1_800_000 }, async () => {
await import('../../../config/scripts/session-search-query-benchmark')
})
EOF
BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts
rm src/main/ai-vault-search/zz-bench.test.ts
```
The `conversation_fts` shoot-out below runs the same way, importing
`config/scripts/session-search-conversation-fts-benchmark` instead, with
`CORPUS_MB` and `TOOL_SHARE` to size and shape its corpus. `config/scripts` is
not inside any typecheck project, so while that throwaway test exists `tsc`
reports TS6307 for each script it pulls in; delete it and the run is clean
again.
`BENCH_OUT` exists because vitest intercepts `console.log`; the report is written
to that path as well as printed.
## Scope: what the second FTS table buys a reader
Corpus: 40 synthetic Claude transcripts, 10.5 MB, 9,600 messages, indexed through
the real store. Eight queries, one per rung of the route ladder plus the two
shapes that skip it; 5 warm-up runs and 25 samples each. Apple silicon, warm page
cache, machine otherwise idle. Milliseconds, and p95 over 25 samples moves
several milliseconds run to run if anything else is competing for the disk.
| Scope | p50 | p95 |
| -------------- | ---- | ---- |
| `all` | 7.22 | 8.94 |
| `conversation` | 5.33 | 7.86 |
Per query, `all` then `conversation` (p50 / p95):
| Query | `all` | `conversation` |
| ------------------------------------------------ | ------------ | -------------- |
| `"terminal reattach"` (phrase) | 5.24 / 8.42 | 2.97 / 3.24 |
| `resolveTerminalPath` (identifier) | 7.55 / 8.94 | 6.47 / 6.72 |
| `src/main/…/session-transcript-reader.ts` (path) | 8.69 / 10.12 | 7.78 / 8.04 |
| `why is the daemon snapshot stale` (prose) | 7.84 / 8.57 | 5.90 / 7.01 |
| `reattahc worktre` (typo repair) | 7.30 / 7.39 | 5.53 / 5.89 |
| `index` (common term) | 5.45 / 5.66 | 3.81 / 4.02 |
| `repo:app-3` (operator only) | 0.12 / 0.16 | 0.10 / 0.10 |
| `worktree` scoped to one cwd | 1.47 / 1.63 | 1.25 / 1.49 |
Reading it:
- `conversation` is about 1.4x faster at p50 and 1.1x at p95, and it is a column
filter over the same table rather than a table of its own. Narrowing to the
two prose columns is what buys the gap: fewer postings to score. It is also
the scope where a match is something a person wrote rather than something a
tool printed.
- A `scopePaths` query is the cheapest real search on the page. It is the one
narrowing SQL can express exactly, so it seeks `sessions_cwd_key` and hands
ranking a small candidate set.
- The operator-only figure is a floor, not a typical cost. `repo:` and `path:`
are applied in JS over retrieved rows (see `session-search-row-filter` for why
they cannot be pushed into SQL), so their cost tracks how many sessions the
walk has to read before it fills a candidate set. This corpus has 40 sessions,
which is one page of that walk; an index where few sessions match the operator
will read up to the ceiling in `session-search-retrieval` instead.
## What the conversation scope costs at real corpus size
`conversation` was a second FTS table holding a copy of the two prose columns.
It is a column filter now — `{user_text assistant_text}: (…)` with bm25 weights
that zero the other two — and PR 2 deleted the table on the strength of the
shoot-out this section used to hold: the filter came in at 1.16-1.36x the p95 of
the dedicated table, under the 2x bar, while the table cost a tenth of the index
to maintain. What follows is what the shipped schema actually does, measured
again on the same corpus after the table went and tool rows were capped.
Corpus: Claude transcripts from `config/scripts/session-search-tool-heavy-corpus.ts`,
105 MB, indexed through the real store, at two points in the 80-97% band a real
transcript tree sits in. Half the tokens in tool output are words the
conversation also uses, so a conversation term really does have postings the
filter must discard. Twenty queries per rung, both scopes interleaved query by
query, warm cache; `config/scripts/session-search-scope-benchmark.ts`, run twice.
| Tool share | Rung | `all` p50 / p95 | `conversation` p50 / p95 |
| ---------- | ------ | --------------- | ------------------------ |
| 86% | phrase | 16.69 / 17.48 | 13.08 / 13.52 |
| 86% | or | 31.91 / 35.74 | 22.25 / 23.87 |
| 86% | and | 70.04 / 74.00 | 53.47 / 59.39 |
| 93% | phrase | 9.14 / 13.36 | 7.23 / 8.51 |
| 93% | or | 16.46 / 18.70 | 12.34 / 14.88 |
| 93% | and | 39.65 / 43.44 | 31.05 / 32.92 |
Three things to read out of it.
**The filter is a win, not a cost.** Every rung is faster narrow than wide, by
1.2x to 1.4x at p50. The shoot-out compared the filter against a table built for
exactly this query; against the wide table it replaces, it does what the second
table did, which is read fewer postings.
**The `and` rung is where the corpus size shows.** Those queries are eight terms,
chosen so no ordered run that long occurs and the phrase rung has to miss; a
real two-term AND sits nearer the phrase row. It is also the noisiest: the
second run's p95 reached 140 ms on one bucket, which is what twenty samples of a
70 ms query buys. Read the p50 column.
**The index is far smaller than the shoot-out's was.** 57 MB at 93% tool output
and 103 MB at 86%, against roughly 150 MB for `messages_fts` alone before PR 2
capped an indexed tool row at 3,072 characters. Most of a tool-heavy transcript
is now not in the index at all, which moves every number above and is the larger
effect of the two.
What is **not** measured here is relevance, and the column filter does carry one
ranking difference the deleted table did not. FTS5's bm25 normalises by the
whole row's length and has no per-column length, so two rows with identical
prose score differently when one also holds tool output. The rowid set is
unchanged, which is what the deletion was decided on; the order within it can
move. `session-search-engine.test.ts` pins the direction.
## `sessionCandidateLimit`
The reviewer's F13: this is a tunable default, not a constant. It bounds how many
sessions the SQL hands ranking, so it bounds both retrieval cost and how deep a
caller can page before the answer simply stops.
The limit only costs anything once more sessions match than the limit allows, so
this is measured over a second corpus: 2,500 one-turn transcripts, 10.9 MB, every
one of them matching the query. Limits are interleaved sample by sample, because
run back to back the first configuration pays for every page the OS cache had not
seen and the ordering alone moves p95 further than the limit does.
| Limit | p50 | p95 | Pages of 20 a caller can reach |
| ----- | ----- | ----- | ------------------------------ |
| 200 | 6.85 | 7.21 | 10 |
| 600 | 7.93 | 8.36 | 30 |
| 1200 | 9.55 | 10.53 | 60 |
| 2400 | 12.32 | 13.45 | 120 |
600 is the default: it costs about 16% over 200 at p50 and buys three times the
reachable depth, and the curve only turns steep past 1200. A host with a much
larger index can raise it; the result's `truncated.candidates` says when the limit
was the thing that cut the answer, so a caller never has to guess.
What is **not** measured here is relevance. These numbers say what a limit costs,
not what it retrieves. The MRR figures quoted in the BM25 weights
(`session-search-retrieval.ts`) and in the identifier shadow column
(`session-search-identifier-split.ts`) come from the original retrieval shoot-out
on real transcripts and are not reproducible from this repository. Any change to
the limit justified on relevance grounds needs an eval set, not this benchmark.
## What typo repair costs
The repair is the one rung whose cost tracks the size of the vocabulary rather
than the size of a result. It only runs for a term the scope has no posting for,
so an ordinary query never pays it; a query of nonsense pays it once per term.
Measured over a synthetic vocabulary of 1.6 M distinct terms, every term in two
rows so none is filtered out:
| Query | p50 |
| -------------------------------------- | ------ |
| one known term (no repair) | 11 ms |
| one unknown term | 10 ms |
| 39 unknown 12-character terms (480 ch) | 387 ms |
| 12 unknown 40-character terms | 99 ms |
Two things follow. The cost is linear in unknown terms and in vocabulary size,
and `search` is synchronous, so a 512-character query of nonsense holds the
thread for a third of a second on an index that large. And the scoped-count fix
made this cheaper rather than dearer — it was 737 ms before — because ordering
the vocabulary scan by term drops the sort that ordering by `doc` required, and
the counts it added are at most eight bounded probes per prefix. A cap on
unknown terms per query is recorded as a follow-up in the split plan.
## Page warmup, dropped
PR 2 deferred `warm()` — a sliced read of `messages` that pulls its pages into
the OS cache before the first query — to whoever knew which pages a read
touches. It is not re-added here, for two reasons. The measurement that
justified it (first query 1.3 s to 0.45 s) was on a 4 GB index, and neither
corpus in this file is within an order of magnitude of that, so PR 4 cannot
show a win: removing the call moved the 10.5 MB corpus's p50 by less than the
run-to-run spread. And it is a cancellable background pass, which needs an owner
with a lifecycle; a query library that holds no timers has nothing to hang the
`stopped()` on, and a fire-and-forget async read from a synchronous `search` is
a rejection nothing can supervise. It belongs with the indexer in PR 3b, which
already owns starting and stopping work.
## Not settled here
Which process may open, unlink and rebuild the index is PR 3b's decision. A
second handle that finds an older schema version replaces the file while a live
store keeps answering from the unlinked inode, and this PR is what first makes
that reachable, because it is the first thing that reads. What PR 4 does is
refuse to make it worse. The engine carries its own schema — the vocabulary, the
query log and the generation triggers — and re-creates whatever of it is missing
on every search, so a dropped object heals rather than degrading.
The one it cannot re-create is the vocabulary's source, because `messages_fts`
is the store's. With one FTS table that is also the end of the degrade: there is
no second corpus to answer from, so an engine over an index mid-rebuild names
typo repair as unavailable and then fails on the table it cannot read, which is
the honest outcome — an empty page would read as an answer. `unavailable` can
therefore no longer be reported alongside a successful search, and PR 5 should
decide whether the field survives into the contract; it becomes reachable again
the day something opens the index read-only.
@@ -0,0 +1,24 @@
// Why injected rather than the globals: every freshness guarantee this indexer
// makes is "within one reconcile interval", and a guarantee stated in wall time
// is only a claim until a test can advance the clock and watch it hold.
/** Opaque to the indexer; a fake clock hands back whatever it likes. */
export type SessionSearchTimerHandle = object | number
export type SessionSearchClock = {
now(): number
setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle
clearTimeout(handle: SessionSearchTimerHandle): void
}
export const systemSessionSearchClock: SessionSearchClock = {
now: () => Date.now(),
setTimeout: (callback, ms) => {
const timer = setTimeout(callback, ms)
// Nothing here should hold the process open: the index is a cache, and a
// pending reconcile is never a reason to keep a CLI or a child alive.
timer.unref?.()
return timer
},
clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout)
}
@@ -0,0 +1,88 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { SessionSearchDirectoryReader } from './session-search-directory-listings'
/** A scan root this pass could not read through, and what stopped it. */
export type SessionSearchDegradedRoot = { root: string; reason: string }
/**
* Roots a pass could not read, derived from that pass alone.
*
* There is no root-health state machine any more and nothing is carried between
* passes: "degraded" now means one of two things this pass observed, both of
* which are readdir results.
*
* 1. Discovery recorded a scan issue against the root itself — a stalled WSL
* distro, a gate refusal, an unreadable tree.
* 2. The retirement walk could not prove a file the index holds under that root
* either present or gone, because a directory between the file and the root
* refused to list, or because the root itself is not there.
* 3. A root that yielded no transcripts refuses to list at all. The file walker
* swallows a readdir failure and returns, so without this an EACCES root and
* an agent that was never installed both arrive as "no files" — reporting
* the first as an empty index is the loss-of-contact-as-absence mistake
* docs/reference/ssh-execution-boundary.md forbids.
*
* The second is what reports a detached volume, and it needs no memory of
* previous passes: the evidence is the index's own rows plus this pass's
* readdir errors. A root the index holds nothing under and cannot list is
* reported by the third; a root that is simply missing is not reported at all,
* because that is what an agent nobody installed looks like.
*/
export function scanIssueDegradedRoots(
roots: readonly string[],
issues: readonly AiVaultScanIssue[]
): SessionSearchDegradedRoot[] {
const degraded = new Map<string, string>()
for (const issue of issues) {
// 'notice' rows are scanner commentary; a per-file failure is not a root's.
if (issue.kind !== 'notice' && roots.includes(issue.path)) {
degraded.set(issue.path, issue.message)
}
}
return [...degraded].map(([root, reason]) => ({ root, reason }))
}
/** One entry per root, first reason kept, so a pass reports each root once. */
export function mergeDegradedRoots(
...groups: readonly (readonly SessionSearchDegradedRoot[])[]
): SessionSearchDegradedRoot[] {
const merged = new Map<string, string>()
for (const group of groups) {
for (const degraded of group) {
if (!merged.has(degraded.root)) {
merged.set(degraded.root, degraded.reason)
}
}
}
return [...merged].map(([root, reason]) => ({ root, reason }))
}
// A missing root is not a broken one: an uninstalled agent's root answers
// exactly this, and the index holding rows under it is what the retirement
// walk reports instead.
const MISSING_ROOT = new Set(['ENOENT', 'ENOTDIR'])
/**
* Roots that yielded no transcripts and cannot be listed either.
*
* Only roots a pass found empty are read: one that returned files is readable
* by construction. The read shares the pass's listing cache, so a root the
* retirement walk also has to ask about costs one readdir between them.
*/
export async function unreadableRoots(
roots: readonly string[],
listings: SessionSearchDirectoryReader,
signal?: AbortSignal
): Promise<SessionSearchDegradedRoot[]> {
const degraded: SessionSearchDegradedRoot[] = []
for (const root of roots) {
if (signal?.aborted) {
break
}
const listing = await listings.namesIn(root, signal)
if (!listing.listed && !(listing.code !== null && MISSING_ROOT.has(listing.code))) {
degraded.push({ root, reason: listing.message })
}
}
return degraded
}
@@ -0,0 +1,356 @@
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { retireDeletedSessionSearchSources } from './session-search-deleted-sources'
import {
SessionSearchDirectoryListings,
type SessionSearchDirectoryListing,
type SessionSearchDirectoryReader
} from './session-search-directory-listings'
import {
openSessionSearchIndexerHarness,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
import { SessionSearchStore } from './session-search-store'
// The invariants this file exists to pin are written at the top of
// session-search-deleted-sources.ts. Each one is named in the tests below.
const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0
let harness: SessionSearchIndexerHarness
let store: SessionSearchStore
let removed: string[]
beforeEach(async () => {
resetTranscriptConsumersForTests()
harness = await openSessionSearchIndexerHarness('ss-deleted-sources')
removed = []
store = new SessionSearchStore(harness.databasePath)
// Only the removal matters here; the store's own removal path has its own tests.
store.removeFile = (path: string) => removed.push(path)
})
afterEach(async () => {
store.close()
await harness.cleanup()
})
/** A reader that answers with whatever a stalled mount would, per directory. */
function readerAnswering(
answers: Record<string, SessionSearchDirectoryListing>
): SessionSearchDirectoryReader {
return {
namesIn: (directory) =>
Promise.resolve(
answers[directory] ?? { listed: false, code: 'ENOENT', message: 'no such directory' }
)
}
}
function retire(
paths: readonly string[],
options: {
roots?: readonly string[]
emptiedRoots?: ReadonlySet<string>
enumeratedContainers?: ReadonlyMap<string, ReadonlySet<string>>
listings?: SessionSearchDirectoryReader
directoryLimit?: number
} = {}
) {
return retireDeletedSessionSearchSources({
store,
paths,
roots: options.roots ?? [harness.roots.claudeProjectsDir ?? ''],
emptiedRoots: options.emptiedRoots,
enumeratedContainers: options.enumeratedContainers,
listings: options.listings ?? new SessionSearchDirectoryListings(),
directoryLimit: options.directoryLimit
})
}
// I4: a file the user deleted retires on the first pass that proves it, with no
// waiting period, because its directory listed and it was not in the listing.
it('retires a deleted file the moment its own directory lists without it', async () => {
const kept = join(harness.claudeProjectDir, 'kept.jsonl')
await mkdir(harness.claudeProjectDir, { recursive: true })
await writeFile(kept, '{}')
const deleted = join(harness.claudeProjectDir, 'deleted.jsonl')
const result = await retire([kept, deleted])
expect(result.retired).toEqual([deleted])
expect(removed).toEqual([deleted])
// A file that is still there is settled, not watched: it is neither retired
// nor carried into the next pass as unfinished business.
expect(result.unverifiable).toEqual([])
expect(result.degradedRoots).toEqual([])
})
// I4, the other shape: the directory itself is gone, so the question moves up
// one level and the root answers it.
it('retires a whole project directory the user deleted', async () => {
const sibling = join(harness.roots.claudeProjectsDir ?? '', 'other', 'kept.jsonl')
await mkdir(join(harness.roots.claudeProjectsDir ?? '', 'other'), { recursive: true })
await writeFile(sibling, '{}')
const gone = join(harness.claudeProjectDir, 'inside-a-deleted-project.jsonl')
const result = await retire([gone])
expect(result.retired).toEqual([gone])
expect(result.unverifiable).toEqual([])
})
// I1 and I2: a root that is not there proves nothing. The walk stops at the
// configured root and never asks what is above it, so a home directory on an
// unmounted volume — the shape a detached drive or a dropped SSH mount takes —
// leaves every row exactly where it was.
it('keeps every row under a root that is not there', async () => {
const root = harness.roots.claudeProjectsDir ?? ''
const held = [join(harness.claudeProjectDir, 'one.jsonl'), join(root, 'flat.jsonl')]
const result = await retire(held)
expect(result.retired).toEqual([])
expect(result.unverifiable).toEqual(held)
// The root is named, once, so a caller can say which tree is unreachable.
expect(result.degradedRoots).toEqual([{ root, reason: `${root} could not be listed.` }])
})
// I3: the same answer with no memory at all. Nothing here is carried from a
// previous pass, which is what makes the first sweep after a restart — when a
// volume is most likely to be missing — behave like every other pass.
it('keeps a missing root on a pass that has seen nothing before it', async () => {
const root = harness.roots.claudeProjectsDir ?? ''
const held = join(harness.claudeProjectDir, 'one.jsonl')
const first = await retire([held], { emptiedRoots: new Set() })
const second = await retire([held], { emptiedRoots: new Set() })
expect([first.retired, second.retired]).toEqual([[], []])
expect(second.degradedRoots.map((one) => one.root)).toEqual([root])
})
// I2: an unreadable directory is not an empty one. EACCES stops the walk where
// it is rather than being walked up like a missing component.
it.skipIf(!CAN_DENY_READ)('keeps rows under a directory that refuses to list', async () => {
const blocked = join(harness.roots.claudeProjectsDir ?? '', 'blocked')
await mkdir(blocked, { recursive: true })
const hidden = join(blocked, 'hidden.jsonl')
await writeFile(hidden, '{}')
await chmod(blocked, 0o000)
try {
const result = await retire([hidden])
expect(result.retired).toEqual([])
expect(result.unverifiable).toEqual([hidden])
expect(result.degradedRoots.map((one) => one.root)).toEqual([harness.roots.claudeProjectsDir])
} finally {
await chmod(blocked, 0o755)
}
})
// I2, without needing a filesystem that can produce it: a stalled network mount
// answers EIO or a WSL gate refusal, and neither is ENOENT. This is the SSH and
// WSL case — loss of contact is never evidence of absence.
it('keeps rows when a directory answers with a transport failure', async () => {
const root = harness.roots.claudeProjectsDir ?? ''
const held = join(harness.claudeProjectDir, 'one.jsonl')
for (const listing of [
{ listed: false as const, code: 'EIO', message: 'input/output error' },
{ listed: false as const, code: 'ETIMEDOUT', message: 'the mount stopped answering' },
{ listed: false as const, code: null, message: 'The distro stopped responding.' }
]) {
const result = await retire([held], {
listings: readerAnswering({ [harness.claudeProjectDir]: listing })
})
expect(result.retired).toEqual([])
expect(result.degradedRoots).toEqual([{ root, reason: listing.message }])
}
})
// The one bit of memory, and the only thing it buys: a root that held
// transcripts on the previous pass and lists empty on this one gets one pass of
// grace, so a directory swapped out for a moment cannot retire a tree.
it('holds a root that went from holding transcripts to empty in one pass', async () => {
const root = harness.roots.claudeProjectsDir ?? ''
await mkdir(root, { recursive: true })
const held = join(harness.claudeProjectDir, 'one.jsonl')
const grace = await retire([held], { emptiedRoots: new Set([root]) })
expect(grace.retired).toEqual([])
expect(grace.unverifiable).toEqual([held])
// The next pass has no transition to point at, so the empty listing is what
// it says it is: the user emptied the root.
const after = await retire([held], { emptiedRoots: new Set() })
expect(after.retired).toEqual([held])
})
// A flat-layout agent, where the mountpoint IS the session directory, is the
// one shape the grace exists for: there is no intermediate directory whose
// absence could stop the walk.
it('holds a flat root that emptied in one pass, and retires it on the next', async () => {
const root = harness.roots.copilotSessionsDir ?? ''
await mkdir(root, { recursive: true })
const held = join(root, 'session.jsonl')
expect((await retire([held], { roots: [root], emptiedRoots: new Set([root]) })).retired).toEqual(
[]
)
expect((await retire([held], { roots: [root] })).retired).toEqual([held])
})
// OpenClaw's discovery merges two directories into one delimiter-joined label.
// Roots reach this function as the real directories behind that label, so one
// of them being unreachable never touches the other's rows.
it('judges each merged-root directory on its own', async () => {
const current = join(harness.roots.openclawStateDir ?? '', 'agents')
const legacy = join(harness.roots.openclawLegacyStateDir ?? '', 'agents')
const onMissing = join(current, 'main', 'sessions', 'mounted.jsonl')
const deleted = join(legacy, 'main', 'sessions', 'deleted.jsonl')
await mkdir(join(legacy, 'main', 'sessions'), { recursive: true })
const result = await retire([onMissing, deleted], { roots: [current, legacy] })
expect(result.retired).toEqual([deleted])
expect(result.unverifiable).toEqual([onMissing])
expect(result.degradedRoots.map((one) => one.root)).toEqual([current])
})
// A row under no configured root is judged by its own directory and nothing
// above it, so a moved profile is never retired on the strength of a root that
// no longer covers it.
it('judges a row under no configured root by its own directory', async () => {
const orphanDir = join(harness.root, 'moved-profile')
await mkdir(orphanDir, { recursive: true })
const gone = join(orphanDir, 'gone.jsonl')
const present = join(orphanDir, 'present.jsonl')
await writeFile(present, '{}')
const result = await retire([gone, present], { roots: [] })
expect(result.retired).toEqual([gone])
// No configured root owns it, so nothing is reported as degraded for it.
expect(result.degradedRoots).toEqual([])
})
// I8. A synthetic row names a container and an entry inside it. Walking the
// row's own path would report every one of them gone, and walking only the
// container proves nothing about the entry: a session deleted inside a database
// that is still there would never be retired at all.
it('proves a synthetic row against its container, not against its own path', async () => {
const db = join(harness.root, 'opencode.db')
await writeFile(db, '')
const kept = `${db}#session-1`
const deleted = `${db}#session-2`
const enumeratedContainers = new Map([[db, new Set(['session-1'])]])
const result = await retire([kept, deleted], { roots: [], enumeratedContainers })
expect(result.retired).toEqual([deleted])
expect(result.unverifiable).toEqual([])
})
it('keeps a synthetic row when this pass did not enumerate its container', async () => {
const db = join(harness.root, 'opencode.db')
await writeFile(db, '')
const row = `${db}#session-1`
// A cycle asks for the newest N per agent, so a row it did not return may be
// the one after them. It enumerates nothing and therefore proves nothing.
await expect(retire([row], { roots: [] })).resolves.toMatchObject({
retired: [],
unverifiable: [row]
})
// An enumeration that returned nothing at all is not evidence either: a
// database whose schema this scanner no longer recognises reads as empty
// with no error, and believing it would retire every session in one pass.
await expect(
retire([row], { roots: [], enumeratedContainers: new Map([[db, new Set<string>()]]) })
).resolves.toMatchObject({ retired: [], unverifiable: [row] })
})
it('retires a synthetic row when the container it came from is gone', async () => {
const db = join(harness.root, 'opencode.db')
await writeFile(db, '')
const row = `${db}#session-1`
const enumeratedContainers = new Map([[db, new Set(['session-1'])]])
await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({
retired: []
})
await rm(db)
await expect(retire([row], { roots: [], enumeratedContainers })).resolves.toMatchObject({
retired: [row]
})
})
// Nothing in this PR can hold a synthetic row: the index pass refuses a source
// whose parser decodes its messages where the message channel cannot reach
// them, and OpenCode's SQLite sessions are read on a worker thread. The rule
// above is the guard for the day that changes -- without it the walk would read
// `<db>#<id>` as a filename and retire every such row the moment it appeared.
it('does not index a source whose messages the channel cannot reach', () => {
const db = join(harness.root, 'opencode.db')
expect(
parserPublishesMessages({
agent: 'opencode',
codexHome: null,
file: { path: `${db}#session-1`, mtimeMs: 1, modifiedAt: '', sizeBytes: 0 }
})
).toBe(false)
})
// Round 12, F1. The cap counts directories because that is what costs: rows
// sharing one are a single read and then map lookups.
it('caps the directories one pass reads, not the rows it answers', async () => {
const roots = [harness.claudeProjectDir]
const inside = (folder: string, name: string): string =>
join(harness.claudeProjectDir, folder, name)
for (const folder of ['one', 'two', 'three']) {
await mkdir(join(harness.claudeProjectDir, folder), { recursive: true })
}
// Four rows in each of three directories: three reads, twelve answers.
const paths = ['one', 'two', 'three'].flatMap((folder) =>
['a', 'b', 'c', 'd'].map((name) => inside(folder, name))
)
const result = await retire(paths, { roots, directoryLimit: 2 })
// Two directories' worth answered, all eight of their rows, and the third
// directory's four left for the pass after this one.
expect(result.retired).toEqual(paths.slice(0, 8))
expect(result.unchecked).toEqual(paths.slice(8))
})
// The starvation this replaced: an unreadable directory answers `unverifiable`
// for every row under it and never becomes readable, so a cap on rows let one
// such directory hold the walk for as long as the permission stayed wrong.
it.skipIf(!CAN_DENY_READ)(
'is not starved by many rows under one unreadable directory',
async () => {
const locked = join(harness.claudeProjectDir, 'locked')
await mkdir(locked, { recursive: true })
const blocked = Array.from({ length: 520 }, (_unused, index) =>
join(locked, `locked-${index}.jsonl`)
)
const deleted = join(harness.claudeProjectDir, 'deleted.jsonl')
await chmod(locked, 0o000)
try {
const result = await retire([...blocked, deleted], { directoryLimit: 512 })
expect(result.retired).toEqual([deleted])
expect(result.unverifiable).toHaveLength(blocked.length)
expect(result.unchecked).toEqual([])
} finally {
await chmod(locked, 0o700)
}
}
)
it('reads each directory once however many files it is asked about', async () => {
await mkdir(harness.claudeProjectDir, { recursive: true })
const listings = new SessionSearchDirectoryListings()
await retire(
Array.from({ length: 50 }, (_unused, index) =>
join(harness.claudeProjectDir, `gone-${index}.jsonl`)
),
{ listings }
)
expect(listings.size).toBe(1)
})
@@ -0,0 +1,263 @@
import { basename, dirname } from 'node:path'
import type { SessionSearchDegradedRoot } from './session-search-degraded-roots'
import type { SessionSearchDirectoryReader } from './session-search-directory-listings'
import { isUnderScanRoot } from './session-search-scan-roots'
import { splitSyntheticSessionSource } from './session-search-synthetic-sources'
import type { SessionSearchStore } from './session-search-store'
/*
* Retirement invariants. Every one of these is a test; changing this file means
* changing the list, not working around it.
*
* I1. A row is retired only when its file is PROVEN gone: some directory
* between the file and its configured root lists successfully, and the next
* path component toward the file is absent from that listing.
* I2. If no directory from the file's parent up to the configured root can be
* listed, nothing is proven and no row is dropped. ENOENT/ENOTDIR is walked
* up (the directory itself is a missing component of some ancestor);
* EACCES, EIO, a WSL gate refusal, anything else, is unverifiable at once.
* I3. The rule is the same on the first pass after a process start and on every
* later pass. It needs no memory of what previous passes saw, because the
* walk is bounded at the configured root and never reasons about what is
* above it.
* I4. A file, or a project directory, the user really deleted retires on the
* first pass that proves it. There is no waiting period and no census.
* I8. A row whose path names an entry inside a container rather than a file of
* its own is proven the same way, one level up: the container must be
* present, and the pass must have enumerated it in full and successfully.
* A listing is a listing whether it comes from readdir or from a database.
*
* What I3 costs, stated rather than hidden: a volume mounted at exactly a
* configured root, unmounted so that the mountpoint stays present and lists
* empty, is indistinguishable from a root the user emptied. It retires. The
* realistic unmount shapes do not: a mount above the root leaves the root
* itself missing (the walk stops at the root boundary), and an unreadable root
* is an error, not a listing. One bit per root buys the remaining grace: a root
* that held transcripts on the previous pass and holds none on this one is
* unverifiable for that pass, so a single flap cannot retire a tree.
*/
// Walked up rather than believed: a directory that ENOENTs is itself the
// missing component its parent has to be asked about.
const MISSING_DIRECTORY = new Set(['ENOENT', 'ENOTDIR'])
export type SessionSearchRetirement = {
/** Paths proven gone and dropped from the index. */
retired: string[]
/** Rows kept: this pass could prove the file neither present nor gone. */
unverifiable: string[]
/** Paths the per-pass cap left for next time. */
unchecked: string[]
/** Roots owning at least one unverifiable verdict, with the reason. */
degradedRoots: SessionSearchDegradedRoot[]
}
export type SessionSearchRetirementArgs = {
store: SessionSearchStore
/** Held paths this pass did not discover; everything else is still there. */
paths: readonly string[]
/** The real directories this pass walked; the longest one containing a path bounds its walk. */
roots: readonly string[]
/** Roots that listed transcripts on the previous pass and none on this one. */
emptiedRoots?: ReadonlySet<string>
/**
* Containers this pass enumerated in full, with the ids each holds. Only a
* census builds it; see session-search-synthetic-sources.ts for the bar a
* container has to meet before it appears here.
*/
enumeratedContainers?: ReadonlyMap<string, ReadonlySet<string>>
/** One readdir per directory per pass, shared with the rest of the pass. */
listings: SessionSearchDirectoryReader
/**
* Directories this walk may read before the pass moves on.
*
* Directories, not rows. A row whose walk finds its directory already read is
* answered from the pass's cache and costs nothing, so counting rows made an
* unreadable directory able to starve the whole walk: five hundred rows under
* one EACCES directory are one readdir and five hundred identical
* unverifiable verdicts, and a row for a file the user really deleted, sorted
* behind them, was never reached on any pass.
*/
directoryLimit?: number
signal?: AbortSignal
}
type SessionSearchSourceVerdict =
| { verdict: 'gone' }
| { verdict: 'present' }
| { verdict: 'unverifiable'; reason: string }
/**
* Retires index rows for sources that are provably gone.
*
* One function, called by both the sweep and the cycle, because either one
* alone deleting a user's history the first time a mount is missing is the bug
* this feature kept shipping. There is no separate root fence: the walk cannot
* reach a verdict of `gone` without a successful listing, so an unreadable or
* missing root produces `unverifiable` structurally rather than by a guard
* somebody has to remember to call (docs/reference/ssh-execution-boundary.md:
* loss of contact is never evidence of absence).
*/
export async function retireDeletedSessionSearchSources(
args: SessionSearchRetirementArgs
): Promise<SessionSearchRetirement> {
const { store, paths, signal } = args
const emptiedRoots = args.emptiedRoots ?? new Set<string>()
const directoryLimit = args.directoryLimit ?? Number.POSITIVE_INFINITY
// Every directory this walk asked for, whether the pass had already read it
// or not. What it bounds is real work: a repeat of one already in here is a
// map lookup, and only a name that is new to it can cost a readdir.
const asked = new Set<string>()
const listings: SessionSearchDirectoryReader = {
namesIn: (directory, signal) => {
asked.add(directory)
return args.listings.namesIn(directory, signal)
}
}
const retirement: SessionSearchRetirement = {
retired: [],
unverifiable: [],
unchecked: [],
degradedRoots: []
}
const degraded = new Map<string, string>()
for (const [index, path] of paths.entries()) {
// A synthetic row names a container and an entry inside it, never a file of
// its own; walking the row's own path would report every one of them gone.
const synthetic = splitSyntheticSessionSource(path)
const filePath = synthetic?.container ?? path
// Why capped at all: the sweep hands over every path it holds and did not
// discover, and under an unmount that is the whole index. What is left is
// simply still undiscovered next pass, so the walk finishes over the ones
// that follow rather than holding this one.
//
// Spent past the bound only by a row that starts somewhere new. One this
// walk has already read is answered from the map, so refusing it would buy
// nothing and would leave the budget hostage to whichever directory the
// rows happened to be sorted by.
if (signal?.aborted || (asked.size >= directoryLimit && !asked.has(dirname(filePath)))) {
retirement.unchecked.push(...paths.slice(index))
break
}
const root = configuredRootFor(filePath, args.roots)
const containerProof = await proveSource(filePath, root ?? dirname(filePath), {
listings,
emptiedRoots,
signal
})
const proof = synthetic
? proveSyntheticSource(synthetic, containerProof, args.enumeratedContainers)
: containerProof
if (proof.verdict === 'gone') {
store.removeFile(path)
retirement.retired.push(path)
continue
}
if (proof.verdict === 'present') {
continue
}
retirement.unverifiable.push(path)
// Only a configured root is an alarm worth raising: a row under no root
// this scan walks is already reported on its own, as an orphan.
if (root !== null && !degraded.has(root)) {
degraded.set(root, proof.reason)
}
}
retirement.degradedRoots = [...degraded].map(([root, reason]) => ({ root, reason }))
return retirement
}
/**
* Walks from the file toward its configured root, asking each directory whether
* the next component toward the file is there. The first directory that answers
* decides; a directory that is itself missing moves the question up one level.
*
* The loop cannot pass the configured root, which is what makes the whole thing
* memoryless: everything above the root — a home directory on an unmounted
* volume, a detached drive, an SSH mount that is not there — is out of scope by
* construction rather than by a state machine that has to remember it.
*/
async function proveSource(
path: string,
root: string,
context: {
listings: SessionSearchDirectoryReader
emptiedRoots: ReadonlySet<string>
signal?: AbortSignal
}
): Promise<SessionSearchSourceVerdict> {
let directory = dirname(path)
let child = basename(path)
while (directory === root || isUnderScanRoot(directory, root)) {
const listing = await context.listings.namesIn(directory, context.signal)
if (!listing.listed) {
if (listing.code !== null && MISSING_DIRECTORY.has(listing.code)) {
const parent = dirname(directory)
if (parent === directory) {
break
}
child = basename(directory)
directory = parent
continue
}
return { verdict: 'unverifiable', reason: listing.message }
}
if (listing.names.has(child)) {
return { verdict: 'present' }
}
if (directory === root && context.emptiedRoots.has(root)) {
// One pass of grace, so a root that blinks empty for a moment — a sync
// client mid-swap, a mount that has not settled — cannot retire a tree.
return {
verdict: 'unverifiable',
reason: 'Listed no transcripts where it listed some on the previous pass.'
}
}
return { verdict: 'gone' }
}
return { verdict: 'unverifiable', reason: `${root} could not be listed.` }
}
/**
* A synthetic row is proven by its container's own enumeration, one level above
* where the filesystem walk stops.
*
* The container has to be present first: a database on a volume that is not
* there proves nothing about the sessions inside it, and a database that is
* gone takes its sessions with it. Only then does the enumeration decide, and
* only when this pass made one that was exhaustive and successful -- a cycle
* asks for the newest N per agent, so an id it did not return may just be the
* one after them.
*/
function proveSyntheticSource(
synthetic: { container: string; id: string },
containerProof: SessionSearchSourceVerdict,
enumerated?: ReadonlyMap<string, ReadonlySet<string>>
): SessionSearchSourceVerdict {
if (containerProof.verdict !== 'present') {
return containerProof
}
const ids = enumerated?.get(synthetic.container)
// An enumeration that returned nothing at all is not evidence that the
// container holds nothing: a source whose schema this scanner no longer
// recognises reads as empty with no error to see, and believing it would
// retire every entry in one pass.
if (!ids || ids.size === 0) {
return {
verdict: 'unverifiable',
reason: `${synthetic.container} was not enumerated in full this pass.`
}
}
return ids.has(synthetic.id) ? { verdict: 'present' } : { verdict: 'gone' }
}
/** Longest configured root containing the path, or null for a row under none. */
function configuredRootFor(path: string, roots: readonly string[]): string | null {
let owner: string | null = null
for (const root of roots) {
if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) {
owner = root
}
}
return owner
}
@@ -0,0 +1,61 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, expect, it, vi } from 'vitest'
import { SessionSearchDirectoryListings } from './session-search-directory-listings'
const { readdir } = vi.hoisted(() => ({ readdir: vi.fn() }))
vi.mock('../native-chat/wsl-transcript-fs-access', () => ({
wslGatedReaddir: readdir
}))
beforeEach(() => {
readdir.mockReset()
})
// A WSL root is a UNC path into the distro, and reading it with raw `fs` is
// what makes a stalled distro look like an empty directory. The gated primitive
// is the same one discovery walks with, so a refusal arrives as an error the
// walk treats as unverifiable rather than as "nothing here".
it('reads through the gated primitive, on the scan lane', async () => {
const unc = '\\\\wsl$\\Ubuntu\\home\\me\\.claude\\projects'
readdir.mockResolvedValueOnce([{ name: 'one.jsonl' }])
const listings = new SessionSearchDirectoryListings()
const listing = await listings.namesIn(unc)
expect(readdir).toHaveBeenCalledWith(unc, 'scan', undefined)
expect(listing).toEqual({ listed: true, names: new Set(['one.jsonl']) })
})
it('reports the code a failed read carried, so ENOENT and EACCES stay apart', async () => {
readdir.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' }))
const listings = new SessionSearchDirectoryListings()
expect(await listings.namesIn('/blocked')).toEqual({
listed: false,
code: 'EACCES',
message: 'permission denied'
})
})
it('reads a directory once per pass, error or not', async () => {
readdir.mockRejectedValue(Object.assign(new Error('gone'), { code: 'ENOENT' }))
const listings = new SessionSearchDirectoryListings()
await listings.namesIn('/gone')
await listings.namesIn('/gone')
expect(readdir).toHaveBeenCalledTimes(1)
expect(listings.size).toBe(1)
})
it('is a real directory read when nothing is mocked out from under it', async () => {
readdir.mockImplementation(async (path: string) => {
const { readdir: real } = await import('node:fs/promises')
return (await real(path, { withFileTypes: true })) as unknown
})
const root = join(tmpdir(), `ss-listings-${process.pid}`)
await mkdir(root, { recursive: true })
await writeFile(join(root, 'present.jsonl'), '{}')
const listing = await new SessionSearchDirectoryListings().namesIn(root)
expect(listing.listed && listing.names.has('present.jsonl')).toBe(true)
})
@@ -0,0 +1,70 @@
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
/** One directory read: the names it holds, or what stopped the read. */
export type SessionSearchDirectoryListing =
| { listed: true; names: ReadonlySet<string> }
| { listed: false; code: string | null; message: string }
/**
* What the retirement walk needs of a directory: its names, or why not.
*
* An interface rather than the class, so a test can hand the walk an EIO or a
* gate refusal — the shapes a stalled network mount answers with, which no
* temporary directory can be made to produce.
*/
export type SessionSearchDirectoryReader = {
namesIn(directory: string, signal?: AbortSignal): Promise<SessionSearchDirectoryListing>
}
/**
* Every directory one pass had to read, read once.
*
* The retirement walk asks the same directories about many files — a project
* directory holds hundreds of transcripts — and under an unmount every path
* under a root walks up through the same ancestors. One readdir per directory
* per pass keeps that bounded, and it also makes the pass self-consistent: two
* files in one directory cannot get contradictory verdicts because the
* directory changed between them.
*
* Reads go through the same gated primitive discovery uses, so a WSL UNC path
* is routed to the distro's helper process rather than read with raw fs, and a
* gate refusal arrives as an error rather than as an empty directory.
*/
export class SessionSearchDirectoryListings implements SessionSearchDirectoryReader {
private readonly listings = new Map<string, SessionSearchDirectoryListing>()
async namesIn(directory: string, signal?: AbortSignal): Promise<SessionSearchDirectoryListing> {
const cached = this.listings.get(directory)
if (cached) {
return cached
}
const listing = await readDirectory(directory, signal)
this.listings.set(directory, listing)
return listing
}
/** Directories read this pass; only tests and cost accounting need it. */
get size(): number {
return this.listings.size
}
}
async function readDirectory(
directory: string,
signal?: AbortSignal
): Promise<SessionSearchDirectoryListing> {
try {
const entries = await wslGatedReaddir(directory, 'scan', signal)
return { listed: true, names: new Set(entries.map((entry) => entry.name)) }
} catch (error) {
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return {
listed: false,
code,
message: error instanceof Error ? error.message : String(error)
}
}
}
@@ -1,113 +0,0 @@
import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine'
import { cwdKey } from './session-search-file-records'
import { identifierShadowText } from './session-search-identifier-split'
import { SessionSearchStore } from './session-search-store'
import {
openSessionSearchIndexFile,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
// Synthetic index rows for the query tests. The write path has its own tests;
// driving it here would make every retrieval assertion depend on the parser.
export type SessionSearchHarness = {
/** The engine's own connection; the store next to it keeps a second, private one. */
db: SyncDatabase
/** A real writer on the same file, so a test can move the index under the engine. */
store: SessionSearchStore
engine: SessionSearchEngine
close: () => Promise<void>
}
export async function openSessionSearchHarness(
name: string,
options: SessionSearchEngineOptions = {}
): Promise<SessionSearchHarness> {
const index: SessionSearchIndexFile = await openSessionSearchIndexFile(name)
const store = new SessionSearchStore(index.path, (error) => {
throw error
})
// Constructed before any row is planted, because constructing it is what
// installs the generation triggers the planted rows have to move.
const engine = new SessionSearchEngine(index.db, options)
return {
db: index.db,
store,
engine,
close: async () => {
store.close()
await index.close()
}
}
}
export type SyntheticSession = {
id: number
cwd?: string | null
text?: string
/** Rows of `text` to write; one session with many rows is one hit. */
rows?: number
role?: TranscriptMessageRole
/**
* Written into `tool_text` alongside `text`, which is the one row shape the
* conversation scope has to exclude while the `all` scope keeps it.
*/
toolText?: string
agent?: string
updatedAt?: string
messageCount?: number
/** Written into `files`, which is what makes the source `present`. */
filePath?: string | null
/** `sessions.file_path`: the transcript `path:` searches alongside cwd. */
sessionFilePath?: string
}
/** One session and its message rows, in both FTS tables the way the writer does. */
export function addSyntheticSession(db: SyncDatabase, session: SyntheticSession): void {
const {
id,
cwd = '/repo/app',
text = 'needle',
rows = 1,
role = 'user',
toolText = '',
agent = 'claude',
updatedAt = `2026-09-${String((id % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
messageCount = rows,
filePath = `/synthetic/${id}.jsonl`,
sessionFilePath = `/synthetic/${id}.jsonl`
} = session
db.prepare(
`INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,message_count,resume_command)
VALUES (?,?,?,?,'fixture',?,?,?,?,'resume')`
).run(id, agent, String(id), sessionFilePath, cwd, cwdKey(cwd), updatedAt, messageCount)
if (filePath !== null) {
db.prepare(
'INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,0,1740000000000,?)'
).run(filePath, id)
}
for (let row = 0; row < rows; row++) {
const messageId = Number(
db
.prepare('INSERT INTO messages(session_row_id,role,ts) VALUES (?,?,?)')
.run(id, role, updatedAt).lastInsertRowid
)
const user = role === 'user' ? text : ''
const assistant = role === 'assistant' ? text : ''
const tool = role === 'tool' ? `${text} ${toolText}`.trim() : toolText
db.prepare(
'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)'
).run(messageId, user, assistant, tool, identifierShadowText(`${text} ${toolText}`))
}
}
export function markFork(db: SyncDatabase, ids: readonly number[], hash: string): void {
for (const id of ids) {
db.prepare('UPDATE sessions SET content_hash = ?, content_hash_count = 8 WHERE id = ?').run(
hash,
id
)
}
}
@@ -1,157 +0,0 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers'
import type { SessionSearchUnavailableFeature } from './session-search-query-schema'
// ENGINE types, deliberately not in src/shared: nothing here is a wire type.
// PR 5 owns the public contract and lifts what a caller may actually receive;
// until then a field can be added, renamed or dropped without a compat story.
export const SESSION_SEARCH_LIMIT_DEFAULT = 20
export const SESSION_SEARCH_LIMIT_MAX = 100
// Longer than this is not a query, and FTS5 pays for every term it plans.
export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512
// Snippet match markers. Why doubled: single brackets are everywhere in code
// transcripts (`arr[0]`, regex classes, markdown links) and would read as
// matches; doubled ones are rare.
export const SESSION_SEARCH_SNIPPET_MARK_OPEN = '[['
export const SESSION_SEARCH_SNIPPET_MARK_CLOSE = ']]'
/**
* Which corpus answers the query.
*
* - `conversation`: user and assistant turns only, as a column filter over
* `messages_fts` (see `scopedExpression`).
* - `all`: those turns plus tool calls and tool output, and the identifier
* shadow column, from `messages_fts`.
*
* The engine searches exactly the scope it is given. Switching corpus as the
* user types is a UI policy and lives in the panel (PR 7); an engine that
* second-guessed the scope would make a result impossible to reproduce from
* its own request.
*/
export type SessionSearchScope = 'conversation' | 'all'
export type SessionSearchSort = 'relevance' | 'newest'
export type SessionSearchFilters = {
agents?: readonly AiVaultAgent[]
/** Only sessions whose cwd is that path or inside it. */
scopePaths?: readonly string[]
/** ISO timestamp; only sessions updated at or after it. */
since?: string
sort?: SessionSearchSort
}
export type SessionSearchRequest = {
query: string
/** Default `all`. */
scope?: SessionSearchScope
limit?: number
/** From a previous response's `page.cursor`; only valid in its own generation. */
cursor?: string
filters?: SessionSearchFilters
}
export type SessionSearchRoute = 'phrase' | 'and' | 'or' | 'typo+phrase' | 'typo+and' | 'typo+or'
/**
* How the query was executed. Diagnostics, not an answer: PR 5 decides which of
* these a caller ever sees (the reviewer's F5/F7 want them behind `debug`).
*/
export type SessionSearchPlannerReport = {
route: SessionSearchRoute
/**
* The whole body the repaired plan searched, in query order, when any term
* was changed. Not just the corrected terms: a caller rendering "searched
* for" needs the query it actually ran, and a repair never drops a term the
* original kept. A corrected term carries the index's own spelling, which the
* tokenizer has case-folded; untouched terms keep the case they were typed in.
*/
repairedTerms?: string[]
/** The corpus the route ran against; today always the requested scope. */
tier: SessionSearchScope
}
/**
* Where a source stands according to the index's own `files` table. The query
* path never stats a transcript, so it can report that the index has a live
* file record for a session or that it has none, and never that a source is
* gone: only a proven deletion may claim `missing`, and proving one is the
* indexer's job (docs/reference/ssh-execution-boundary.md).
*/
export type SessionSearchSourcePresence = 'present' | 'unverifiable'
export type SessionSearchEvidence = {
role: TranscriptMessageRole
timestamp: string | null
/** FTS5 snippet with the matched terms wrapped in `[[` `]]`. */
snippet: string
/** The snippet hit the engine's per-hit ceiling and was cut. */
snippetTruncated?: boolean
}
export type SessionSearchHit = {
agent: AiVaultAgent
sessionId: string
filePath: string
codexHome: string | null
title: string
cwd: string | null
branch: string | null
updatedAt: string | null
messageCount: number
resumeCommand: string
score: number
/** Sessions folded into this hit (forks sharing an opening prefix); absent when unique. */
duplicateCount?: number
source: SessionSearchSourcePresence
/** Null when the operators alone put this session on the page, with no text match. */
evidence: SessionSearchEvidence | null
}
export type SessionSearchPage = {
/** Null when this page is the last one. */
cursor: string | null
hasMore: boolean
}
export type SessionSearchTruncation = {
/**
* Ranking saw only the first `sessionCandidateLimit` sessions, so a session
* past that cut cannot appear on any page of this query.
*/
candidates: boolean
/** Hits on this page whose snippet was cut. */
snippets: number
/**
* The query itself was cut before it was searched: past the length ceiling,
* or past the number of terms the planner will plan. The terms that survived
* were searched in full, so a hit is still a hit; a miss is not proof of
* absence.
*/
query: boolean
}
export type SessionSearchResponse = {
hits: SessionSearchHit[]
/**
* Engine features the index on disk cannot serve, empty on a current index.
* A route ladder missing its repair rung still answers; saying so is what
* keeps the answer honest.
*/
unavailable: readonly SessionSearchUnavailableFeature[]
planner: SessionSearchPlannerReport
page: SessionSearchPage
truncated: SessionSearchTruncation
/** The index snapshot these hits came from; a cursor is only valid within it. */
generation: number
durationMs: number
}
export function resolveSessionSearchLimit(limit: number | undefined): number {
// Why clamped here and not at the caller: a non-positive limit becomes
// `slice(0, -1)`, which silently drops the last hit of every page.
const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT
return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX)
}
@@ -1,471 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { SESSION_SEARCH_QUERY_MAX_LENGTH } from './session-search-engine-types'
import type { SessionSearchRequest, SessionSearchResponse } from './session-search-engine-types'
import { planSessionSearchQuery } from './session-search-query-planner'
import { ensureSessionSearchQuerySchema } from './session-search-query-schema'
import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet'
import {
addSyntheticSession,
markFork,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
async function open(name: string, options = {}): Promise<SessionSearchHarness> {
harness = await openSessionSearchHarness(name, options)
return harness
}
function ids(result: SessionSearchResponse): string[] {
return result.hits.map((hit) => hit.sessionId)
}
describe('the route ladder tries phrase, then AND, then repair, then OR', () => {
async function routeFor(
text: string,
request: SessionSearchRequest
): Promise<SessionSearchResponse> {
const { db, engine } = await open('ss-engine-route')
addSyntheticSession(db, { id: 1, text })
return engine.search(request)
}
it('takes the phrase route when the tokens are adjacent and in order', async () => {
const result = await routeFor('the alpha beta gamma line', { query: '"alpha beta"' })
expect(result.planner.route).toBe('phrase')
expect(ids(result)).toEqual(['1'])
})
it('falls to AND when the tokens are present but not adjacent', async () => {
const result = await routeFor('beta separated alpha', { query: '"alpha beta"' })
expect(result.planner.route).toBe('and')
expect(ids(result)).toEqual(['1'])
})
it('falls to OR for prose, where no phrase was ever claimed', async () => {
const result = await routeFor('the relay dropped a frame', { query: 'relay frames dropped' })
expect(result.planner.route).toBe('or')
expect(ids(result)).toEqual(['1'])
})
it('repairs a typo before the OR fallback, and says which terms it changed', async () => {
const { db, engine } = await open('ss-engine-typo')
// Two copies: the repair only suggests a term the index really holds.
addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' })
addSyntheticSession(db, { id: 2, text: 'coalesces again here' })
const result = engine.search({ query: 'coalescs' })
expect(result.planner.route).toBe('typo+or')
expect(result.planner.repairedTerms).toEqual(['coalesces'])
expect(ids(result).sort()).toEqual(['1', '2'])
})
it('keeps every term a repaired literal was typed with', async () => {
const { db, engine } = await open('ss-engine-typo-literal')
addSyntheticSession(db, { id: 1, text: 'parseJson the data' })
addSyntheticSession(db, { id: 2, text: 'parseJson the data again' })
// `parseJsonn(the, data)` is literal because of its punctuation; the
// corrected spelling read on its own is prose. Re-planning without carrying
// the original decision across would drop `the` and report a body that was
// never typed.
// A corrected term comes back in the index's own spelling, which unicode61
// has folded; the terms the repair left alone keep the case they were typed.
const result = engine.search({ query: 'parseJsonn(the, data)' })
expect(result.planner.repairedTerms).toEqual(['parsejson', 'the', 'data'])
})
it('does not repair a term the index already holds', async () => {
const { db, engine } = await open('ss-engine-no-typo')
addSyntheticSession(db, { id: 1, text: 'coalesces' })
const result = engine.search({ query: 'coalesces' })
expect(result.planner.repairedTerms).toBeUndefined()
expect(result.planner.route).toBe('or')
})
it('reports the scope it searched as the planner tier', async () => {
const { db, engine } = await open('ss-engine-tier')
addSyntheticSession(db, { id: 1, text: 'needle' })
expect(engine.search({ query: 'needle' }).planner.tier).toBe('all')
expect(engine.search({ query: 'needle', scope: 'conversation' }).planner.tier).toBe(
'conversation'
)
})
})
describe('scope picks the corpus and never switches it', () => {
async function corpus(): Promise<SessionSearchHarness> {
const opened = await open('ss-engine-scope')
addSyntheticSession(opened.db, { id: 1, text: 'harbor pilot manifest', role: 'user' })
addSyntheticSession(opened.db, { id: 2, text: 'harbor tool output line', role: 'tool' })
return opened
}
it('searches conversation turns only under `conversation`', async () => {
const { engine } = await corpus()
expect(ids(engine.search({ query: 'harbor', scope: 'conversation' }))).toEqual(['1'])
})
it('includes tool output under `all`, which is the default', async () => {
const { engine } = await corpus()
expect(ids(engine.search({ query: 'harbor', scope: 'all' })).sort()).toEqual(['1', '2'])
expect(ids(engine.search({ query: 'harbor' })).sort()).toEqual(['1', '2'])
})
it('returns nothing rather than widening when the narrow scope misses', async () => {
// The panel's two-tier typing is a UI policy (PR 7). An engine that widened
// here would make a result impossible to reproduce from its own request.
const { engine } = await corpus()
const result = engine.search({ query: 'output', scope: 'conversation' })
expect(result.hits).toEqual([])
expect(result.planner.tier).toBe('conversation')
})
it('matches an identifier through its pieces only in the full corpus', async () => {
const { db, engine } = await open('ss-engine-identifiers')
addSyntheticSession(db, { id: 1, text: 'resolveTerminalPath' })
// The identifier shadow column lives in messages_fts alone.
expect(ids(engine.search({ query: 'terminal path' }))).toEqual(['1'])
expect(engine.search({ query: 'terminal path', scope: 'conversation' }).hits).toEqual([])
})
})
describe('the conversation scope is a column filter, and it binds the whole query', () => {
it('refuses an AND whose second term lives only in tool output', async () => {
// The filter binds to the expression it prefixes. `{cols}: (a AND b)`
// filters both terms; `{cols}: a AND b` filters only `a` and searches tool
// output for the rest, which is a conversation search answering from a
// column it promised not to read.
const { db, engine } = await open('ss-engine-scope-binding')
addSyntheticSession(db, { id: 1, text: 'alpha gamma beta' })
addSyntheticSession(db, { id: 2, text: 'alpha gamma', toolText: 'beta' })
// Quoted, so the query is literal; not adjacent, so the phrase rung misses
// and the AND rung is the one that answers.
const query = '"alpha" beta'
const wide = engine.search({ query, scope: 'all' })
expect(wide.planner.route).toBe('and')
expect(ids(wide).sort()).toEqual(['1', '2'])
const narrowed = engine.search({ query, scope: 'conversation' })
expect(narrowed.planner.route).toBe('and')
expect(ids(narrowed)).toEqual(['1'])
})
it('ranks a conversation hit down for tool output it will not show', async () => {
// The one behavioural difference the column filter carries, pinned rather
// than wished away. FTS5's bm25 normalises by the whole row's length and
// has no per-column length, so two rows with identical prose do not score
// identically when one of them also holds tool output. A dedicated
// two-column table scored them the same. The rowid set is unchanged, which
// is what the decision was measured on; the order within it can move.
const { db, engine } = await open('ss-engine-scope-weights')
addSyntheticSession(db, { id: 1, text: 'harbor pilot' })
addSyntheticSession(db, { id: 2, text: 'harbor pilot', toolText: 'unrelated '.repeat(40) })
const narrowed = engine.search({ query: 'harbor', scope: 'conversation' })
expect(ids(narrowed)).toEqual(['1', '2'])
expect(narrowed.hits[0]!.score).toBeGreaterThan(narrowed.hits[1]!.score)
})
it('never snippets a conversation hit out of tool output', async () => {
const { db, engine } = await open('ss-engine-scope-snippet')
addSyntheticSession(db, { id: 1, text: 'harbor pilot', toolText: 'harbor tool output line' })
const [hit] = engine.search({ query: 'harbor', scope: 'conversation' }).hits
expect(hit?.evidence?.snippet).toContain('pilot')
expect(hit?.evidence?.snippet).not.toContain('output')
// And asked for a tool-only row directly, it has nothing to show.
addSyntheticSession(db, { id: 2, text: 'harbor tool output line', role: 'tool' })
const rowid = Number(
(db.prepare('SELECT max(id) AS id FROM messages').get() as { id: number }).id
)
const plan = planSessionSearchQuery('harbor')
expect(sessionSearchSnippet(db, 'conversation', rowid, plan)).toEqual(EMPTY_SNIPPET)
expect(sessionSearchSnippet(db, 'all', rowid, plan).text).toContain('output')
})
})
describe('a session is one hit, however many of its rows matched', () => {
it.each(['relevance', 'newest'] as const)(
'keeps a short session on the %s page beside a 650-row session',
async (sort) => {
const { db, engine } = await open('ss-engine-aggregate', { sessionCandidateLimit: 600 })
addSyntheticSession(db, { id: 1, rows: 650, updatedAt: '2026-09-06T00:00:00.000Z' })
addSyntheticSession(db, {
id: 2,
text: 'needle padding',
updatedAt: '2026-09-05T00:00:00.000Z'
})
// Collapsing to one row per session happens before the candidate limit,
// so the 650-row session cannot crowd the one-row session off the page on
// either order; which of them ranks first is the sort's business.
expect(ids(engine.search({ query: 'needle', filters: { sort } })).sort()).toEqual(['1', '2'])
}
)
it('folds forks the same way for an operator-only page as for a text page', async () => {
const { db, engine } = await open('ss-engine-forks')
for (const id of [1, 2, 3, 4]) {
addSyntheticSession(db, { id, updatedAt: `2026-09-0${id}T00:00:00.000Z` })
}
markFork(db, [1, 2, 3, 4], 'shared-fork-prefix')
const operatorOnly = engine.search({ query: 'repo:app' })
const withText = engine.search({ query: 'needle repo:app' })
expect(ids(operatorOnly)).toEqual(['4'])
expect(operatorOnly.hits[0]?.duplicateCount).toBe(4)
expect(ids(withText)).toEqual(ids(operatorOnly))
expect(withText.hits[0]?.duplicateCount).toBe(4)
})
it('answers an operator-only query with the newest sessions and no evidence', async () => {
const { db, engine } = await open('ss-engine-operator-only')
addSyntheticSession(db, { id: 1, updatedAt: '2026-09-01T00:00:00.000Z' })
addSyntheticSession(db, { id: 2, updatedAt: '2026-09-09T00:00:00.000Z' })
const result = engine.search({ query: 'repo:app' })
expect(ids(result)).toEqual(['2', '1'])
expect(result.hits[0]?.evidence).toBeNull()
})
it('has no hits for a query with neither text nor operators', async () => {
const { db, engine } = await open('ss-engine-empty')
addSyntheticSession(db, { id: 1 })
expect(engine.search({ query: ' ' }).hits).toEqual([])
})
})
describe('filters narrow retrieval, not just the page', () => {
it('finds a scoped match behind 600 out-of-scope rows', async () => {
const { db, engine } = await open('ss-engine-scoped')
addSyntheticSession(db, { id: 1, cwd: '/unrelated', rows: 600 })
addSyntheticSession(db, { id: 2, cwd: '/target', text: 'needle padding' })
expect(ids(engine.search({ query: 'needle', filters: { scopePaths: ['/target'] } }))).toEqual([
'2'
])
})
it('falls back to a later rung when the exact hit is out of scope', async () => {
const { db, engine } = await open('ss-engine-scoped-route')
addSyntheticSession(db, { id: 1, cwd: '/unrelated', text: 'resolveTerminalPath' })
addSyntheticSession(db, { id: 2, cwd: '/target', text: 'resolve terminal path' })
expect(
ids(engine.search({ query: 'resolveTerminalPath', filters: { scopePaths: ['/target'] } }))
).toEqual(['2'])
})
})
describe('evidence', () => {
it('takes each snippet from that hit’s own best message', async () => {
const { db, engine } = await open('ss-engine-snippet')
// Written first, so its row owns the lowest rowid: the row a dropped rowid
// constraint would hand back for every hit.
addSyntheticSession(db, {
id: 1,
text: 'hydration marmoset appears once in a long paragraph about routing and caching',
updatedAt: '2026-09-01T00:00:00.000Z'
})
addSyntheticSession(db, {
id: 2,
text: 'hydration capybara',
updatedAt: '2026-09-09T00:00:00.000Z'
})
const hits = engine.search({ query: 'hydration' }).hits
expect(hits[0]?.evidence?.snippet).toContain('capybara')
expect(hits[0]?.evidence?.snippet).not.toContain('marmoset')
expect(hits.find((hit) => hit.sessionId === '1')?.evidence?.snippet).toContain('marmoset')
})
it('shows the prose column rather than the identifier shadow when both match', async () => {
const { db, engine } = await open('ss-engine-snippet-shadow')
addSyntheticSession(db, {
id: 1,
text: 'resolveTerminalPath is broken and the terminal never comes up for a pane, which is odd because every other pane on this host resolves its path'
})
const snippet = engine.search({ query: 'terminal path' }).hits[0]?.evidence?.snippet ?? ''
expect(snippet).toContain('[[')
expect(snippet).not.toContain('resolve [[terminal]] [[path]]')
})
it('flags a snippet it had to cut, and counts it on the result', async () => {
const { db, engine } = await open('ss-engine-snippet-truncated')
// The window is twelve tokens wide, and one of them is 4000 characters, so
// the token count is no bound at all on what a hit carries.
addSyntheticSession(db, { id: 1, text: `needle ${'x'.repeat(4000)}` })
const result = engine.search({ query: 'needle' })
expect(result.hits[0]?.evidence?.snippetTruncated).toBe(true)
expect(result.hits[0]?.evidence?.snippet.length).toBeLessThan(600)
expect(result.truncated.snippets).toBe(1)
})
it('leaves an ordinary snippet unflagged', async () => {
const { db, engine } = await open('ss-engine-snippet-whole')
addSyntheticSession(db, { id: 1, text: 'needle in a short line' })
const result = engine.search({ query: 'needle' })
expect(result.hits[0]?.evidence?.snippetTruncated).toBeUndefined()
expect(result.truncated.snippets).toBe(0)
})
})
describe('source presence comes from the files table, never a stat', () => {
it('calls a session with a live file record present', async () => {
const { db, engine } = await open('ss-engine-presence')
addSyntheticSession(db, { id: 1 })
expect(engine.search({ query: 'needle' }).hits[0]?.source).toBe('present')
})
it('calls a session with no file record unverifiable, and still returns it', async () => {
// Loss of contact is never evidence of absence: the hit stays on the page.
const { db, engine } = await open('ss-engine-presence-unknown')
addSyntheticSession(db, { id: 1, filePath: null })
const hits = engine.search({ query: 'needle' }).hits
expect(hits).toHaveLength(1)
expect(hits[0]?.source).toBe('unverifiable')
})
})
describe('the engine carries its own schema and puts it back', () => {
it('installs the vocabulary and the log over an index a writer built alone', async () => {
// The store creates none of these: PR 3's indexer can fill a whole index
// before anything opens an engine over it.
const { db, engine } = await open('ss-engine-installs')
addSyntheticSession(db, { id: 1, text: 'the coalesces path is slow' })
addSyntheticSession(db, { id: 2, text: 'coalesces again here' })
const result = engine.search({ query: 'coalescs' })
expect(result.unavailable).toEqual([])
expect(result.planner.route).toBe('typo+or')
expect(ids(result).sort()).toEqual(['1', '2'])
})
it('re-creates a vocabulary that vanished under a live engine', async () => {
const { db, engine } = await open('ss-engine-vocab-vanishes')
addSyntheticSession(db, { id: 1, text: 'coalesces here now' })
addSyntheticSession(db, { id: 2, text: 'coalesces again here' })
expect(engine.search({ query: 'coalescs' }).planner.route).toBe('typo+or')
db.exec('DROP TABLE messages_vocab')
const after = engine.search({ query: 'coalescs' })
expect(after.unavailable).toEqual([])
expect(after.planner.route).toBe('typo+or')
})
it('names the feature it cannot serve when the vocabulary has no source left', async () => {
// What an index being rebuilt by another handle looks like from here. The
// vocabulary can be created over a missing `messages_fts` and every query
// against it then fails, so the probe reads the source, not the view.
//
// With one FTS table there is no scope left to answer from, so this is now
// the boundary of the degrade: the engine names the feature and the search
// fails loudly on the table it cannot read, rather than returning an empty
// page that looks like an answer.
const { db, engine } = await open('ss-engine-vocab-source-gone')
addSyntheticSession(db, { id: 1, text: 'coalesces here now', role: 'user' })
db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts')
expect(ensureSessionSearchQuerySchema(db)).toEqual(['typo-repair'])
for (const scope of ['all', 'conversation'] as const) {
expect(() => engine.search({ query: 'coalesces', scope })).toThrow(/no such (fts5 )?table/i)
}
})
it('picks the feature back up when the source comes back', async () => {
const { db, engine } = await open('ss-engine-vocab-returns')
addSyntheticSession(db, { id: 1, text: 'coalesces here now' })
addSyntheticSession(db, { id: 2, text: 'coalesces again here' })
const fts = (
db.prepare("SELECT sql FROM sqlite_master WHERE name = 'messages_fts'").get() as {
sql: string
}
).sql
db.exec('DROP TABLE messages_vocab; DROP TABLE messages_fts')
expect(ensureSessionSearchQuerySchema(db)).toEqual(['typo-repair'])
db.exec(fts)
// Two, because the vocabulary only offers a term at least two rows carry.
addSyntheticSession(db, { id: 3, text: 'coalesces one more time' })
addSyntheticSession(db, { id: 4, text: 'coalesces once again' })
// Nothing throws on the way back up, so the recovery cannot come from the
// error path; it comes from the probe running per search.
const restored = engine.search({ query: 'coalescs' })
expect(restored.unavailable).toEqual([])
expect(restored.planner.route).toBe('typo+or')
})
})
describe('a query the engine had to cut says so', () => {
it('answers a query whose cap falls inside an astral character', async () => {
// The cut is on a whole code point rather than a code unit, so nothing
// downstream is handed half a surrogate pair. That is hygiene rather than a
// behaviour: the planner's tokenizer does not treat a lone surrogate as a
// token character, so it drops out of the terms either way. What this pins
// is that the boundary is answerable at all.
const { db, engine } = await open('ss-engine-surrogate-cap')
const kept = 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH - 2)
addSyntheticSession(db, { id: 1, text: kept })
const result = engine.search({ query: `${kept} 😀 tail` })
expect(result.truncated.query).toBe(true)
expect(result.hits.map((hit) => hit.sessionId)).toEqual(['1'])
})
it('loads a candidate set larger than one batch of bound ids', async () => {
// The id list is as long as the candidate limit and every id is a bound
// parameter. No SQLite this stack can run refuses 1,100 of them, so this
// pins that batching returns the same answer, not that it rescues one.
const { db, engine } = await open('ss-engine-id-batching', {
sessionCandidateLimit: 1200
})
for (let id = 1; id <= 1100; id++) {
addSyntheticSession(db, { id, text: 'needle' })
}
const result = engine.search({ query: 'needle', limit: 5 })
expect(result.hits).toHaveLength(5)
expect(result.truncated.candidates).toBe(false)
})
it('reports truncation when the planner drops terms past its cap', async () => {
// The 56th term is the only one that matches. Without the flag this is a
// confident empty answer to a query the engine never finished reading.
const { db, engine } = await open('ss-engine-term-cap')
addSyntheticSession(db, { id: 1, text: 'onlyattheend' })
const query = `${Array.from({ length: 55 }, (_unused, n) => `term${n}`).join(' ')} onlyattheend`
const result = engine.search({ query })
expect(result.hits).toEqual([])
expect(result.truncated.query).toBe(true)
})
it('reports truncation when the query is longer than the engine will plan', async () => {
const { db, engine } = await open('ss-engine-length-cap')
addSyntheticSession(db, { id: 1, text: 'needle' })
const result = engine.search({ query: `needle ${'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH)}` })
expect(result.truncated.query).toBe(true)
})
it('claims no truncation for a query that fit', async () => {
const { db, engine } = await open('ss-engine-no-cap')
addSyntheticSession(db, { id: 1, text: 'needle' })
expect(engine.search({ query: 'needle' }).truncated.query).toBe(false)
})
})
describe('a query longer than the engine will plan is cut, not refused', () => {
it('cuts one enormous token down to the cap before FTS5 ever sees it', async () => {
const { db, engine } = await open('ss-engine-long-query')
// The planner already caps how many terms it will plan, so a long query of
// ordinary words is bounded without this. What is not bounded is a single
// token: one 100 kB word is one term, and FTS5 would carry the whole thing
// into the MATCH expression. The cut is observable because the indexed
// token is exactly the capped length.
addSyntheticSession(db, { id: 1, text: 'x'.repeat(SESSION_SEARCH_QUERY_MAX_LENGTH) })
expect(ids(engine.search({ query: 'x'.repeat(4000) }))).toEqual(['1'])
})
})
describe('unicode terms survive the round trip', () => {
it.each(['café', 'C', 'R', 'x', '修復', '안녕하세요'])('searches %s', async (text) => {
const { db, engine } = await open('ss-engine-unicode')
addSyntheticSession(db, { id: 1, text })
expect(engine.search({ query: text }).hits).toHaveLength(1)
})
})
@@ -1,349 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers'
import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization'
import {
hasAiVaultSearchQueryOperators,
splitAiVaultSearchQuery,
type AiVaultSearchQuerySplit
} from '../../shared/ai-vault-search-query-operators'
import { matchesAiVaultQueryOperators } from '../../shared/ai-vault-session-filters'
import {
resolveSessionSearchLimit,
SESSION_SEARCH_QUERY_MAX_LENGTH,
type SessionSearchHit,
type SessionSearchRequest,
type SessionSearchResponse,
type SessionSearchScope,
type SessionSearchSourcePresence
} from './session-search-engine-types'
import { readIndexGeneration } from './session-search-index-generation'
import {
rankSessionHits,
type MessageRow,
type RankedSession,
type SessionRow
} from './session-search-hit-ranking'
import {
decodeSessionSearchCursor,
encodeSessionSearchCursor,
sessionSearchPageKey
} from './session-search-page-cursor'
import { planSessionSearchQuery } from './session-search-query-planner'
import { logSessionSearchQuery } from './session-search-query-log'
import {
SessionSearchRetrieval,
type RetrievalScope,
type Retrieved
} from './session-search-retrieval'
import { sessionRowFilter } from './session-search-row-filter'
import {
ensureSessionSearchQuerySchema,
type SessionSearchUnavailableFeature
} from './session-search-query-schema'
import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet'
import { sessionSourcePresence } from './session-search-source-presence'
/**
* Sessions retrieved before ranking cuts the page.
*
* Not a fixed constant (the reviewer's F13): it is the knob that trades page
* completeness for retrieval cost, and the right value depends on index size.
* Measurements behind this default, and what changing it costs, are in
* docs/reference/agent-session-search-query-tuning.md.
*/
export const SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT = 600
/** One ranked list plus what produced it; a page is a slice of `ranked`. */
type RankedPage = {
ranked: RankedSession[]
/** Null when no text was searched, so there is nothing to snippet from. */
retrieved: Retrieved | null
/**
* Retrieval may have missed a session: a cap ended it, not the data. True
* whether the candidate limit filled or the operator walk gave up scanning.
*/
incomplete: boolean
}
export type SessionSearchEngineOptions = {
sessionCandidateLimit?: number
/** Oldest transcript mtime a hit may come from; PR 3 derives it from retention. */
retentionCutoffMs?: number | null
/** Write each query to `search_log`. Off unless a caller asks (see query-log). */
logQueries?: boolean
}
/**
* Ranked session search over the PR 2 index.
*
* A library: it holds no timers, reads no settings, and knows nothing about
* Electron, IPC or a panel. It is handed a connection rather than opening one,
* because which process may open, rebuild or unlink the index file is PR 3b's
* decision and not a query engine's.
*
* **Every read here is a single statement, and no read transaction is ever
* open across an `await`.** There is no `BEGIN` on this path, no `.iterate()`
* outliving its statement, and `search` is synchronous end to end. That is a
* constraint PR 2 measured rather than a style: a reader that pins a WAL
* snapshot holds off every checkpoint behind it, and the same 47 MB of writes
* that leave a 9.9 MB WAL grew to 266 MB with one `BEGIN` + `SELECT` held open.
*
* One search is one synchronous pass, and every page of it is a slice of the
* same ranked list. That list is rebuilt per page rather than streamed, which
* is what makes a page repeatable: within one index generation the same request
* ranks the same way, and a cursor from any other generation is refused.
*
* That fence is strict on purpose, and the cost is worth stating plainly: any
* committed read moves the generation, so while a backfill is running an
* outstanding cursor will be refused, often within a second. Pagination is
* usable against a settled index and unreliable against one still filling. The
* rejection carries both generations, so a caller that sees `stale-generation`
* knows the index moved rather than that it holds a bad cursor, and can quietly
* re-issue page one instead of showing anyone an error.
*/
export class SessionSearchEngine {
private retrieval: SessionSearchRetrieval
private readonly candidateLimit: number
/** Re-probed whenever a query proves it stale; see `withCapabilityRetry`. */
private unavailable: readonly SessionSearchUnavailableFeature[]
constructor(
private readonly db: SyncDatabase,
private readonly options: SessionSearchEngineOptions = {}
) {
this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT
// Installed here and not on the first search, so the generation triggers are
// watching before anything this engine will be asked to page over is
// written, and so retrieval below prepares against tables that exist.
this.unavailable = ensureSessionSearchQuerySchema(this.db)
this.retrieval = new SessionSearchRetrieval(this.db, !this.unavailable.includes('typo-repair'))
}
search(request: SessionSearchRequest): SessionSearchResponse {
const startedAt = performance.now()
this.probeCapabilities()
const generation = readIndexGeneration(this.db)
const scope = request.scope ?? 'all'
const sort = request.filters?.sort ?? 'relevance'
// Not a bare `slice`: cutting between a surrogate pair leaves a lone half
// that no tokenizer can match and that a caller cannot echo back.
const capped = sliceAtCodeUnitLimit(request.query, SESSION_SEARCH_QUERY_MAX_LENGTH)
const split = splitAiVaultSearchQuery(capped)
const retrievalScope: RetrievalScope = {
scope,
sort,
filter: sessionRowFilter(request.filters ?? {}, this.options.retentionCutoffMs ?? null),
matchesOperators: operatorPredicate(split),
candidateLimit: this.candidateLimit
}
// Decoded before any retrieval: a cursor the engine will refuse must not
// cost a query, and the caller has to hear about it either way.
const pageKey = sessionSearchPageKey(request)
const offset = request.cursor
? decodeSessionSearchCursor(request.cursor, generation, pageKey)
: 0
const plan = planSessionSearchQuery(split.text)
const { ranked, retrieved, incomplete } = this.withCapabilityRetry(() =>
plan.terms.length === 0
? this.operatorOnly(split, retrievalScope)
: this.text(plan, retrievalScope, sort)
)
const limit = resolveSessionSearchLimit(request.limit)
const page = ranked.slice(offset, offset + limit)
const hits = this.hits(page, scope, retrieved)
const hasMore = ranked.length > offset + limit
const response: SessionSearchResponse = {
hits,
unavailable: this.unavailable,
planner: {
route: retrieved?.route ?? 'or',
tier: scope,
...(retrieved?.repairedTerms ? { repairedTerms: retrieved.repairedTerms } : {})
},
page: {
hasMore,
cursor: hasMore ? encodeSessionSearchCursor(generation, offset + limit, pageKey) : null
},
truncated: {
// Decided by retrieval, which is the only layer that knows whether a cap
// ended it. Deriving it from the hits cannot work: an operator walk that
// gave up at its scan ceiling returns no hits, and so does a search that
// genuinely matched nothing.
candidates: incomplete,
snippets: hits.filter((hit) => hit.evidence?.snippetTruncated).length,
query: capped.length < request.query.length || plan.truncated
},
generation,
durationMs: performance.now() - startedAt
}
if (this.options.logQueries) {
logSessionSearchQuery(this.db, {
query: request.query,
route: response.planner.route,
hits: hits.length,
durationMs: response.durationMs
})
}
return response
}
/**
* Where the engine's own schema is created and checked, once per search.
*
* A capability is a fact about the file, not about this object: another handle
* can rebuild the index under a live connection, so a verdict cached in the
* constructor is wrong for the rest of the engine's life in both directions —
* it would keep reaching for a table that went away, and never pick one back
* up when it returned. Retrieval is only rebuilt when the answer changes, so
* the steady-state cost is one indexed lookup and nothing else.
*/
private probeCapabilities(): void {
const unavailable = ensureSessionSearchQuerySchema(this.db)
if (unavailable.join() === this.unavailable.join()) {
return
}
this.unavailable = unavailable
this.retrieval = new SessionSearchRetrieval(this.db, !unavailable.includes('typo-repair'))
}
/**
* Runs a retrieval, and re-probes once if it turns out the index no longer
* has what an earlier probe found.
*
* `probeCapabilities` already runs per search, so this only covers the window
* between that probe and the statement that reaches for the table. Losing a
* table there is a thrown error rather than a wrong verdict, so it re-probes
* and runs the search again.
*/
private withCapabilityRetry(run: () => RankedPage): RankedPage {
try {
return run()
} catch (error) {
if (!isMissingTableError(error)) {
throw error
}
this.probeCapabilities()
return run()
}
}
/**
* Operators with no free text still name a scope, so the answer is the newest
* sessions inside it. Ranked through the same path as a text query, because
* forks must fold here exactly as they do there or the same sessions answer
* `repo:x` and `word repo:x` differently. There is no relevance signal
* without text, so the order is always newest.
*/
private operatorOnly(split: AiVaultSearchQuerySplit, scope: RetrievalScope): RankedPage {
if (!hasAiVaultSearchQueryOperators(split)) {
return { ranked: [], retrieved: null, incomplete: false }
}
const { sessions, incomplete } = this.retrieval.recent(scope)
return { ranked: rankSessionHits(sessions, new Map(), 'newest'), retrieved: null, incomplete }
}
private text(
plan: ReturnType<typeof planSessionSearchQuery>,
scope: RetrievalScope,
sort: 'relevance' | 'newest'
): RankedPage {
const retrieved = this.retrieval.run(plan, scope)
// `match` already grouped to one best row per session.
const best = new Map<number, MessageRow>(retrieved.rows.map((row) => [row.session_row_id, row]))
// Operators cut here, after retrieval, so the candidate count still reports
// what the SQL limit saw: that is what tells a caller the limit was binding.
const sessions = this.retrieval.loadSessions([...best.keys()], scope)
// Counted before the operator predicate and before fork folding: the SQL
// LIMIT is what could have hidden a session, and it saw the unfiltered set.
return {
ranked: rankSessionHits(sessions, best, sort),
retrieved,
incomplete: best.size >= this.candidateLimit
}
}
/** Snippets and source presence are paid for by the page, never by the list. */
private hits(
page: readonly RankedSession[],
scope: SessionSearchScope,
retrieved: Retrieved | null
): SessionSearchHit[] {
const presence = sessionSourcePresence(
this.db,
page.map((entry) => entry.session.id)
)
return page.map((entry) => this.hit(entry, scope, retrieved, presence))
}
private hit(
entry: RankedSession,
scope: SessionSearchScope,
retrieved: Retrieved | null,
presence: ReadonlyMap<number, SessionSearchSourcePresence>
): SessionSearchHit {
const { session, message } = entry
const snippet =
message && retrieved
? sessionSearchSnippet(this.db, scope, message.rowid, retrieved.plan)
: EMPTY_SNIPPET
return {
...sessionFields(session),
score: entry.score,
...(entry.duplicateCount > 1 ? { duplicateCount: entry.duplicateCount } : {}),
source: presence.get(session.id) ?? 'unverifiable',
evidence: message
? {
role: message.role as TranscriptMessageRole,
timestamp: message.ts,
snippet: snippet.text,
...(snippet.truncated ? { snippetTruncated: true } : {})
}
: null
}
}
}
// SQLite reports a table that went away at the statement that reaches for it.
// `fts5` is in the message when the table is the vocabulary's target, which is
// the one an index rebuilt under a live connection loses first.
const MISSING_TABLE = /no such (fts5 )?table/i
function isMissingTableError(error: unknown): boolean {
return error instanceof Error && MISSING_TABLE.test(error.message)
}
/**
* The one reading of `repo:` / `path:`: the sessions panel's own predicate, over
* the columns the index stores. The engine has no project map, so a session's
* repo label falls back to its folder label, which is what the panel does for
* every session it cannot resolve a project for.
*/
function operatorPredicate(split: AiVaultSearchQuerySplit): (session: SessionRow) => boolean {
if (!hasAiVaultSearchQueryOperators(split)) {
return () => true
}
return (session) =>
matchesAiVaultQueryOperators(
{ cwd: session.cwd, filePath: session.file_path },
{ repoTerms: split.repoTerms, pathTerms: split.pathTerms }
)
}
function sessionFields(
session: SessionRow
): Omit<SessionSearchHit, 'score' | 'evidence' | 'source' | 'duplicateCount'> {
return {
agent: session.agent,
sessionId: session.session_id,
filePath: session.file_path,
codexHome: session.codex_home,
title: session.title,
cwd: session.cwd,
branch: session.branch,
updatedAt: session.updated_at,
messageCount: session.message_count,
resumeCommand: session.resume_command
}
}
@@ -137,8 +137,11 @@ it('rolls a whole file back when a write throws part way through its transaction
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40)
expect(errors).toHaveLength(1)
// The file is owed a re-read, which is the only reason anything was lost.
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
// The row itself says the read failed, which is the only reason anything was
// lost and the only record that outlives this read.
expect(
index.db.prepare('SELECT state, fail_count FROM files WHERE path = ?').get(SYNTHETIC_TRANSCRIPT)
).toMatchObject({ state: 'failed', fail_count: 1 })
// And the connection is usable again: a transaction left open by the failure
// would take down every write after it, not just the one that threw.
@@ -590,10 +593,16 @@ it('writes nothing for an incomplete read and owes the file a whole re-read', ()
expect(counts(index.db)).toMatchObject({
sessions: 0,
messages: 0,
files: 0,
full: 0
})
expect(store.pendingFileCount).toBe(1)
// One row, holding nothing but the failure: an incomplete read indexes no
// content, and the count of how often it has happened at this stat is the
// only thing that stops the file being read again on every pass.
expect(index.db.prepare('SELECT byte_offset, state, fail_count FROM files').get()).toMatchObject({
byte_offset: 0,
state: 'failed',
fail_count: 1
})
expect(errors).toEqual([])
})
@@ -1,172 +0,0 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import type SyncDatabase from '../sqlite/sync-database'
import { indexTokens } from './session-search-query-planner'
import { ensureSessionSearchQuerySchema } from './session-search-query-schema'
import { openSessionSearchDatabase } from './session-search-schema'
// SQLite/FTS5 behaviours the query layer depends on. Each one cost a live
// debugging session; a refactor that reintroduces the trap fails here.
const FIRST_ROWID = 101
const SECOND_ROWID = 202
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => removeTree(root)))
tempRoots = []
})
async function openDatabase(): Promise<SyncDatabase> {
const root = await mkdtemp(join(tmpdir(), 'orca-fts5-contract-'))
tempRoots.push(root)
return openSessionSearchDatabase(join(root, 'index.sqlite'))
}
function insertMessageRow(db: SyncDatabase, rowid: number, text: string): void {
db.prepare(
`INSERT INTO messages_fts(rowid, user_text, assistant_text, tool_text, identifiers)
VALUES (?, ?, '', '', '')`
).run(rowid, text)
}
describe('FTS5 aux functions take the table name, never an alias', () => {
it('rejects bm25 over an aliased table and accepts the table-name form', async () => {
const db = await openDatabase()
insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one')
expect(() =>
db.prepare('SELECT bm25(f) AS score FROM messages_fts f WHERE f MATCH ?').all('alpha')
).toThrow(/no such column: f/)
const scored = db
.prepare('SELECT bm25(messages_fts) AS score FROM messages_fts WHERE messages_fts MATCH ?')
.all('alpha') as { score: number }[]
expect(scored).toHaveLength(1)
expect(Number.isFinite(scored[0]?.score)).toBe(true)
db.close()
})
it('rejects snippet over an aliased table too', async () => {
const db = await openDatabase()
insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one')
expect(() =>
db
.prepare(
"SELECT snippet(f, -1, '[', ']', '…', 12) AS s FROM messages_fts f WHERE f MATCH ?"
)
.all('alpha')
).toThrow(/no such column: f/)
db.close()
})
})
describe('a rowid constraint beside MATCH is honoured only as a subselect', () => {
it('ignores `rowid = ?` and returns every match, first row first', async () => {
const db = await openDatabase()
insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one')
insertMessageRow(db, SECOND_ROWID, 'alpha capybara two')
const rows = db
.prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?')
.all('alpha', SECOND_ROWID) as { rowid: number }[]
// The planner drops the constraint entirely: both rows come back.
expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID])
// A caller reading one row therefore gets the first match, not the one asked for.
const single = db
.prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?')
.get('alpha', SECOND_ROWID) as { rowid: number } | undefined
expect(single?.rowid).toBe(FIRST_ROWID)
db.close()
})
it('ignores `rowid IN (?)` the same way', async () => {
const db = await openDatabase()
insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one')
insertMessageRow(db, SECOND_ROWID, 'alpha capybara two')
const rows = db
.prepare('SELECT rowid FROM messages_fts WHERE messages_fts MATCH ? AND rowid IN (?)')
.all('alpha', SECOND_ROWID) as { rowid: number }[]
expect(rows.map((row) => row.rowid)).toEqual([FIRST_ROWID, SECOND_ROWID])
db.close()
})
it('honours `rowid IN (SELECT ?)` even with the session join on', async () => {
const db = await openDatabase()
db.prepare(
`INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command)
VALUES (1,'claude','1','/synthetic/1','fixture','')`
).run()
for (const rowid of [FIRST_ROWID, SECOND_ROWID]) {
db.prepare("INSERT INTO messages(id,session_row_id,role) VALUES (?,1,'user')").run(rowid)
}
insertMessageRow(db, FIRST_ROWID, 'alpha marmoset one')
insertMessageRow(db, SECOND_ROWID, 'alpha capybara two')
// The shape the snippet read uses: the joins are what subtract a row whose
// session a purge cut loose, and they must not cost the rowid constraint
// its effect.
const snippet = db
.prepare(
`SELECT snippet(messages_fts, -1, '[', ']', '…', 12) AS s
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)`
)
.get('alpha', SECOND_ROWID) as { s: string } | undefined
expect(snippet?.s).toContain('capybara')
expect(snippet?.s).not.toContain('marmoset')
db.close()
})
})
describe('sessions.file_path is deliberately not unique', () => {
it('accepts two sessions sharing one store path', async () => {
const db = await openDatabase()
const insert = db.prepare(
`INSERT INTO sessions(agent, session_id, file_path, title, resume_command)
VALUES (?, ?, ?, ?, ?)`
)
// OpenCode and Cursor keep every session in one SQLite store; files.path is the key.
const storePath = '/home/user/.local/share/opencode/storage.db'
insert.run('opencode', 'ses_one', storePath, 'first', 'opencode --session ses_one')
expect(() =>
insert.run('opencode', 'ses_two', storePath, 'second', 'opencode --session ses_two')
).not.toThrow()
const rows = db
.prepare('SELECT session_id FROM sessions WHERE file_path = ? ORDER BY session_id')
.all(storePath) as { session_id: string }[]
expect(rows.map((row) => row.session_id)).toEqual(['ses_one', 'ses_two'])
db.close()
})
})
describe('the planner tokenizer draws the same boundaries as unicode61', () => {
// unicode61 folds case and strips Latin diacritics on both index and query side.
function asIndexed(token: string): string {
return token.toLowerCase().normalize('NFD').replaceAll(/\p{M}/gu, '')
}
it('produces exactly the terms fts5vocab reports for the same text', async () => {
const db = await openDatabase()
// The vocabulary is the engine's own object, not the store's.
ensureSessionSearchQuerySchema(db)
const corpus =
'resolveTerminalPath src/main/foo-bar.ts a.b C++ #123 修复 café naïve MAX_TOKEN x'
insertMessageRow(db, FIRST_ROWID, corpus)
const indexed = (
db.prepare('SELECT term FROM messages_vocab ORDER BY term').all() as { term: string }[]
).map((row) => row.term)
expect([...new Set(indexTokens(corpus).map(asIndexed))].sort()).toEqual(indexed)
db.close()
})
})
@@ -1,102 +0,0 @@
import { describe, expect, it } from 'vitest'
import { rankSessionHits, type MessageRow, type SessionRow } from './session-search-hit-ranking'
function session(id: number, overrides: Partial<SessionRow> = {}): SessionRow {
return {
id,
agent: 'claude',
session_id: String(id),
file_path: `/synthetic/${id}.jsonl`,
codex_home: null,
title: 'fixture',
cwd: '/repo/app',
branch: null,
updated_at: '2026-09-01T00:00:00.000Z',
message_count: 1,
resume_command: 'resume',
content_hash: null,
content_hash_count: 0,
...overrides
}
}
function match(id: number, score: number): MessageRow {
return { rowid: id, score, session_row_id: id, role: 'user', ts: null }
}
function matches(...rows: MessageRow[]): Map<number, MessageRow> {
return new Map(rows.map((row) => [row.session_row_id, row]))
}
describe('order', () => {
it('ranks by score under relevance and by recency under newest', () => {
const sessions = [
session(1, { updated_at: '2026-09-01T00:00:00.000Z' }),
session(2, { updated_at: '2026-09-09T00:00:00.000Z' })
]
const scores = matches(match(1, 10), match(2, 1))
expect(rankSessionHits(sessions, scores, 'relevance').map((e) => e.session.id)).toEqual([1, 2])
expect(rankSessionHits(sessions, scores, 'newest').map((e) => e.session.id)).toEqual([2, 1])
})
it.each(['relevance', 'newest'] as const)(
'breaks a %s tie by session, whatever order retrieval handed them over in',
(sort) => {
// A cursor is an offset into this list, so two entries that tie must not
// be free to swap between pages. Retrieval hands sessions over in
// whatever order the `IN (...)` lookup produced, which SQL does not
// promise, so the order below is deliberately reversed.
const sessions = [6, 5, 4, 3, 2, 1].map((id) => session(id))
const scores = matches(...sessions.map((entry) => match(entry.id, 5)))
expect(rankSessionHits(sessions, scores, sort).map((entry) => entry.session.id)).toEqual([
1, 2, 3, 4, 5, 6
])
}
)
it('prefers the shorter session when two match equally well', () => {
// The length prior: `0.02 · ln(1 + messages)`, subtracted per session.
const sessions = [session(1, { message_count: 5000 }), session(2, { message_count: 2 })]
const ranked = rankSessionHits(sessions, matches(match(1, 5), match(2, 5)), 'relevance')
expect(ranked.map((entry) => entry.session.id)).toEqual([2, 1])
expect(ranked[0]!.score).toBeGreaterThan(ranked[1]!.score)
})
})
describe('forks fold into one answer', () => {
const fork = (id: number, updatedAt: string): SessionRow =>
session(id, {
updated_at: updatedAt,
content_hash: 'shared-opening-prefix',
content_hash_count: 8
})
it('keeps the newest copy and counts the rest', () => {
const sessions = [
fork(1, '2026-09-01T00:00:00.000Z'),
fork(2, '2026-09-09T00:00:00.000Z'),
fork(3, '2026-09-05T00:00:00.000Z')
]
const ranked = rankSessionHits(
sessions,
matches(match(1, 9), match(2, 1), match(3, 5)),
'relevance'
)
expect(ranked).toHaveLength(1)
expect(ranked[0]!.session.id).toBe(2)
expect(ranked[0]!.duplicateCount).toBe(3)
})
it('leaves sessions with no shared prefix alone', () => {
const sessions = [session(1), session(2)]
const ranked = rankSessionHits(sessions, matches(match(1, 9), match(2, 5)), 'relevance')
expect(ranked.map((entry) => entry.duplicateCount)).toEqual([1, 1])
})
})
it('scores a session that matched no text at zero, less its length prior', () => {
// The operator-only page: there is no relevance signal, only an order.
const ranked = rankSessionHits([session(1, { message_count: 9 })], new Map(), 'newest')
expect(ranked[0]!.message).toBeNull()
expect(ranked[0]!.score).toBeLessThan(0)
})
@@ -1,109 +0,0 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import { isCollapsibleContentHash } from './session-search-content-hash'
import type { SessionSearchSort } from './session-search-engine-types'
// Subtracted per session: `0.02 · ln(1 + messages)`; slightly positive on both eval sets.
const LENGTH_PRIOR = 0.02
export type SessionRow = {
id: number
agent: AiVaultAgent
session_id: string
file_path: string
codex_home: string | null
title: string
cwd: string | null
branch: string | null
updated_at: string | null
message_count: number
resume_command: string
content_hash: string | null
content_hash_count: number
}
/** The one message that stands for a session: its best-scoring match. */
export type MessageRow = {
rowid: number
score: number
session_row_id: number
role: string
ts: string | null
}
export type RankedSession = {
session: SessionRow
/** Null on an operator-only page: the session matched no text at all. */
message: MessageRow | null
score: number
duplicateCount: number
}
/**
* Everything between "these sessions matched" and "this is the ranked list":
* the length prior, fork folding and the caller's order. Retrieval stays in SQL
* and nothing here touches the database.
*
* The whole list is returned, not a page: a cursor indexes into it, and slicing
* here would make page two a different ranking from page one. The engine cuts
* the page and only then pays for a snippet.
*/
export function rankSessionHits(
sessions: readonly SessionRow[],
matches: ReadonlyMap<number, MessageRow>,
sort: SessionSearchSort
): RankedSession[] {
const scored = collapseForks(
sessions.map((session) => {
const message = matches.get(session.id) ?? null
return {
session,
message,
score: (message?.score ?? 0) - LENGTH_PRIOR * Math.log(1 + session.message_count),
duplicateCount: 1
}
})
)
// Why a total order and not just the key: a cursor is an offset into this
// list, so two entries that tie must not be free to swap between pages.
scored.sort(
(left, right) =>
(sort === 'newest'
? (right.session.updated_at ?? '').localeCompare(left.session.updated_at ?? '')
: right.score - left.score) || left.session.id - right.session.id
)
return scored
}
/**
* Folds forked copies of one conversation into a single entry: same opening
* prefix, newest `updated_at` wins, the rest become `duplicateCount`. Done here
* and not at write time so index rows stay per file (cursors and deletes).
*/
function collapseForks(scored: RankedSession[]): RankedSession[] {
const groups = new Map<string, RankedSession[]>()
for (const entry of scored) {
const { content_hash: hash, content_hash_count: count, id } = entry.session
const key = isCollapsibleContentHash(hash, count) ? `hash:${hash}` : `session:${id}`
const group = groups.get(key)
if (group) {
group.push(entry)
} else {
groups.set(key, [entry])
}
}
const collapsed: RankedSession[] = []
for (const group of groups.values()) {
if (group.length === 1) {
collapsed.push(group[0]!)
continue
}
const winner = group.reduce((best, entry) => (isNewer(entry, best) ? entry : best))
collapsed.push({ ...winner, duplicateCount: group.length })
}
return collapsed
}
function isNewer(entry: RankedSession, best: RankedSession): boolean {
const order = (entry.session.updated_at ?? '').localeCompare(best.session.updated_at ?? '')
return order === 0 ? entry.score > best.score : order > 0
}
@@ -10,7 +10,7 @@ import {
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore, STALE_PATH_LIMIT } from './session-search-store'
import { SessionSearchStore } from './session-search-store'
let index: SessionSearchIndexFile
let store: SessionSearchStore
@@ -41,6 +41,13 @@ function cursor(): number | null | undefined {
return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset
}
/** What the row itself says it still owes, which is the only record there is. */
function owed(): { state: string; fail_count: number } | undefined {
return index.db
.prepare('SELECT state, fail_count FROM files WHERE path = ?')
.get(SYNTHETIC_TRANSCRIPT) as { state: string; fail_count: number } | undefined
}
it('appends onto its own cursor and carries the content hash forward', async () => {
replayTranscriptRead({
messages: userMessages('first half', 3),
@@ -64,7 +71,7 @@ it('appends onto its own cursor and carries the content hash forward', async ()
.get() as { hash: string; count: number }
expect(second.count).toBe(first.count + 2)
expect(second.hash).not.toBe(first.hash)
expect(store.takeStale()).toEqual([])
expect(owed()).toMatchObject({ state: 'current', fail_count: 0 })
})
it('appends onto a file it read through and decoded no session from', async () => {
@@ -76,7 +83,7 @@ it('appends onto a file it read through and decoded no session from', async () =
outcome: { session: null, byteOffset: 100 }
})
expect(cursor()).toBe(100)
expect(store.takeStale()).toEqual([])
expect(owed()).toMatchObject({ state: 'current', fail_count: 0 })
replayTranscriptRead({
mode: 'append',
@@ -87,7 +94,7 @@ it('appends onto a file it read through and decoded no session from', async () =
expect(indexedMessages()).toBe(2)
expect(cursor()).toBe(220)
expect(store.takeStale()).toEqual([])
expect(owed()).toMatchObject({ state: 'current', fail_count: 0 })
})
it('declines an append that starts past its own cursor and records the file', async () => {
@@ -107,7 +114,7 @@ it('declines an append that starts past its own cursor and records the file', as
expect(indexedMessages()).toBe(3)
expect(cursor()).toBe(100)
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
expect(owed()).toMatchObject({ state: 'due' })
})
it('declines a file whose identity changed under the same path', async () => {
@@ -127,7 +134,7 @@ it('declines a file whose identity changed under the same path', async () => {
})
expect(indexedMessages()).toBe(2)
expect(store.takeStale()).toHaveLength(1)
expect(owed()?.state).not.toBe('current')
})
it('never advances the cursor for an incomplete read', async () => {
@@ -152,7 +159,7 @@ it('never advances the cursor for an incomplete read', async () => {
}
).n
).toBe(3)
expect(store.takeStale()).toHaveLength(1)
expect(owed()?.state).not.toBe('current')
})
it('indexes nothing at all from a read that was incomplete from the start', async () => {
@@ -167,7 +174,11 @@ it('indexes nothing at all from a read that was incomplete from the start', asyn
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
n: 0
})
expect(cursor()).toBeUndefined()
// No cursor, because nothing was read through. The row exists all the same:
// it is where the failure is counted, and a file that fails on its first read
// is exactly the one that has no row of its own to count on.
expect(cursor()).toBe(0)
expect(owed()).toMatchObject({ state: 'failed', fail_count: 1 })
})
it('drops a file whose parser returned no session', async () => {
@@ -207,7 +218,9 @@ it('writes nothing for a source whose parser cannot reach the channel', async ()
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.takeStale()).toEqual([])
// No row at all, which is the record: the next pass reads a path the
// file table does not name.
expect(owed()).toBeUndefined()
})
it('ignores a candidate older than the retention cutoff', async () => {
@@ -217,53 +230,9 @@ it('ignores a candidate older than the retention cutoff', async () => {
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.takeStale()).toEqual([])
})
it('stops writing while the store refuses writes, but remembers what it skipped', async () => {
store.setAcceptingWrites(false)
replayTranscriptRead({ messages: userMessages('paused', 3) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(errors).toEqual([])
// A pause is exactly the window in which every read is declined. Forgetting
// them would leave the whole paused span unindexed with nothing to replay it.
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
})
it('keeps the paused re-read set when the retention window is reconfigured', async () => {
store.setAcceptingWrites(false)
replayTranscriptRead({ messages: userMessages('paused', 2) })
expect(store.pendingFileCount).toBe(1)
// The set records what still has to be read, not what is worth keeping. A
// window that now excludes this file is enforced where the re-read is
// dispatched, so nothing is written and the file leaves the set there.
store.setRetentionCutoffMs(Date.now())
expect(store.pendingFileCount).toBe(1)
store.setAcceptingWrites(true)
expect(store.takeStale()).toHaveLength(1)
replayTranscriptRead({ messages: userMessages('outside the window now', 2) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.pendingFileCount).toBe(0)
})
it('drops the oldest record rather than growing without a bound, and says so', () => {
store.setAcceptingWrites(false)
for (let index = 0; index < STALE_PATH_LIMIT + 5; index++) {
store.markStale(syntheticCandidate({ path: `/transcript-${index}.jsonl` }))
}
expect(store.pendingFileCount).toBe(STALE_PATH_LIMIT)
expect(store.droppedPendingFileCount).toBe(5)
const kept = store.takeStale().map((candidate) => candidate.file.path)
expect(kept).not.toContain('/transcript-0.jsonl')
expect(kept).toContain(`/transcript-${STALE_PATH_LIMIT + 4}.jsonl`)
// No row at all, which is the record: the next pass reads a path the
// file table does not name.
expect(owed()).toBeUndefined()
})
it('keeps the session list running when the index write fails', async () => {
@@ -282,7 +251,7 @@ it('keeps the session list running when the index write fails', async () => {
})
).not.toThrow()
expect(errors.length).toBeGreaterThan(0)
expect(store.takeStale()).toHaveLength(1)
expect(owed()?.state).not.toBe('current')
})
it('unregisters cleanly, leaving later reads unindexed', async () => {
@@ -369,5 +338,5 @@ it('keeps a proven file identity when a later read cannot stat it', async () =>
expect(indexedMessages()).toBe(4)
expect(cursor()).toBe(200)
expect(store.takeStale()).toHaveLength(1)
expect(owed()?.state).not.toBe('current')
})
@@ -7,6 +7,7 @@ import {
type TranscriptReadOutcome,
type TranscriptReadStart
} from '../ai-vault/session-transcript-consumers'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import { fileIdentity } from './session-search-file-cursor'
import type { SessionSearchFileWrite } from './session-search-index-writer'
import type { SessionSearchStore } from './session-search-store'
@@ -16,30 +17,22 @@ import type { SessionSearchStore } from './session-search-store'
*
* It keeps its own cursor in the `files` table and never consults the parse
* cache: the two answer different questions and diverge the moment either
* declines a read. Three refusals, each of which leaves the cursor where it
* was and records the file for a later whole re-read:
* declines a read.
*
* - `beginRead` returns null when this index's cursor is behind the offset an
* `append` continues from, or when the file's identity changed.
* - a buffering failure stops the read's rows without failing the session list.
* - an `incomplete` outcome never commits; those rows are not the whole span.
* Every refusal leaves the cursor where it was and writes what the next pass
* needs on the row itself, because the row is the only thing that outlives this
* read. A declined append is `due`: the index is behind on a span no append
* reaches, so the file has to be read whole. A read that started and did not
* commit is `failed`, counted, and stamped with the stat it failed at, which is
* what stops an unreadable transcript being retried on every pass for ever.
*/
export class SessionSearchIndexConsumer implements TranscriptConsumer {
constructor(private readonly store: SessionSearchStore) {}
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
const { candidate } = start
if (!this.store.acceptsCandidate(candidate)) {
// A pause is a reason not to write now, not a reason to forget the read.
// `markStale` applies the retention rule itself, so a candidate that is
// out of scope rather than merely paused is still dropped here.
this.store.markStale(candidate)
return null
}
// A parser that decodes where the channel cannot reach it reports every read
// as incomplete. Declining here is not the same as being behind: no re-read
// would help, so the file is not recorded either.
if (!parserPublishesMessages(candidate)) {
this.noteUnreachableParser(candidate)
return null
}
if (start.mode === 'append') {
@@ -48,7 +41,8 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
// This index never saw the span before `previousByteOffset`; appending
// here would leave a hole no later read can fill. A null cursor is the
// file a chunked read left half written, which no offset continues.
this.store.markStale(candidate)
// Either way the next pass has to read this file from the start.
this.store.setFileState(candidate.file.path, 'due')
return null
}
}
@@ -59,11 +53,42 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
start.identity
)
if (!write) {
this.store.markStale(candidate)
// A closed store, a candidate outside the retention window, or a row that
// moved under this read. Only a row that exists has anything to record.
this.store.setFileState(candidate.file.path, 'due')
return null
}
return new SessionSearchReadConsumer(this.store, start, write)
}
/**
* A source no read can ever index, recorded as one this index has seen.
*
* A parser that decodes where the message channel cannot reach it -- OpenCode's
* SQLite sessions today -- publishes nothing, so no read of it will ever
* commit a row. Leaving the file table silent about it is not free: the next
* pass sees a path the index holds nothing for, asks for a read, and asking
* over a warm cache drops the session list's own resume point. The sidebar's
* fold is thrown away and the whole database is decoded again, on every pass,
* for ever.
*
* The row written is the shape the store already has for a read that went
* through and decoded no session: cursor at the file's size, no session row.
* The decide step then skips it until its stat moves, and the retirement walk
* retires it like any other row when it goes.
*/
private noteUnreachableParser(candidate: SessionFileCandidate): void {
const write = this.store.beginWrite(candidate, 'replace', 0)
const committed =
write?.commit({
session: null,
byteOffset: candidate.file.sizeBytes ?? 0,
incomplete: false
}) === true
if (committed) {
this.store.writeCommitted(candidate)
}
}
}
class SessionSearchReadConsumer implements TranscriptReadConsumer {
@@ -105,7 +130,10 @@ class SessionSearchReadConsumer implements TranscriptReadConsumer {
this.store.writeCommitted(candidate)
return
}
this.store.markStale(candidate)
// Counted against the stat it failed at, not merely recorded: a transcript
// the reader cannot open fails identically on every pass, and only a change
// to this stat can mean the file itself changed.
this.store.setFileState(candidate.file.path, 'failed', candidate.file.mtimeMs)
}
}
@@ -1,320 +0,0 @@
import { appendFile, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchEngine } from './session-search-engine'
import { readIndexGeneration } from './session-search-index-generation'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import type { SessionSearchCursorError } from './session-search-page-cursor'
import { openSessionSearchDatabase } from './session-search-schema'
import { SessionSearchStore } from './session-search-store'
import { parseTranscript, userRecord } from './session-search-transcript-fixtures'
let roots: string[] = []
let handles: SyncDatabase[] = []
afterEach(async () => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
for (const handle of handles) {
handle.close()
}
handles = []
await Promise.all(roots.map((root) => removeTree(root)))
roots = []
})
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-search-generation-'))
roots.push(root)
return root
}
/**
* A reader's own handle on the index, with the engine's schema installed.
*
* PR 2's store keeps its connection private, so a reader opens its own — which
* is what the fence has to survive: nothing this handle does moves the
* generation, and it must still see every writer's move.
*/
function reader(path: string): SyncDatabase {
const db = openSessionSearchDatabase(path)
handles.push(db)
// Constructing an engine is what installs the triggers.
new SessionSearchEngine(db)
return db
}
/** Indexes one transcript through the real consumer and returns its path. */
async function indexOneTranscript(root: string, store: SessionSearchStore): Promise<string> {
resetSessionParseCacheForTests()
const sessionId = `aaaaaaaa-0000-4000-8000-${String(roots.length).padStart(12, '0')}`
const path = join(root, `${Math.random().toString(36).slice(2)}.jsonl`)
await writeFile(path, `${userRecord(0, 'generation fixture needle', sessionId)}\n`)
const unregister = registerSessionSearchIndexConsumer(store)
try {
await parseTranscript(path)
} finally {
unregister()
}
return path
}
it('moves the generation forward when a committed read changes what a read returns', async () => {
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
const before = readIndexGeneration(db)
await indexOneTranscript(root, store)
expect(readIndexGeneration(db)).toBeGreaterThan(before)
} finally {
store.close()
}
})
it('moves the generation forward when an append adds rows to a live session', async () => {
// The first read of a file inserts its `files` row; every read after that
// updates it. An append changes a session's rank and its message count, so a
// cursor minted before it indexes into a list that no longer exists.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
const transcript = await indexOneTranscript(root, store)
const indexed = readIndexGeneration(db)
const unregister = registerSessionSearchIndexConsumer(store)
try {
resetSessionParseCacheForTests()
await appendFile(transcript, `${userRecord(1, 'a second needle turn')}\n`)
await parseTranscript(transcript)
} finally {
unregister()
}
expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 2 })
expect(readIndexGeneration(db)).toBeGreaterThan(indexed)
} finally {
store.close()
}
})
it('moves the generation forward when a proven deletion hides a session', async () => {
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
const transcript = await indexOneTranscript(root, store)
const indexed = readIndexGeneration(db)
store.removeFile(transcript)
expect(readIndexGeneration(db)).toBeGreaterThan(indexed)
} finally {
store.close()
}
})
it('moves the generation forward when retention cuts a session loose', async () => {
// Retention deletes the session row and the file row in one transaction, then
// reclaims the messages over many. It is the first half that changes what a
// search returns, and the first half that has to move the generation.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
await indexOneTranscript(root, store)
const indexed = readIndexGeneration(db)
await store.purgeOlderThan(Date.now() + 60_000)
expect(db.prepare('SELECT COUNT(*) AS c FROM sessions').get()).toEqual({ c: 0 })
expect(readIndexGeneration(db)).toBeGreaterThan(indexed)
} finally {
store.close()
}
})
it('moves the generation when a purge reclaims rows nothing can reach', async () => {
// The drain writes only `messages`, and for a while that was argued to change
// no answer. Retrieval never saw those rows; the typo repair's dictionary
// did, because `messages_vocab` is a view over the FTS b-tree and lists a
// term whether or not a reader can reach it. See
// `session-search-orphan-rows.test.ts` for the answer that moved. The price
// of fencing it is a cursor refused once per batch while a purge runs.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
await indexOneTranscript(root, store)
// The shape an interrupted purge leaves: rows with no session row.
db.prepare('DELETE FROM sessions').run()
const orphaned = readIndexGeneration(db)
expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).not.toEqual({ c: 0 })
await store.purgeOlderThan(null)
expect(db.prepare('SELECT COUNT(*) AS c FROM messages').get()).toEqual({ c: 0 })
expect(readIndexGeneration(db)).toBeGreaterThan(orphaned)
} finally {
store.close()
}
})
it("leaves the generation alone when a replace swaps a session's own rows", async () => {
// The same trigger must not fire here, or every re-read of a large transcript
// would move the generation once per deleted row on top of the one bump its
// file record already makes. A replace deletes rows whose session row still
// stands, which is what the trigger's `WHEN` clause tests.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
await indexOneTranscript(root, store)
const rows = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }
const indexed = readIndexGeneration(db)
db.prepare('DELETE FROM messages WHERE session_row_id IN (SELECT id FROM sessions)').run()
expect(rows.c).toBeGreaterThan(0)
expect(readIndexGeneration(db)).toBe(indexed)
} finally {
store.close()
}
})
it('leaves the generation alone when a removal hides nothing', async () => {
// A backfill retires paths it never held; if that moved the generation, every
// cursor would be refused for as long as indexing ran.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
await indexOneTranscript(root, store)
const before = readIndexGeneration(db)
store.removeFile('/synthetic/never-indexed.jsonl')
expect(readIndexGeneration(db)).toBe(before)
} finally {
store.close()
}
})
it('keeps the generation across a reopen, because the bump rides its own commit', async () => {
// The bump is inside the transaction that changes visibility, so nothing can
// be lost to a crash and reopening need not invalidate anyone's cursor.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
reader(path)
const first = new SessionSearchStore(path, (error) => {
throw error
})
await indexOneTranscript(root, first)
const indexed = readIndexGeneration(reader(path))
first.close()
const second = new SessionSearchStore(path)
try {
expect(readIndexGeneration(reader(path))).toBe(indexed)
} finally {
second.close()
}
})
it('fences a reader against a writer it does not share a process with', async () => {
// The shape PR 3 creates: the indexer writes from the scanner child while an
// engine reads elsewhere. A generation cached in the reader's memory tracks
// only that reader's own writes, so it would stand still through the
// writer's deletion, honour the stale cursor, and skip a session.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const writer = new SessionSearchStore(path, (error) => {
throw error
})
try {
const transcripts: string[] = []
for (let n = 0; n < 3; n++) {
transcripts.push(await indexOneTranscript(root, writer))
}
const engine = new SessionSearchEngine(db)
const page = engine.search({ query: 'needle', limit: 1 })
expect(page.page.cursor).not.toBeNull()
writer.removeFile(transcripts[0]!)
// The reader never wrote anything, and must still refuse.
try {
engine.search({ query: 'needle', limit: 1, cursor: page.page.cursor! })
expect.unreachable('a page cursor must not survive another writer moving the index')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('stale-generation')
}
} finally {
writer.close()
}
})
it('re-creates a fence something dropped, on the next search', async () => {
// An index whose triggers are gone cannot move its generation, so every stale
// cursor would compare equal and be honoured against a list the caller never
// saw. The engine owns those triggers, so it puts them back.
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const store = new SessionSearchStore(path, (error) => {
throw error
})
try {
await indexOneTranscript(root, store)
const engine = new SessionSearchEngine(db)
db.exec('DROP TRIGGER search_generation_file_update')
engine.search({ query: 'needle' })
const restored = readIndexGeneration(db)
await indexOneTranscript(root, store)
expect(readIndexGeneration(db)).toBeGreaterThan(restored)
} finally {
store.close()
}
})
it('mints a distinct generation per change even when two handles write', async () => {
const root = await tempRoot()
const path = join(root, 'index.sqlite')
const db = reader(path)
const first = new SessionSearchStore(path, (error) => {
throw error
})
const second = new SessionSearchStore(path, (error) => {
throw error
})
try {
const seen: number[] = [readIndexGeneration(db)]
for (const store of [first, second, first, second]) {
await indexOneTranscript(root, store)
seen.push(readIndexGeneration(db))
}
// Read-then-write from two connections would hand out one value twice.
expect(new Set(seen).size).toBe(seen.length)
expect([...seen].sort((left, right) => left - right)).toEqual(seen)
} finally {
second.close()
first.close()
}
})
@@ -1,97 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
const GENERATION_KEY = 'index_generation'
/**
* Names of the triggers that move the generation. Exported so the query schema
* can check they are all still there before an engine trusts a cursor.
*/
export const SESSION_SEARCH_GENERATION_TRIGGERS = [
'search_generation_file_insert',
'search_generation_file_update',
'search_generation_file_delete',
'search_generation_orphan_reclaim'
] as const
const BUMP = `INSERT INTO meta(key, value) VALUES ('${GENERATION_KEY}', '1')
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1;`
/**
* The fence, as three triggers on `files`.
*
* Why `files`. Every transaction the store opens that can change what a search
* returns writes this table: a committed read upserts the file's cursor beside
* its rows, a chunk of a long read upserts the partial sentinel beside its
* prefix, `removeFile` deletes the row with the session, and retention deletes
* the file row in the same transaction as the session row.
*
* And why `messages` as well, for orphans only. Retention's second half
* reclaims rows whose session row is already gone, and touches neither table
* above. It was left unfenced on the argument that those rows answer nothing,
* which is true of retrieval and was not true of the whole engine: the typo
* repair's dictionary is `messages_vocab`, a view over the FTS b-tree that
* lists a term whether or not a reader can reach the rows carrying it, and
* reclaiming them moved which word a query was repaired to. The repair now
* counts live rows instead, so the common case is fixed at its source; this
* trigger is what makes the fence true rather than nearly true, because the
* vocabulary still decides which candidates survive its scan limit.
*
* The `WHEN` clause is what keeps it free. `removeFile` deletes a session's
* rows while its `sessions` row still stands, so it does not fire here; a
* replace cuts the old `sessions` row loose and leaves its messages to the
* drain (PR 2 round 10). Both already bump through `files`; the drain is the
* only path that deletes a row whose session is gone, and it fires here. The cost of the fence is real and worth naming: a
* cursor outstanding while a purge runs is refused once per batch, which
* `SessionSearchCursorError` reports as `stale-generation` so a caller
* re-issues page one rather than showing anyone an error.
*
* A trigger rather than a call the writer makes, for two reasons. PR 4 does not
* own the writer, and more importantly the fence has to hold for writers this
* process cannot see: the triggers live in the file, so PR 3's indexer in the
* scanner child moves the generation without knowing a reader exists.
*
* Correctness comes from where the increment runs, not from what it counts. It
* is one statement inside the writer's own `BEGIN IMMEDIATE`, so it commits
* with the change it describes and two connections cannot mint one value twice.
* It over-counts in one harmless direction: a read that decoded no session from
* a file the index also held no session for advances a cursor and bumps
* anyway. That refuses a cursor early; it never honours one late.
*/
export const SESSION_SEARCH_GENERATION_SQL = `
CREATE TRIGGER IF NOT EXISTS search_generation_file_insert AFTER INSERT ON files BEGIN
${BUMP}
END;
CREATE TRIGGER IF NOT EXISTS search_generation_file_update AFTER UPDATE ON files BEGIN
${BUMP}
END;
CREATE TRIGGER IF NOT EXISTS search_generation_file_delete AFTER DELETE ON files BEGIN
${BUMP}
END;
CREATE TRIGGER IF NOT EXISTS search_generation_orphan_reclaim AFTER DELETE ON messages
WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = OLD.session_row_id) BEGIN
${BUMP}
END;
`
/**
* A monotone id for what the index currently publishes.
*
* A search page is a slice of one ranked list, so a cursor only means anything
* against the snapshot that produced it. Every change to what a read can return
* moves this on, and a cursor minted under an older value is refused rather
* than silently re-run against a list it no longer indexes into.
*
* Read from the database on every call, never cached in a process. The writer
* and the reader need not be the same one: PR 3's indexer runs in the scanner
* child while an engine reads elsewhere, and any number of handles may be open
* on one file. A generation cached in memory only ever tracks that process's
* own writes, so a reader would see another writer's deletions while its
* generation stood still, honour a stale cursor, and skip a session.
*/
export function readIndexGeneration(db: SyncDatabase): number {
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(GENERATION_KEY) as
| { value: string }
| undefined
const parsed = row ? Number(row.value) : Number.NaN
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
}
@@ -0,0 +1,194 @@
import { appendFile, rm, stat, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { runSessionSearchIndexPass } from './session-search-index-pass'
import { parseTranscript } from './session-search-transcript-fixtures'
import {
claudeLines,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
import { discoverSessionSearchCandidates } from './session-search-scan-roots'
import { SessionSearchStore } from './session-search-store'
const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
let harness: SessionSearchIndexerHarness
let store: SessionSearchStore
let errors: unknown[]
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
errors = []
harness = await openSessionSearchIndexerHarness('ss-index-pass')
await writeClaudeTranscript(transcript(FIRST), ['the first transcript'], FIRST)
await writeClaudeTranscript(transcript(SECOND), ['the second transcript'], SECOND)
store = openStore()
})
afterEach(async () => {
resetTranscriptConsumersForTests()
store.close()
await harness.cleanup()
})
function transcript(sessionId: string): string {
return join(harness.claudeProjectDir, `${sessionId}.jsonl`)
}
function openStore(): SessionSearchStore {
const opened = new SessionSearchStore(harness.databasePath, (error) => errors.push(error))
registerSessionSearchIndexConsumer(opened)
return opened
}
async function candidates() {
return (
await discoverSessionSearchCandidates(harness.roots, {
limitPerAgent: Number.POSITIVE_INFINITY
})
).candidates
}
/** What a pass hands the read loop: the store's rows, read once. */
function rows() {
return new Map(store.files().map((row) => [row.path, row]))
}
function pass(options: { overdue?: () => boolean } = {}) {
return runSessionSearchIndexPass(store, [], { rows: rows(), ...options })
}
async function passOverAll(options: { overdue?: () => boolean } = {}) {
return runSessionSearchIndexPass(store, await candidates(), { rows: rows(), ...options })
}
function states(): Record<string, string> {
return Object.fromEntries(store.files().map((row) => [row.path, row.state]))
}
it('re-reads nothing it already holds, even with a cold session-list cache', async () => {
const first = await passOverAll()
expect(first.stats.fullParses).toBe(2)
// A restart: the parse cache is gone, the index's `files` table is not.
store.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store = openStore()
const second = await passOverAll()
expect(second.stats).toMatchObject({ fullParses: 0, incremental: 0, reused: 0, bytesRead: 0 })
expect(errors).toEqual([])
})
it('resumes into a grown transcript instead of re-reading it whole', async () => {
await passOverAll()
await appendFile(transcript(FIRST), `${claudeLines(['a later turn'], FIRST, 10).join('\n')}\n`)
const second = await passOverAll()
expect(second.stats).toMatchObject({ incremental: 1, fullParses: 0 })
})
// Nothing is recorded about what a deadline cut off, because being owed is a
// fact about the row: the file is read on the next pass for the same reason it
// was owed on this one.
it('leaves what it ran out of time for owed, with nothing written down', async () => {
const all = await candidates()
const cut = await runSessionSearchIndexPass(store, all, { rows: rows(), overdue: () => true })
expect(cut.outOfTime).toBe(true)
expect(store.files()).toHaveLength(1)
const second = await passOverAll()
expect(second.stats.fullParses).toBe(1)
expect(store.files()).toHaveLength(2)
})
// The deadline is never applied before the pass has read anything, so a single
// transcript larger than one deadline is read alone rather than starved.
it('reads one file even when the deadline has already expired', async () => {
const only = (await candidates()).slice(0, 1)
const alone = await runSessionSearchIndexPass(store, only, { rows: rows(), overdue: () => true })
expect(alone.outOfTime).toBe(false)
expect(store.files()).toHaveLength(1)
})
it('skips a source the reader cannot even open without failing the pass', async () => {
const all = await candidates()
await rm(transcript(FIRST))
await runSessionSearchIndexPass(store, all, { rows: rows() })
// One session indexed, and the missing one recorded as a failed read rather
// than as content the index holds.
expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({
n: 1
})
expect(states()[transcript(FIRST)]).toBe('failed')
})
// Finding 6: mtime alone is not the freshness key. A transcript that grows
// while keeping its mtime (a same-second append, a restored timestamp) is a
// different file to the index, and reading only mtime would skip it forever.
it('re-reads a file that grew without its mtime moving', async () => {
const path = transcript(FIRST)
// A whole-millisecond stamp, so restoring it later reproduces it exactly.
const frozen = new Date(1_740_000_000_000)
await utimes(path, frozen, frozen)
await passOverAll()
await appendFile(path, `${claudeLines(['a same-mtime append'], FIRST, 20).join('\n')}\n`)
await utimes(path, frozen, frozen)
expect((await stat(path)).mtimeMs).toBe(frozen.getTime())
const second = await passOverAll()
expect(second.stats.fullParses + second.stats.incremental).toBe(1)
})
// Finding 5: the decision reads the session list's cache and then changes it,
// so outside the per-path lane an overlapping list parse stores its entry in
// between and the forced read degrades into a reuse.
it('is not overtaken by a list parse racing the same path', async () => {
const path = transcript(FIRST)
const all = await candidates()
const only = all.filter((candidate) => candidate.file.path === path)
// The list parses this path first, so its cursor covers the file, and again
// concurrently with the index's pass so the two interleave.
await parseTranscript(path)
await Promise.all([
parseTranscript(path),
runSessionSearchIndexPass(store, only, { rows: rows() })
])
expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({
n: 1
})
})
// Finding 4d: a declined read is a parse that returns normally and indexes
// nothing. It has to leave the row owing a read, not looking covered.
it('leaves a declined read owed rather than recorded as held', async () => {
const only = (await candidates()).slice(0, 1)
// What a store that refuses a write looks like from the consumer's side: the
// read runs, and nothing is written.
store.beginWrite = () => null
const stats = await runSessionSearchIndexPass(store, only, { rows: rows() })
expect(stats.stats.fullParses).toBe(1)
expect(harness.read((db) => db.prepare('SELECT count(*) AS n FROM sessions').get())).toEqual({
n: 0
})
expect(store.files()).toEqual([])
})
it('reads nothing when there is nothing to read', async () => {
expect((await pass()).stats).toMatchObject({ fullParses: 0 })
})
@@ -0,0 +1,84 @@
import { throwIfAiVaultScanCancelled } from '../ai-vault/ai-vault-scan-cancellation'
import {
createSessionParseStats,
parseAgentSessionFileCached,
type SessionParseStats
} from '../ai-vault/session-scanner-parse-cache'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import { fileIdentity } from './session-search-file-cursor'
import { sessionSearchReadDecision } from './session-search-read-decision'
import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store'
export type SessionSearchIndexPassOptions = {
signal?: AbortSignal
/** The store's rows for this pass, read once. Absent means the index holds nothing. */
rows: ReadonlyMap<string, SessionSearchFileRow>
/**
* True once the pass has spent its wall-clock deadline. The one bound on how
* long a pass reads for: files and bytes are proxies for time, and the thing
* worth capping is the share of the wall clock an unasked background index
* takes. Never applied before the pass has read anything, so an oversized
* transcript is read alone rather than deferred for ever.
*/
overdue?: () => boolean
}
/**
* Reads whatever the decide step says is owed, until the deadline.
*
* Nothing is recorded about what it did not reach. A candidate the deadline cut
* off is still owed on the next pass for the same reason it was owed on this
* one — its row says so — so there is no queue to keep, nothing to bound, and
* nothing to drop. What the reads themselves leave behind is written by the
* index consumer onto the rows.
*/
export async function runSessionSearchIndexPass(
store: SessionSearchStore,
candidates: readonly SessionFileCandidate[],
options: SessionSearchIndexPassOptions
): Promise<{ stats: SessionParseStats; outOfTime: boolean }> {
const stats = createSessionParseStats()
const cutoffMs = store.retentionCutoff
let read = 0
let outOfTime = false
for (const candidate of candidates) {
throwIfAiVaultScanCancelled(options.signal)
const path = candidate.file.path
const row = options.rows.get(path)
const decision = sessionSearchReadDecision({
candidate,
row,
// Only asked for a path the index holds something for; for the rest the
// decision is already made and this would be a query per new file.
cursor: row ? store.indexedFile(path, fileIdentity(candidate.file)) : null,
cutoffMs
})
if (decision === 'skip') {
continue
}
// The decide step is one cursor lookup, so it runs for the whole list even
// once the deadline has gone: knowing what is owed costs nothing, and the
// count of what a pass left is worth more than the microseconds.
outOfTime ||= read > 0 && options.overdue?.() === true
if (outOfTime) {
continue
}
// The clock the deadline reads is one the owner may close behind: the read
// below writes to the store, so stop here rather than on a shut handle.
throwIfAiVaultScanCancelled(options.signal)
read += 1
try {
await parseAgentSessionFileCached(candidate, process.platform, stats, decision)
} catch (error) {
throwIfAiVaultScanCancelled(options.signal)
// The reader reports a read it could not finish to the consumer, which is
// what records the failure on the row; nothing is counted here.
console.warn(
'[ai-vault-search] indexing skipped',
candidate.agent,
error instanceof Error ? error.name : 'ParseError'
)
}
}
return { stats, outOfTime }
}
@@ -112,13 +112,12 @@ it('refuses to commit a write whose file was removed mid-read', () => {
it('declines a behind cursor in beginRead before it ever reaches the store', () => {
const attempted: number[] = []
const stub = {
acceptsCandidate: () => true,
indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }),
beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => {
attempted.push(previousByteOffset)
return { add: () => undefined, commit: () => true }
},
markStale: () => undefined
setFileState: () => undefined
} as unknown as SessionSearchStore
const consumer = new SessionSearchIndexConsumer(stub)
@@ -144,7 +143,6 @@ it('declines a behind cursor in beginRead before it ever reaches the store', ()
it("hands the read's identity accessor to the store", () => {
const captured: unknown[] = []
const stub = {
acceptsCandidate: () => true,
indexedFile: () => null,
beginWrite: (
_candidate: unknown,
@@ -155,7 +153,7 @@ it("hands the read's identity accessor to the store", () => {
captured.push(identity)
return { add: () => undefined, commit: () => true }
},
markStale: () => undefined
setFileState: () => undefined
} as unknown as SessionSearchStore
const identity = (): null => null
@@ -0,0 +1,49 @@
import type { SessionSearchClock } from './session-search-clock'
import type { SessionSearchScanRoots } from './session-search-scan-roots'
/** Default cycle. Long enough that a machine with thousands of transcripts is
* not re-statting continuously, short enough that a live conversation shows up
* while the user is still in it. */
export const DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS = 20_000
/** Newest-N per agent root: the same recency rule the session sidebar applies. */
export const DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT = 12
/**
* A quarter of the interval: the only bound on how long one pass reads for.
*
* The timer re-arms after a pass settles, so a pass that spends its whole
* deadline is followed by a full interval of quiet — five seconds of reading in
* every twenty-five, a fifth of the wall clock, and the stated ceiling is a
* quarter. Files the deadline cut off go back on the queue at full speed rather
* than being read slowly, which is what a load-average back-off did instead.
*/
export const DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION = 4
/**
* Cycles between whole-machine sweeps: five minutes at the default interval.
*
* A sweep is the only pass that sees a file nothing has told the indexer about
* — an old transcript deleted, a root that came back, a tree restored from a
* backup — so the cadence is what replaces every re-arm-on-recovery rule. A
* warm sweep is stats and readdirs, not reads, because the pass skips anything
* the index already covers at its current stat.
*/
export const DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES = 15
/**
* Everything an indexer is. Immutable after construction: a settings change is
* `close()` and a new instance, which is also how the index is thrown away
* (`close()`, `removeSessionSearchDatabase(databasePath)`, construct again).
*/
export type SessionSearchIndexerOptions = {
databasePath: string
roots: SessionSearchScanRoots
/** null = all history; otherwise only transcripts modified within this many days. */
historyDays: number | null
clock?: SessionSearchClock
reconcileIntervalMs?: number
recentPerAgent?: number
/** Wall time one pass may read for; the rest goes back on the queue. */
passDeadlineMs?: number
/** Cycles between whole-machine sweeps. */
fullSweepEveryCycles?: number
onError?: (error: unknown) => void
}
@@ -0,0 +1,168 @@
import { mkdir, mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
import { isolatedScanRoots } from '../ai-vault/session-scanner-test-fixtures'
import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock'
import type { SessionSearchScanRoots } from './session-search-scan-roots'
import { assistantRecord, userRecord } from './session-search-transcript-fixtures'
const CLOCK_EPOCH_MS = 1_740_000_000_000
/** Wall time the indexer's guarantee is stated in, under the test's control. */
export class FakeSessionSearchClock implements SessionSearchClock {
private time = CLOCK_EPOCH_MS
private nextId = 1
private nowCalls = 0
private readonly timers = new Map<number, { at: number; callback: () => void }>()
/**
* What each `now()` reading costs. A pass reads the clock once per file it is
* about to read, so this is how a test spends a pass's deadline without
* waiting: it is the wall time the reads themselves take.
*/
costPerNowMs = 0
/**
* Runs on every `now()`, with the call number. The only synchronous seam into
* a running pass: the deadline check is what a pass consults between files.
*/
onNow: ((call: number) => void) | null = null
now(): number {
const at = this.time
this.time += this.costPerNowMs
this.onNow?.(++this.nowCalls)
return at
}
setTimeout(callback: () => void, ms: number): SessionSearchTimerHandle {
const id = this.nextId++
this.timers.set(id, { at: this.time + ms, callback })
return id
}
clearTimeout(handle: SessionSearchTimerHandle): void {
this.timers.delete(handle as number)
}
/** Moves time forward and fires every timer that came due, in order. */
advance(ms: number): void {
this.time += ms
for (const [id, timer] of [...this.timers].sort((left, right) => left[1].at - right[1].at)) {
if (timer.at <= this.time) {
this.timers.delete(id)
timer.callback()
}
}
}
get pendingTimers(): number {
return this.timers.size
}
}
export type SessionSearchIndexerHarness = {
root: string
databasePath: string
roots: SessionSearchScanRoots
claudeProjectDir: string
/** A second connection: the store keeps its own private. */
read: <T>(query: (db: SyncDatabase) => T) => T
/** Plants what a killed writer would have left; nothing in the app writes here. */
write: <T>(query: (db: SyncDatabase) => T) => T
cleanup: () => Promise<void>
}
export async function openSessionSearchIndexerHarness(
name: string
): Promise<SessionSearchIndexerHarness> {
const root = await mkdtemp(join(tmpdir(), `${name}-`))
const roots = isolatedScanRoots(root)
const databasePath = join(root, 'index', 'index.sqlite')
return {
root,
databasePath,
roots,
claudeProjectDir: join(roots.claudeProjectsDir, 'project'),
read: (query) => withConnection(databasePath, true, query),
write: (query) => withConnection(databasePath, false, query),
cleanup: () => rm(root, { recursive: true, force: true })
}
}
function withConnection<T>(
path: string,
readonlyConnection: boolean,
query: (db: SyncDatabase) => T
): T {
const db = new SyncDatabase(path, { readonly: readonlyConnection })
try {
return query(db)
} finally {
db.close()
}
}
/** A native-chat-shaped Claude transcript: the same records the app itself writes. */
export async function writeClaudeTranscript(
path: string,
turns: readonly string[],
sessionId: string
): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${claudeLines(turns, sessionId, 0).join('\n')}\n`)
}
export function claudeLines(
turns: readonly string[],
sessionId: string,
startIndex: number
): string[] {
return turns.flatMap((turn, offset) => [
userRecord(startIndex + offset * 2, turn, sessionId),
assistantRecord(startIndex + offset * 2 + 1, `noted: ${turn}`, sessionId)
])
}
/**
* Replaces a transcript the way an editor or a sync client does: a new inode
* renamed over the old name. Same byte length on purpose, so the only thing
* that can tell the two files apart is their filesystem identity.
*/
export async function renameReplaceTranscript(
path: string,
turns: readonly string[],
sessionId: string
): Promise<void> {
const before = await stat(path)
const replacement = `${path}.replacement`
await writeClaudeTranscript(replacement, turns, sessionId)
await rename(replacement, path)
const later = new Date(before.mtimeMs + 5_000)
await utimes(path, later, later)
}
/**
* A message-graph transcript, the shape OpenClaw, Pi, OMP and Prime Agent
* write. The session id comes from the file name, so callers name the file.
*/
export async function writeMessageGraphTranscript(
path: string,
turns: readonly string[]
): Promise<void> {
await mkdir(dirname(path), { recursive: true })
const lines = turns.flatMap((turn, index) => [
JSON.stringify({
type: 'message',
timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000).toISOString(),
message: { role: 'user', content: turn }
}),
JSON.stringify({
type: 'message',
timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000 + 60_000).toISOString(),
message: { role: 'assistant', content: `noted: ${turn}` }
})
])
await writeFile(path, `${lines.join('\n')}\n`)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
import { systemSessionSearchClock, type SessionSearchClock } from './session-search-clock'
import {
DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES,
DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION,
DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT,
DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS,
type SessionSearchIndexerOptions
} from './session-search-indexer-options'
import { SessionSearchDirectoryListings } from './session-search-directory-listings'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { runSessionSearchPass } from './session-search-pass'
import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy'
import { SessionSearchStore, type SessionSearchStateCounts } from './session-search-store'
import type { SessionSearchDegradedRoot } from './session-search-degraded-roots'
import { SessionSearchWorkLoop } from './session-search-work-loop'
/**
* Database paths a live indexer already owns.
*
* One process, one writer, one consumer registration per index. Two indexers on
* one path both register with the reader, so every transcript is read and
* written twice and the second write is fenced by the first at random. The
* recipe for every configuration change is close-then-construct, so the
* ordering that causes this is the one the recipe already rules out; this is
* what says so rather than letting it corrupt quietly.
*/
const liveIndexerPaths = new Set<string>()
export type SessionSearchIndexPhase = 'idle' | 'indexing' | 'current' | 'degraded' | 'closed'
export type SessionSearchIndexStatus = {
phase: SessionSearchIndexPhase
/** Rows whose content matches the file at the stat the row records. */
filesIndexed: number
/** Rows owed a whole read: a declined append, or a window that widened. */
filesDue: number
/** Rows whose last read did not commit. */
filesFailed: number
degradedRoots: SessionSearchDegradedRoot[]
lastReconcileAt: number | null
/** When a whole-machine sweep last finished; null until one has. */
lastSweepCompletedAt: number | null
}
/**
* Owns freshness for the index store: a whole-machine sweep, then a timer that
* keeps the newest N transcripts per agent reconciled and sweeps again every
* `fullSweepEveryCycles`.
*
* A library, not a service. It knows nothing about Electron, the app lifecycle,
* settings storage, IPC or the panel, and nothing here reads a setting or
* registers itself anywhere. Whoever constructs it decides all of that.
*
* **The store is the only memory.** Every question a pass asks between passes —
* what is owed a read, what has failed and how often, what the index holds and
* therefore what may have been deleted, what to report — is answered by a row
* in the `files` table. There is no queue, no watch set, no hold-out map and no
* counter with a reset rule.
*
* What is left here, and why none of it can be a row:
* - `previousRootsWithFiles`, the one bit per root the retirement walk's grace
* needs. Deliberately not durable: see the mountpoint trade in
* `session-search-deleted-sources.ts`.
* - `cyclesSinceSweep` and `sweepNext`, which are about the timer rather than
* about any file, and mean nothing to a second process.
* - `degradedRoots`, `lastReconcileAt` and `lastSweepCompletedAt`: what the last
* pass observed, held so `status()` can answer between passes.
* - `lastCounts`, the one cached query result, read only after `close()` so that
* describing what happened does not reopen a handle the owner has finished
* with. While the indexer is open every call re-queries.
*
* **Immutable after construction.** There is no `pause`, `resume`, `clear` or
* `setHistoryDays`. A configuration change is `close()` and a new instance;
* throwing the index away is
* `close(); removeSessionSearchDatabase(databasePath);` and a new instance.
* Widening retention is a new instance whose opening sweep admits the older
* files; narrowing is the purge that opens every full sweep.
*
* The guarantee it makes: while started, a transcript among the newest N per
* agent that grows, is replaced or is deleted is reflected in the index within
* one reconcile interval. Everything else is reached by the periodic sweep.
*/
export class SessionSearchIndexer {
private readonly ownershipPath: string
private readonly clock: SessionSearchClock
private readonly intervalMs: number
private readonly passDeadlineMs: number
private readonly recentPerAgent: number
private readonly fullSweepEveryCycles: number
private readonly onError: (error: unknown) => void
private readonly loop: SessionSearchWorkLoop
private readonly store: SessionSearchStore
private readonly unregister: () => void
/** Null until a pass has recorded one; an empty set is a real observation. */
private previousRootsWithFiles: ReadonlySet<string> | null = null
private degradedRoots: SessionSearchDegradedRoot[] = []
private lastReconcileAt: number | null = null
private lastSweepCompletedAt: number | null = null
private lastCounts: SessionSearchStateCounts | null = null
private cyclesSinceSweep = 0
private sweepNext = false
private started = false
private closed = false
constructor(private readonly options: SessionSearchIndexerOptions) {
this.ownershipPath = resolve(options.databasePath)
this.clock = options.clock ?? systemSessionSearchClock
this.intervalMs = options.reconcileIntervalMs ?? DEFAULT_SESSION_SEARCH_RECONCILE_INTERVAL_MS
this.passDeadlineMs =
options.passDeadlineMs ??
Math.max(1, Math.floor(this.intervalMs / DEFAULT_SESSION_SEARCH_PASS_DEADLINE_FRACTION))
this.recentPerAgent = options.recentPerAgent ?? DEFAULT_SESSION_SEARCH_RECENT_PER_AGENT
this.fullSweepEveryCycles = Math.max(
1,
options.fullSweepEveryCycles ?? DEFAULT_SESSION_SEARCH_FULL_SWEEP_EVERY_CYCLES
)
const onError = options.onError ?? ((error) => console.warn('[ai-vault-search]', error))
this.onError = onError
this.loop = new SessionSearchWorkLoop({
clock: this.clock,
intervalMs: this.intervalMs,
onFailure: onError
})
if (liveIndexerPaths.has(this.ownershipPath)) {
throw new Error(
`SessionSearchIndexer: ${options.databasePath} already has a live indexer; close it first`
)
}
// Store, registration and indexer share one lifetime, which is what makes
// the object immutable: there is no second open to get out of step with.
// Claimed only once the store is open, because a construction that throws
// has no `close()` to release the claim: registering first would leave the
// path owned by an object that does not exist, and every later attempt at
// it -- including the one that fixes whatever broke the open -- would be
// refused for the life of the process.
this.store = new SessionSearchStore(options.databasePath, onError)
liveIndexerPaths.add(this.ownershipPath)
this.store.setRetentionCutoffMs(this.cutoffMs())
this.unregister = registerSessionSearchIndexConsumer(this.store)
}
/** Runs a full sweep, then reconciles on the interval until closed. */
start(): Promise<void> {
if (this.closed || this.started) {
return this.loop.settled
}
this.started = true
this.sweepNext = true
return this.tick()
}
/**
* Runs one pass now, off the timer. A full pass sweeps every root.
*
* Refused before `start()` and after `close()`: a pass against an indexer
* nobody started writes the index once and leaves it to go stale with no
* timer armed to notice the next change, and a pass against a closed one has
* no store to write to. Both are caller bugs, so both throw rather than
* resolving as though a pass had run.
*/
reconcile(options: { full?: boolean } = {}): Promise<void> {
if (this.closed) {
throw new Error('SessionSearchIndexer.reconcile: the indexer is closed')
}
if (!this.started) {
throw new Error('SessionSearchIndexer.reconcile: start() first')
}
this.sweepNext ||= options.full === true
return this.tick()
}
/**
* What the index holds, read from the rows rather than tallied.
*
* A second connection can compute every number here with one `GROUP BY`,
* which is the point: nothing is counted as it happens, so nothing can drift
* from what the database actually holds or need a rule about when to reset.
*/
status(): SessionSearchIndexStatus {
// A closed indexer reports what it last knew: opening a shut handle to
// answer a call whose whole job is to describe what happened is how a close
// came to report a database error to the owner who asked for it.
const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? {
current: 0,
due: 0,
failed: 0
}
return {
phase: this.phase(settled),
filesIndexed: settled.current,
filesDue: settled.due,
filesFailed: settled.failed,
degradedRoots: this.degradedRoots.map((root) => ({ ...root })),
lastReconcileAt: this.lastReconcileAt,
lastSweepCompletedAt: this.lastSweepCompletedAt
}
}
/** Stops everything. Nothing queued before this call may run afterwards. */
close(): void {
if (this.closed) {
return
}
// Read before the handle goes, so a status call afterwards reports what the
// index last held rather than opening a database its owner has finished with.
this.lastCounts = this.readCounts() ?? this.lastCounts
this.closed = true
// The loop, not just its timer: a task queued before this call would
// otherwise still run against a store this line is about to close.
this.loop.close()
this.unregister()
this.store.close()
liveIndexerPaths.delete(this.ownershipPath)
}
/** Tests only: everything else drives this through the timer. */
settled(): Promise<void> {
return this.loop.settled
}
private readCounts(): SessionSearchStateCounts | null {
try {
const counts = this.store.stateCounts()
this.lastCounts = counts
return counts
} catch (error) {
this.onError(error)
return this.lastCounts
}
}
/**
* `current` is a claim, so it takes all three: no row owed a read, no row
* whose last read failed, and a whole sweep that finished. `idle` is the
* other end of it — an indexer nobody started has not promised to index
* anything, and calling that `current` would claim an index nobody built is
* up to date.
*/
private phase(counts: SessionSearchStateCounts): SessionSearchIndexPhase {
if (this.closed) {
return 'closed'
}
if (!this.started) {
return 'idle'
}
// A root the pass could not read, or a file it could not read: both are gaps
// the index knows about and cannot close on its own.
if (this.degradedRoots.length > 0 || counts.failed > 0) {
return 'degraded'
}
return counts.due === 0 && this.lastSweepCompletedAt !== null ? 'current' : 'indexing'
}
private tick(): Promise<void> {
return this.loop.queue(
(signal) => this.pass(signal),
() => void this.tick()
)
}
private async pass(signal: AbortSignal): Promise<void> {
// The window moves with the clock, and the decide step reads it from the
// store. Setting it once at construction leaves a sweep purging rows that
// the very next candidate check happily re-indexes.
this.store.setRetentionCutoffMs(this.cutoffMs())
// The one bound on a pass: wall time. What it does not reach is still owed,
// because a row says so and nothing had to be written down.
const startedAt = this.clock.now()
const full = this.sweepNext
// Taken on entry, not cleared on the way out: a `reconcile({ full: true })`
// raised while this pass is running sets it again, and clearing it at the
// end would erase that request along with this pass's own.
this.sweepNext = false
try {
const result = await runSessionSearchPass({
store: this.store,
roots: this.options.roots,
full,
recentPerAgent: this.recentPerAgent,
previousRootsWithFiles: this.previousRootsWithFiles ?? undefined,
overdue: () => this.clock.now() - startedAt >= this.passDeadlineMs,
// One readdir per directory for the whole pass, shared by every step.
listings: new SessionSearchDirectoryListings(),
signal
})
if (!result.completed) {
// A pass cut short learned nothing about root health, and publishing its
// empty findings would clear a live alarm. A sweep stays owed.
this.sweepNext ||= full
return
}
this.degradedRoots = result.degradedRoots
this.previousRootsWithFiles = result.rootsWithFiles
this.lastReconcileAt = this.clock.now()
// A backlog outside the recency window is only visible to a sweep, so a
// pass that ran out of time asks for one. It is self-limiting: the first
// pass that finishes its reads hands the interval back to cycles.
this.sweepNext ||= result.outOfTime
if (full) {
this.lastSweepCompletedAt = this.lastReconcileAt
this.cyclesSinceSweep = 0
return
}
// A root that came back, a tree restored from a backup, an old transcript
// deleted: only a sweep sees any of it, and the count of cycles is the
// whole rule for when one is owed.
this.cyclesSinceSweep += 1
if (this.cyclesSinceSweep >= this.fullSweepEveryCycles) {
this.sweepNext = true
}
} catch (error) {
// The flag is this method's to hold, so it is this method's to give back:
// a pass that threw part way learned nothing, and losing it here would
// leave nothing armed to try again.
this.sweepNext ||= full
throw error
}
}
private cutoffMs(): number | null {
return sessionSearchHistoryCutoffMs(this.options.historyDays, this.clock.now())
}
}
import { resolve } from 'node:path'
@@ -0,0 +1,378 @@
import { chmod, mkdir, rename, rm } from 'node:fs/promises'
import { delimiter, dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchIndexer } from './session-search-indexer'
import type { SessionSearchIndexerOptions } from './session-search-indexer-options'
import { removeSessionSearchDatabase } from './session-search-schema'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
writeMessageGraphTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
/*
* The lifecycle matrix: every operation a caller can perform, against every
* shape an unreachable root takes, against both ways discovery reports a root.
*
* The indexer is immutable, so "every operation" is a shorter list than it was:
* `pause`, `resume`, `clear`, `setHistoryDays` and `invalidate` are gone, and
* the two of them a caller still needs — a settings change and throwing the
* index away — are here as what replaced them, a new instance over the same
* path. In their place are the two passes the immutable design added: the
* periodic sweep, and a pass whose wall-clock deadline expires on its first file.
*
* What each cell asserts:
* A. No row is retired for a file that still exists. Throwing the index away
* is the one exception, and it is stated per operation rather than excused.
* B. The unreachable root is named in `degradedRoots`, by a real directory
* path — never the delimiter-joined label a merged discovery reports.
* C. The phase is never `current` while a root is degraded.
* D. Once the root is reachable again, a sweep indexes everything under it.
*
* Round 6 ran this as a throwaway harness on the previous design; it lives in
* the repository now. Two of its shapes changed with the stateless walk. The
* "present but empty mountpoint" shape is gone, because a readable root that
* lists nothing is no longer treated as unreachable — that is a root the user
* emptied, and `session-search-deleted-sources.ts` states the trade. In its
* place is a root whose transcripts sit behind an unreadable subdirectory,
* which is the partial-tree case the old shape never covered.
*/
const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0
const INTERVAL_MS = 20_000
const SESSIONS = ['aaaaaaaa', 'bbbbbbbb', 'cccccccc']
type RootShape = {
name: string
/** Where the unreachable root's transcripts live, and where its files go. */
detachedRoot: (harness: SessionSearchIndexerHarness) => string
detachedFile: (harness: SessionSearchIndexerHarness, session: string) => string
writeDetached: (path: string, session: string) => Promise<void>
healthyFile: (harness: SessionSearchIndexerHarness, session: string) => string
writeHealthy: (path: string, session: string) => Promise<void>
}
const OPENCLAW_SESSION_DIR = join('agents', 'main', 'sessions')
const ROOT_SHAPES: RootShape[] = [
{
name: 'roots discovery reports one per directory',
detachedRoot: (harness) => harness.roots.claudeProjectsDir ?? '',
detachedFile: (harness, session) => join(harness.claudeProjectDir, `${session}.jsonl`),
writeDetached: (path, session) =>
writeClaudeTranscript(path, [`detached ${session}`], fullSessionId(session)),
healthyFile: (harness, session) => join(harness.roots.piSessionsDir ?? '', `${session}.jsonl`),
writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`])
},
{
name: 'roots a merged discovery joins into one label',
detachedRoot: (harness) => join(harness.roots.openclawStateDir ?? '', 'agents'),
detachedFile: (harness, session) =>
join(harness.roots.openclawStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`),
writeDetached: (path, session) => writeMessageGraphTranscript(path, [`detached ${session}`]),
healthyFile: (harness, session) =>
join(harness.roots.openclawLegacyStateDir ?? '', OPENCLAW_SESSION_DIR, `${session}.jsonl`),
writeHealthy: (path, session) => writeMessageGraphTranscript(path, [`healthy ${session}`])
}
]
type UnreachableShape = {
name: string
needsDeniedRead: boolean
/**
* Whether an empty index can see this at all. Reading the root itself is the
* one probe a pass makes with no rows to go on: a root that answers ENOENT is
* what an uninstalled agent answers too, and a readable root with an
* unreadable subdirectory is swallowed by the file walker, which returns
* rather than reporting. Both are invisible until the index holds a row under
* the root, which is the evidence the retirement walk runs on.
*/
visibleWithNoRows: boolean
detach: (root: string, transcriptDir: string, parked: string) => Promise<void>
attach: (root: string, transcriptDir: string, parked: string) => Promise<void>
}
const UNREACHABLE_SHAPES: UnreachableShape[] = [
{
name: 'the root itself is not there',
needsDeniedRead: false,
visibleWithNoRows: false,
detach: (root, _transcriptDir, parked) => rename(root, parked),
attach: (root, _transcriptDir, parked) => rename(parked, root)
},
{
name: 'the root refuses to list',
needsDeniedRead: true,
visibleWithNoRows: true,
detach: (root) => chmod(root, 0o000),
attach: (root) => chmod(root, 0o755)
},
{
name: 'the transcripts sit behind a directory that refuses to list',
needsDeniedRead: true,
visibleWithNoRows: false,
detach: (_root, transcriptDir) => chmod(transcriptDir, 0o000),
attach: (_root, transcriptDir) => chmod(transcriptDir, 0o755)
}
]
type Operation = {
name: string
/** True when the operation throws the index away, so no row survives it. */
clearsIndex?: boolean
/** Healthy-root sessions the operation deletes from disk. */
deletes?: readonly string[]
/** Construction options for every indexer this cell opens. */
options?: Partial<SessionSearchIndexerOptions>
run: (context: MatrixContext) => Promise<void>
}
const OPERATIONS: Operation[] = [
{ name: 'one cycle', run: (context) => context.cycle() },
{
name: 'two cycles',
run: async (context) => {
await context.cycle()
await context.cycle()
}
},
{
name: 'close and restart',
run: (context) => context.reopen()
},
{
name: 'two full reconciles',
run: async (context) => {
await context.indexer().reconcile({ full: true })
await context.indexer().reconcile({ full: true })
}
},
{
name: 'one healthy transcript deleted',
deletes: SESSIONS.slice(0, 1),
run: (context) => context.cycle()
},
{
name: 'every healthy transcript deleted',
deletes: SESSIONS,
run: async (context) => {
// Twice: a root that goes from holding transcripts to holding none in one
// pass is unverifiable for that pass, so the second is the proving one.
await context.indexer().reconcile({ full: true })
await context.indexer().reconcile({ full: true })
}
},
{
// The cadence that replaced every re-arm-on-recovery rule: no caller asks
// for this sweep, so the cell drives it off the timer alone.
name: 'the periodic sweep comes round',
options: { fullSweepEveryCycles: 2 },
run: async (context) => {
await context.cycle()
await context.cycle()
await context.cycle()
}
},
{
// Every pass is out of wall time from its first file, so each one hands
// almost all of its work back. A pass that read almost nothing must still
// not conclude anything about what it did not reach.
name: 'every pass out of time at its first file',
options: { passDeadlineMs: 0 },
run: async (context) => {
await context.cycle()
await context.cycle()
}
},
{
// What replaced `setHistoryDays`: a new instance over the same database.
// Every transcript here was written just now, so a 30-day window holds all
// of them and no row may be purged.
name: 'reconstructed for a narrower history window',
run: (context) => context.reopen({ historyDays: 30 })
},
{
// What replaced `clear()`, exactly as the PR body documents it.
name: 'the index thrown away and rebuilt',
clearsIndex: true,
run: (context) => context.reopen({ removeDatabase: true })
}
]
type MatrixContext = {
indexer: () => SessionSearchIndexer
/** Closes and constructs again over the same path: the immutable design's one edit. */
reopen: (args?: { historyDays?: number | null; removeDatabase?: boolean }) => Promise<void>
cycle: () => Promise<void>
detachedRoot: string
detachedPaths: string[]
}
function fullSessionId(prefix: string): string {
return `${prefix}-bbbb-4ccc-8ddd-eeeeeeeeeeee`
}
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer | null
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-lifecycle')
indexer = null
})
afterEach(async () => {
indexer?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
function open(overrides: Partial<SessionSearchIndexerOptions> = {}): SessionSearchIndexer {
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: harness.roots,
historyDays: null,
clock,
reconcileIntervalMs: INTERVAL_MS,
...overrides
})
return indexer
}
/**
* Runs cycles until the index stops growing. Every operation but the
* out-of-time one settles on the first call; that one reads a transcript a pass.
*/
async function driveUntilIndexed(maxCycles: number): Promise<void> {
let held = indexedSessions().length
for (let cycle = 0; cycle < maxCycles; cycle++) {
clock.advance(INTERVAL_MS)
await indexer?.settled()
const now = indexedSessions().length
if (now === held) {
return
}
held = now
}
}
/** Session ids the index answers for, whichever agent wrote them. */
function indexedSessions(): string[] {
return harness
.read(
(db: SyncDatabase) =>
db.prepare('SELECT session_id AS id FROM sessions').all() as { id: string }[]
)
.map((row) => row.id)
.sort()
}
for (const roots of ROOT_SHAPES) {
for (const unreachable of UNREACHABLE_SHAPES) {
describe.skipIf(unreachable.needsDeniedRead && !CAN_DENY_READ)(
`${roots.name}, ${unreachable.name}`,
() => {
for (const operation of OPERATIONS) {
it(operation.name, async () => {
const detachedRoot = roots.detachedRoot(harness)
const detachedPaths = SESSIONS.map((session) => roots.detachedFile(harness, session))
const healthyPaths = SESSIONS.map((session) => roots.healthyFile(harness, session))
for (const [index, session] of SESSIONS.entries()) {
await roots.writeDetached(detachedPaths[index] ?? '', session)
await roots.writeHealthy(healthyPaths[index] ?? '', session)
}
const transcriptDir = dirname(detachedPaths[0] ?? '')
const parked = join(harness.root, 'parked-root')
await open(operation.options).start()
// A deadline that expires on the first file reads one transcript a
// pass, so the setup drives passes until the index has caught up.
await driveUntilIndexed(SESSIONS.length * 2)
const detachedIds = detachedPaths.map((_path, index) =>
roots === ROOT_SHAPES[0]
? fullSessionId(SESSIONS[index] ?? '')
: (SESSIONS[index] ?? '')
)
const healthyIds = SESSIONS.map((session) => session)
expect(indexedSessions()).toEqual([...detachedIds, ...healthyIds].sort())
// One cycle so the watch set holds the recency window, which is the
// state a running indexer is in when a volume goes away.
clock.advance(INTERVAL_MS)
await indexer?.settled()
await unreachable.detach(detachedRoot, transcriptDir, parked)
try {
const kept = SESSIONS.filter((session) => !operation.deletes?.includes(session))
for (const session of operation.deletes ?? []) {
await rm(healthyPaths[SESSIONS.indexOf(session)] ?? '')
}
await operation.run({
indexer: () => indexer as SessionSearchIndexer,
reopen: async (args = {}) => {
indexer?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
if (args.removeDatabase === true) {
removeSessionSearchDatabase(harness.databasePath)
}
const overrides = { ...operation.options }
if ('historyDays' in args) {
overrides.historyDays = args.historyDays
}
await open(overrides).start()
await driveUntilIndexed(SESSIONS.length * 2)
},
cycle: async () => {
clock.advance(INTERVAL_MS)
await indexer?.settled()
},
detachedRoot,
detachedPaths
})
// A: nothing that still exists lost its rows.
const survivingDetached = operation.clearsIndex ? [] : detachedIds
expect(indexedSessions()).toEqual([...survivingDetached, ...kept].sort())
const status = indexer?.status()
const degraded = status?.degradedRoots.map((root) => root.root) ?? []
// With no rows under it, the only thing a pass can go on is
// whether the root itself refuses to list.
if (operation.clearsIndex && !unreachable.visibleWithNoRows) {
expect(degraded).not.toContain(detachedRoot)
} else {
// B: named, by a real directory rather than a joined label.
expect(degraded).toContain(detachedRoot)
expect(degraded.every((root) => !root.includes(delimiter))).toBe(true)
// C: not current while a root is degraded.
expect(status?.phase).not.toBe('current')
}
} finally {
await unreachable.attach(detachedRoot, transcriptDir, parked)
}
// D: reachable again, a sweep reads the whole tree back.
await mkdir(dirname(healthyPaths[0] ?? ''), { recursive: true })
await indexer?.reconcile({ full: true })
await driveUntilIndexed(SESSIONS.length * 2)
expect(indexedSessions()).toEqual(
[
...detachedIds,
...SESSIONS.filter((session) => !operation.deletes?.includes(session))
].sort()
)
})
}
}
)
}
}
@@ -193,16 +193,16 @@ it('indexes a file the session list already read past, once a whole read is aske
// The append continued from a byte offset the index never saw, so it declined.
expect(sessionsMatching('zygomorphic')).toEqual([])
const behind = store.takeStale()
expect(behind.map((candidate) => candidate.file.path)).toEqual([path])
for (const candidate of behind) {
requestWholeTranscriptRead(candidate.file.path)
}
// The index holds no row for this file at all, and that is the record: a
// path the file table does not name is read from the start by the next pass,
// which is what asks the reader to drop the session list's resume point.
expect(store.files()).toEqual([])
requestWholeTranscriptRead(path)
const reread = await parseTranscript(path)
expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 })
expect(errors).toEqual([])
expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID])
expect(sessionsMatching('opening')).toEqual([SESSION_ID])
expect(store.takeStale()).toEqual([])
expect(store.files().map((row) => row.state)).toEqual(['current'])
})
@@ -0,0 +1,135 @@
import { chmod, rm } from 'node:fs/promises'
import { delimiter, join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchIndexer } from './session-search-indexer'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeMessageGraphTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
// OpenClaw is the one agent whose roots are alternates for a single install, so
// discovery reports them as ONE discovery whose rootDir is every path joined by
// the platform's path delimiter. That string is not a directory: readdir on it
// answers ENOENT, containment never matches a real file, and a scan issue
// recorded against a real root never compares equal to it. Everything that
// judges a root works on the constituent directories, taken from the same
// source table discovery reads, never by splitting the label -- a directory may
// legally contain the delimiter.
const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0
const INTERVAL_MS = 20_000
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-merged-roots')
})
afterEach(async () => {
indexer.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
/** OpenClaw reads `<stateDir>/agents/**` and keeps only paths through `sessions`. */
function openclawTranscript(stateDir: string, name: string): string {
return join(stateDir, 'agents', 'main', 'sessions', `${name}.jsonl`)
}
function sessionsMatching(term: string): string[] {
return harness.read((db: SyncDatabase) =>
(
db
.prepare(
`SELECT DISTINCT s.session_id AS id FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? ORDER BY s.session_id`
)
.all(term) as { id: string }[]
).map((row) => row.id)
)
}
it.skipIf(!CAN_DENY_READ)('fences one merged root without taking its partner down', async () => {
const current = harness.roots.openclawStateDir ?? ''
const legacy = harness.roots.openclawLegacyStateDir ?? ''
const mounted = openclawTranscript(current, 'mounted-session')
const local = openclawTranscript(legacy, 'local-session')
await writeMessageGraphTranscript(mounted, ['a conversation on the mounted volume'])
await writeMessageGraphTranscript(local, ['a conversation on local disk'])
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: harness.roots,
historyDays: null,
clock,
reconcileIntervalMs: INTERVAL_MS
})
await indexer.start()
expect(sessionsMatching('conversation').sort()).toEqual(['local-session', 'mounted-session'])
// One of the two roots goes away; the other is untouched.
await chmod(join(current, 'agents'), 0o000)
try {
await indexer.reconcile({ full: true })
const status = indexer.status()
const degraded = status.degradedRoots.map((root) => root.root)
// A real directory, not the joined string discovery reports.
expect(degraded).toContain(join(current, 'agents'))
expect(degraded.every((root) => !root.includes(delimiter))).toBe(true)
// Unprovable, so the unreadable root keeps its rows.
expect(sessionsMatching('mounted')).toEqual(['mounted-session'])
} finally {
await chmod(join(current, 'agents'), 0o755)
}
})
it('retires from one merged root while its partner is healthy', async () => {
const current = harness.roots.openclawStateDir ?? ''
const legacy = harness.roots.openclawLegacyStateDir ?? ''
const going = openclawTranscript(current, 'going-session')
await writeMessageGraphTranscript(going, ['a conversation about to be deleted'])
// A sibling in the same root, so deleting one leaves the root listing files
// and therefore healthy: this is a deletion, not an unmount.
await writeMessageGraphTranscript(openclawTranscript(current, 'sibling-session'), [
'a conversation beside it'
])
await writeMessageGraphTranscript(openclawTranscript(legacy, 'staying-session'), [
'a conversation that stays'
])
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: harness.roots,
historyDays: null,
clock,
reconcileIntervalMs: INTERVAL_MS
})
await indexer.start()
expect(sessionsMatching('conversation').sort()).toEqual([
'going-session',
'sibling-session',
'staying-session'
])
// A genuine deletion inside a healthy root still retires normally.
await rm(going)
await indexer.reconcile({ full: true })
expect(sessionsMatching('deleted')).toEqual([])
expect(indexer.status().degradedRoots).toEqual([])
expect(sessionsMatching('conversation').sort()).toEqual(['sibling-session', 'staying-session'])
})
@@ -0,0 +1,113 @@
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchIndexer } from './session-search-indexer'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
// Reviewer F4, and the plan's fourth open decision: a conversation held in
// Orca's own chat is the same file in the same place as one held in the
// terminal, so it must be searchable through the same path with no panel
// mounted, no scanner service running, and nobody calling refresh. Everything
// below is the library and the filesystem.
const INTERVAL_MS = 20_000
const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000'
const CWD = '/repo/orca'
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-native-chat')
})
afterEach(async () => {
indexer.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
/** The rows Orca's native chat writes: uuid, block content, cwd on the first turn. */
function nativeChatTurn(uuid: string, role: 'user' | 'assistant', text: string): string {
const timestamp = new Date(1_740_000_000_000 + Number(uuid.slice(-2)) * 60_000).toISOString()
return JSON.stringify({
type: role,
uuid,
sessionId: SESSION_ID,
timestamp,
cwd: CWD,
gitBranch: 'main',
message: {
role,
...(role === 'assistant' ? { model: 'claude-fable-5' } : {}),
content: [{ type: 'text', text }]
}
})
}
function messageTexts(term: string): { role: string; session: string }[] {
return harness.read(
(db: SyncDatabase) =>
db
.prepare(
`SELECT m.role AS role, s.session_id AS session FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? ORDER BY m.id`
)
.all(term) as { role: string; session: string }[]
)
}
it('indexes a native-chat conversation and its later turns with no panel and no service', async () => {
const path = join(harness.claudeProjectDir, `${SESSION_ID}.jsonl`)
await mkdir(harness.claudeProjectDir, { recursive: true })
await writeFile(
path,
`${[
nativeChatTurn('turn-01', 'user', 'why does the relay drop the lease at 105 seconds'),
nativeChatTurn('turn-02', 'assistant', 'that is the client silence watchdog, not a cliff')
].join('\n')}\n`
)
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: harness.roots,
historyDays: null,
clock,
reconcileIntervalMs: INTERVAL_MS
})
await indexer.start()
expect(messageTexts('watchdog')).toEqual([{ role: 'assistant', session: SESSION_ID }])
expect(harness.read((db: SyncDatabase) => db.prepare('SELECT cwd FROM sessions').get())).toEqual({
cwd: CWD
})
// The conversation continues in the panel; nothing tells the index about it.
await appendFile(
path,
`${[
nativeChatTurn('turn-03', 'user', 'and the fleetwide 4408 bursts'),
nativeChatTurn('turn-04', 'assistant', 'those are desktop lease rotations, cohort waves')
].join('\n')}\n`
)
clock.advance(INTERVAL_MS)
await indexer.settled()
expect(messageTexts('cohort')).toEqual([{ role: 'assistant', session: SESSION_ID }])
expect(messageTexts('4408')).toEqual([{ role: 'user', session: SESSION_ID }])
expect(indexer.status().phase).toBe('current')
})
@@ -0,0 +1,177 @@
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
// Only the thread hop is replaced: both implementations below are the repo's
// own in-process readers, which the worker entry calls on the other side.
export const openCodeParseCalls: string[] = []
vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => {
const list = await import('../ai-vault/session-scanner-opencode-sqlite-list')
const parse = await import('../ai-vault/session-scanner-opencode-sqlite')
const own = await import('./session-search-opencode-decline.test')
return {
resolveOpenCodeSqliteWorkerEntryPath: () => null,
listOpenCodeSqliteSessionsViaWorker: (
args: Parameters<typeof list.listOpenCodeSqliteSessions>[0]
) => list.listOpenCodeSqliteSessions(args),
parseOpenCodeSqliteSessionViaWorker: (
args: Parameters<typeof parse.parseOpenCodeSqliteSession>[0]
) => {
own.openCodeParseCalls.push(args.sessionId)
return parse.parseOpenCodeSqliteSession(args)
}
}
})
import Database from '../sqlite/sync-database'
import { getSessionParseCacheEntry } from '../ai-vault/session-parse-cache-store'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { buildOpenCodeSqliteCandidatePath } from '../ai-vault/session-scanner-opencode-sqlite-paths'
import { SessionSearchIndexer } from './session-search-indexer'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
/*
* Round 12, F3. An OpenCode SQLite session decodes where the message channel
* cannot reach it, so no read of one will ever commit a row. The consumer
* declined it and wrote nothing, which left the file table silent about a
* source discovery returns on every pass: the decide step saw a path the index
* held nothing for, asked for a read, and asking for one over a warm cache
* drops the session list's own resume point. Every OpenCode session was fully
* decoded on every pass and the sidebar's fold was thrown away with it, which
* is the cache STA-1278 and STA-1417 added.
*/
const SESSION = 'ses_r12'
const CLAUDE_SESSION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer | null = null
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-opencode-decline')
indexer = null
openCodeParseCalls.length = 0
})
afterEach(async () => {
indexer?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
function writeOpenCodeDb(path: string, sessionId: string): void {
const db = new Database(path)
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, slug TEXT NOT NULL,
directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, share_url TEXT,
summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, revert TEXT, permission TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER,
time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT,
cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL,
tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
metadata TEXT
);
CREATE TABLE message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
CREATE TABLE project (
id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT,
icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT
);
CREATE TABLE part (
id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL
);
`)
db.prepare(
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
time_created, time_updated, agent, model, cost, tokens_input, tokens_output,
tokens_reasoning, tokens_cache_read, tokens_cache_write)
VALUES (?, 'proj-1', NULL, 'slug-1', '/tmp/opencode', 'OpenCode title', '1.0.0',
?, ?, 'build', '{"id":"glm"}', 0, 1, 1, 0, 0, 0)`
).run(sessionId, 1_740_000_000_000, 1_740_000_100_000)
db.prepare(
`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`
).run(
'msg-1',
sessionId,
1_740_000_000_000,
1_740_000_000_000,
JSON.stringify({ role: 'user', time: { created: 1_740_000_000_000 } })
)
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`
).run(
'part-1',
'msg-1',
sessionId,
1_740_000_000_000,
1_740_000_000_000,
JSON.stringify({ type: 'text', text: 'hello opencode' })
)
db.prepare(
`INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
VALUES ('proj-1', '/tmp/opencode', 'proj', ?, ?, '[]')`
).run(1_740_000_000_000, 1_740_000_000_000)
db.close()
}
it('reads an OpenCode session once, not on every pass', async () => {
const dbPath = join(harness.root, 'opencode-db', 'opencode.db')
mkdirSync(join(harness.root, 'opencode-db'), { recursive: true })
writeOpenCodeDb(dbPath, SESSION)
const claudePath = join(harness.claudeProjectDir, 'control.jsonl')
await writeClaudeTranscript(claudePath, ['control turn'], CLAUDE_SESSION)
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: { ...harness.roots, opencodeDbPaths: [dbPath] },
historyDays: null,
clock,
reconcileIntervalMs: 20_000,
onError: () => undefined
})
await indexer.start()
const syntheticPath = buildOpenCodeSqliteCandidatePath(dbPath, SESSION)
const openCodeAfterFirst = getSessionParseCacheEntry(syntheticPath)
const claudeAfterFirst = getSessionParseCacheEntry(claudePath)
await indexer.reconcile()
await indexer.reconcile()
// One decode across three passes, and the session list's cached fold for it
// is the same object it was after the first: nothing invalidated it.
expect(openCodeParseCalls).toHaveLength(1)
expect(getSessionParseCacheEntry(syntheticPath)).toBe(openCodeAfterFirst)
// The control, which the index really does hold, is untouched either way.
expect(getSessionParseCacheEntry(claudePath)).toBe(claudeAfterFirst)
// What makes it skippable: a row saying the index has seen this source and
// holds no session for it, which is the shape a read-through-with-no-session
// already leaves.
const rows = harness.read((db) =>
db.prepare('SELECT path, state, session_row_id FROM files ORDER BY path').all()
) as { path: string; state: string; session_row_id: number | null }[]
expect(rows).toHaveLength(2)
expect(rows.find((row) => row.path === syntheticPath)).toMatchObject({
state: 'current',
session_row_id: null
})
expect(indexer.status()).toMatchObject({ filesDue: 0, filesFailed: 0, phase: 'current' })
})
@@ -1,186 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type SyncDatabase from '../sqlite/sync-database'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
import { identifierShadowText } from './session-search-identifier-split'
import { readIndexGeneration } from './session-search-index-generation'
import { planSessionSearchQuery } from './session-search-query-planner'
import { sessionSearchSnippet } from './session-search-snippet'
import type { SessionSearchCursorError } from './session-search-page-cursor'
import { SessionSearchTypoRepair } from './session-search-typo-repair'
// Retention deletes a session row in one small transaction and reclaims its
// message rows in batches afterwards, so a `messages` row with no `sessions` row
// is a state every purge, every removed source and every interrupted drain
// passes through. Those rows are still in both FTS tables and still in the
// vocabulary, and nothing here may return one.
//
// A hit is a session row, and the ranked list is loaded `FROM sessions`, so the
// route ladder below cannot surface an orphan even if a join were loosened —
// those cases are a ratchet over the shape, not the proof. The two reads that
// can leak one are pinned separately and each is a real oracle: the snippet,
// which is handed a rowid and asked for its text, and the typo repair, whose
// dictionary is the FTS b-tree and lists an orphan's terms like any other.
const ORPHAN_SESSION_ROW = 99
const ORPHAN_TEXT = 'orphaned marmoset secret'
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
/** Two rows in the FTS table and the vocabulary, and no session row for them. */
function plantOrphans(db: SyncDatabase, text: string = ORPHAN_TEXT): number[] {
const rowids: number[] = []
for (let n = 0; n < 2; n++) {
const rowid = Number(
db
.prepare("INSERT INTO messages(session_row_id,role,ts) VALUES (?,'user',?)")
.run(ORPHAN_SESSION_ROW, '2026-09-10T00:00:00.000Z').lastInsertRowid
)
db.prepare(
'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)'
).run(rowid, text, '', '', identifierShadowText(text))
rowids.push(rowid)
}
return rowids
}
async function withOrphans(): Promise<{ harness: SessionSearchHarness; rowids: number[] }> {
harness = await openSessionSearchHarness('ss-orphan-rows')
addSyntheticSession(harness.db, { id: 1, text: 'the haystack line here' })
const rowids = plantOrphans(harness.db)
// The oracle only means anything if the rows are really there to be found.
expect(
harness.db
.prepare("SELECT count(*) AS c FROM messages_fts WHERE messages_fts MATCH 'marmoset'")
.get()
).toEqual({ c: 2 })
expect(
harness.db.prepare("SELECT doc FROM messages_vocab WHERE term = 'marmoset'").get()
).toEqual({ doc: 2 })
return { harness, rowids }
}
it.each([
['phrase', '"orphaned marmoset"'],
['and', 'orphaned secret'],
['single-token literal', 'marmoset'],
['or', 'marmoset haystack orphaned'],
['typo repair', 'marmosett'],
['operator only', 'repo:app']
])('returns no orphaned row on the %s route', async (_route, query) => {
const { harness: open } = await withOrphans()
for (const scope of ['all', 'conversation'] as const) {
const hits = open.engine.search({ query, scope }).hits
expect(hits.map((hit) => hit.sessionId)).not.toContain(String(ORPHAN_SESSION_ROW))
expect(hits.filter((hit) => hit.evidence?.snippet.includes('marmoset'))).toEqual([])
}
})
it('never repairs a term onto a spelling only orphaned rows carry', async () => {
const { harness: open } = await withOrphans()
// `marmoset` is in the vocabulary twice, which is what would make it the
// repair for `marmosett` if the repair trusted the vocabulary alone.
expect(new SessionSearchTypoRepair(open.db).correct('marmosett', 'all')).toBeNull()
expect(open.engine.search({ query: 'marmosett' }).planner.repairedTerms).toBeUndefined()
})
it('snippets nothing for an orphaned row, even asked for it by rowid', async () => {
const { harness: open, rowids } = await withOrphans()
const plan = planSessionSearchQuery('marmoset')
for (const scope of ['all', 'conversation'] as const) {
expect(sessionSearchSnippet(open.db, scope, rowids[0]!, plan)).toEqual({
text: '',
truncated: false
})
}
})
it('still answers for the live session beside them', async () => {
const { harness: open } = await withOrphans()
expect(open.engine.search({ query: 'haystack' }).hits.map((hit) => hit.sessionId)).toEqual(['1'])
})
// Reclaiming those rows is the other half. The drain deletes only from
// `messages`, so for a long time it was argued to change no answer and left
// outside the generation fence. Retrieval never saw them, but the typo repair's
// dictionary is `messages_vocab`, a view over the FTS b-tree that lists a term
// whether or not a reader can reach the rows carrying it — so the drain moved
// which word a query was repaired to, under a cursor that was still honoured.
describe('a purge reclaiming rows nothing can reach', () => {
/** A live session and a purged one that both carry `text`. */
async function withReclaimable(): Promise<SessionSearchHarness> {
harness = await openSessionSearchHarness('ss-orphan-drain')
// Two live rows, which is what makes `marmoset` eligible as a repair at all.
addSyntheticSession(harness.db, { id: 1, text: 'the marmoset lives here', rows: 2 })
plantOrphans(harness.db)
return harness
}
it('answers the same before and after, because the repair counts live rows', async () => {
const open = await withReclaimable()
const before = open.engine.search({ query: 'marmosett' })
expect(before.planner.repairedTerms).toEqual(['marmoset'])
expect(before.hits.map((hit) => hit.sessionId)).toEqual(['1'])
await open.store.purgeOlderThan(null)
expect(open.db.prepare('SELECT count(*) AS c FROM messages').get()).toEqual({ c: 2 })
const after = open.engine.search({ query: 'marmosett' })
expect(after.planner.repairedTerms).toEqual(before.planner.repairedTerms)
expect(after.hits.map((hit) => hit.sessionId)).toEqual(before.hits.map((hit) => hit.sessionId))
})
it('moves the generation anyway, so no cursor spans it', async () => {
// The repair counting live rows fixes the common case. It does not make the
// drain provably inert: `messages_vocab` still decides which candidates
// survive its scan limit, and reclaiming a term's last row changes where
// that limit cuts. The fence is what covers the rest, at the price of
// refusing a cursor once per batch while a purge runs.
const open = await withReclaimable()
// A second live session, so page one has a page two to be refused.
addSyntheticSession(open.db, { id: 2, text: 'the marmoset again', rows: 2 })
const page = open.engine.search({ query: 'marmoset', limit: 1 })
expect(page.page.cursor).not.toBeNull()
const before = readIndexGeneration(open.db)
await open.store.purgeOlderThan(null)
expect(readIndexGeneration(open.db)).toBeGreaterThan(before)
try {
open.engine.search({ query: 'marmoset', limit: 1, cursor: page.page.cursor! })
expect.unreachable('a cursor must not span a purge')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('stale-generation')
}
})
it('picks the same repair when an unreachable spelling was the more common one', async () => {
// Two candidates equally close to the query. `marmosetx` led on the old
// ranking only because two of its rows belonged to a session retention had
// already cut loose, so the drain swapped the repair under a live cursor.
harness = await openSessionSearchHarness('ss-orphan-drain-tie')
const db = harness.db
for (let id = 1; id <= 4; id++) {
addSyntheticSession(db, { id, text: `marmosetx session${id}` })
}
for (let id = 5; id <= 9; id++) {
addSyntheticSession(db, { id, text: `marmosetq session${id}` })
}
plantOrphans(db, 'marmosetx')
const before = harness.engine.search({ query: 'marmosett' })
expect(before.planner.repairedTerms).toEqual(['marmosetq'])
await harness.store.purgeOlderThan(null)
expect(harness.engine.search({ query: 'marmosett' }).planner.repairedTerms).toEqual(
before.planner.repairedTerms
)
})
})
@@ -1,108 +0,0 @@
import { createHash } from 'node:crypto'
import type { SessionSearchRequest } from './session-search-engine-types'
export type SessionSearchCursorRejection = 'stale-generation' | 'different-query' | 'malformed'
/**
* A cursor the engine refuses to honour. Typed, and thrown rather than
* swallowed: silently restarting at page one hands the caller a page it has
* already shown as if it were the next one, and silently re-running against a
* newer index hands it a slice of a list it never saw.
*/
export class SessionSearchCursorError extends Error {
constructor(
readonly rejection: SessionSearchCursorRejection,
/**
* The generation the index is at now. Always present: the engine knows it
* before it looks at the cursor at all.
*/
readonly actualGeneration: number,
/**
* The generation the cursor claims it was minted in. Absent only when the
* cursor could not be decoded far enough to carry a number, which is one of
* the `malformed` cases.
*/
readonly expectedGeneration?: number
) {
super(`Search cursor rejected: ${rejection}`)
this.name = 'SessionSearchCursorError'
}
}
type CursorPayload = {
/** Index generation. */
g: number
/**
* Offset into the ranked list, not a session id. Ids are not in a cursor at
* all, so nothing here depends on `sessions.id` being unique over time —
* though it is, because PR 2 made the column AUTOINCREMENT so a purged
* session's id is never reissued to a live one.
*/
o: number
/** Query identity; see `sessionSearchPageKey`. */
k: string
}
/**
* Everything a page's ranking depends on except the limit. Two requests with
* the same key produce the same ranked list within one generation, so a cursor
* minted by one is meaningful to the other; the limit is left out on purpose so
* a caller may change its page size mid-pagination.
*/
export function sessionSearchPageKey(request: SessionSearchRequest): string {
const filters = request.filters ?? {}
const identity = JSON.stringify([
request.query,
request.scope ?? 'all',
filters.sort ?? 'relevance',
filters.since ?? null,
[...(filters.agents ?? [])].sort(),
[...(filters.scopePaths ?? [])].sort()
])
return createHash('sha256').update(identity).digest('base64url').slice(0, 16)
}
export function encodeSessionSearchCursor(generation: number, offset: number, key: string): string {
const payload: CursorPayload = { g: generation, o: offset, k: key }
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url')
}
/**
* The offset this cursor points at, or a typed rejection.
*
* Every rejection carries `actualGeneration`, and every one that could read a
* generation out of the cursor carries `expectedGeneration` too, so a caller
* can tell "the index moved under you, ask for page one" from "this cursor is
* not ours" and act on the first without showing anyone an error.
*/
export function decodeSessionSearchCursor(cursor: string, generation: number, key: string): number {
let payload: CursorPayload
try {
payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')) as CursorPayload
} catch {
throw new SessionSearchCursorError('malformed', generation)
}
// A generation that survived parsing is worth reporting even when the rest of
// the payload is unusable: it is what tells the caller which snapshot the
// cursor thought it was walking.
const claimed =
typeof payload?.g === 'number' && Number.isFinite(payload.g) ? payload.g : undefined
if (
claimed === undefined ||
!Number.isInteger(payload?.o) ||
payload.o < 0 ||
typeof payload?.k !== 'string'
) {
throw new SessionSearchCursorError('malformed', generation, claimed)
}
// Generation first: a caller who changed the query AND waited through a
// publish should hear about the index moving, which is the condition it
// cannot fix by paging again.
if (claimed !== generation) {
throw new SessionSearchCursorError('stale-generation', generation, claimed)
}
if (payload.k !== key) {
throw new SessionSearchCursorError('different-query', generation, claimed)
}
return payload.o
}
@@ -1,309 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { SessionSearchRequest } from './session-search-engine-types'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
import { readIndexGeneration } from './session-search-index-generation'
import {
decodeSessionSearchCursor,
encodeSessionSearchCursor,
SessionSearchCursorError,
sessionSearchPageKey
} from './session-search-page-cursor'
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
async function open(name: string, options = {}): Promise<SessionSearchHarness> {
harness = await openSessionSearchHarness(name, options)
return harness
}
async function withSessions(count: number, options = {}): Promise<SessionSearchHarness> {
harness = await openSessionSearchHarness('ss-engine-paging', options)
for (let id = 1; id <= count; id++) {
addSyntheticSession(harness.db, {
id,
text: `needle padding ${'word '.repeat(id % 5)}`,
updatedAt: `2026-09-${String(id).padStart(2, '0')}T00:00:00.000Z`
})
}
return harness
}
describe('a cursor walks one ranked list', () => {
it('pages through every session exactly once, in one stable order', async () => {
const { engine } = await withSessions(25)
const request: SessionSearchRequest = { query: 'needle', limit: 10 }
const seen: string[] = []
let cursor: string | null = null
let pages = 0
do {
const page = engine.search(cursor ? { ...request, cursor } : request)
seen.push(...page.hits.map((hit) => hit.sessionId))
cursor = page.page.cursor
pages++
expect(pages).toBeLessThan(10)
} while (cursor !== null)
expect(pages).toBe(3)
expect(seen).toHaveLength(25)
expect(new Set(seen).size).toBe(25)
// The same walk, run again against the same generation, is the same walk.
expect(engine.search(request).hits.map((hit) => hit.sessionId)).toEqual(seen.slice(0, 10))
})
it('closes the page when the last hit has been handed out', async () => {
const { engine } = await withSessions(3)
const page = engine.search({ query: 'needle', limit: 10 })
expect(page.hits).toHaveLength(3)
expect(page.page.hasMore).toBe(false)
expect(page.page.cursor).toBeNull()
})
it('lets a caller change page size mid-walk', async () => {
const { engine } = await withSessions(12)
const first = engine.search({ query: 'needle', limit: 5 })
const rest = engine.search({ query: 'needle', limit: 20, cursor: first.page.cursor! })
expect(rest.hits).toHaveLength(7)
expect(rest.page.hasMore).toBe(false)
})
it('breaks a tie by session, so two entries cannot swap between pages', async () => {
// Same text, same timestamp: every ranking key is equal, which is exactly
// where an unstable sort would hand one session out twice and lose another.
harness = await openSessionSearchHarness('ss-engine-ties')
for (let id = 1; id <= 6; id++) {
addSyntheticSession(harness.db, { id, text: 'needle', updatedAt: '2026-09-01T00:00:00.000Z' })
}
const first = harness.engine.search({ query: 'needle', limit: 3 })
const second = harness.engine.search({ query: 'needle', limit: 3, cursor: first.page.cursor! })
const seen = [...first.hits, ...second.hits].map((hit) => hit.sessionId)
expect(seen).toEqual(['1', '2', '3', '4', '5', '6'])
})
})
describe('a cursor is refused rather than reinterpreted', () => {
it('rejects a cursor minted before the index moved', async () => {
const { engine, store } = await withSessions(25)
const first = engine.search({ query: 'needle', limit: 10 })
// A proven deletion of a path this index really held hides a session, which
// is exactly the change a cursor must not be allowed to page across.
store.removeFile('/synthetic/1.jsonl')
expect(() => engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })).toThrow(
SessionSearchCursorError
)
try {
engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })
expect.unreachable('a stale cursor must not be silently re-run')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('stale-generation')
}
})
it('names both generations, so a caller can tell a moved index from a bad cursor', async () => {
// What a caller does about it differs: a moved index means quietly ask for
// page one again, a bad cursor means something is wrong with the caller.
const { engine, store } = await withSessions(25)
const first = engine.search({ query: 'needle', limit: 10 })
const minted = readIndexGeneration(harness!.db)
// Any published read moves the generation, including one for a file this
// page never mentioned. That is the fence working, not a defect.
store.removeFile('/synthetic/9.jsonl')
try {
engine.search({ query: 'needle', limit: 10, cursor: first.page.cursor! })
expect.unreachable('the index moved')
} catch (error) {
const rejected = error as SessionSearchCursorError
expect(rejected.rejection).toBe('stale-generation')
expect(rejected.expectedGeneration).toBe(minted)
expect(rejected.actualGeneration).toBe(readIndexGeneration(harness!.db))
expect(rejected.actualGeneration).toBeGreaterThan(rejected.expectedGeneration!)
}
})
it('rejects a cursor carried over to a different query', async () => {
const { engine } = await withSessions(25)
const first = engine.search({ query: 'needle', limit: 10 })
try {
engine.search({ query: 'padding', limit: 10, cursor: first.page.cursor! })
expect.unreachable('a cursor indexes into one ranked list, not any list')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('different-query')
}
})
it('rejects a cursor whose filters changed, which reranks the list', async () => {
const { engine } = await withSessions(25)
const first = engine.search({ query: 'needle', limit: 10 })
try {
engine.search({
query: 'needle',
limit: 10,
cursor: first.page.cursor!,
filters: { sort: 'newest' }
})
expect.unreachable('a different sort is a different ranked list')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('different-query')
}
})
// Every field the ranked list depends on has to be in the key, and a field
// that is in the key but never pinned is a field a refactor can drop while
// the suite stays green. One case each, through the engine, so the assertion
// is about a refused page and not about a hash.
it.each([
['scope', { scope: 'conversation' as const }],
['sort', { filters: { sort: 'newest' as const } }],
['agents', { filters: { agents: ['codex' as const] } }],
['scopePaths', { filters: { scopePaths: ['/repo/app'] } }],
['since', { filters: { since: '2026-09-01T00:00:00.000Z' } }]
])('rejects a cursor presented with a different %s', async (_field, changed) => {
const { engine } = await withSessions(25)
const request: SessionSearchRequest = {
query: 'needle',
limit: 10,
scope: 'all',
filters: { sort: 'relevance', agents: ['claude'], scopePaths: ['/'], since: undefined }
}
const first = engine.search(request)
expect(first.page.cursor).not.toBeNull()
try {
engine.search({
...request,
...changed,
filters: { ...request.filters, ...('filters' in changed ? changed.filters : {}) },
cursor: first.page.cursor!
})
expect.unreachable('a narrowing the ranked list depends on must invalidate the cursor')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('different-query')
}
})
it('rejects a cursor that is not one of ours', async () => {
const { engine } = await withSessions(3)
try {
engine.search({ query: 'needle', cursor: 'not-a-cursor' })
expect.unreachable('a malformed cursor is not an empty one')
} catch (error) {
expect((error as SessionSearchCursorError).rejection).toBe('malformed')
}
})
})
describe('cursor encoding', () => {
const request: SessionSearchRequest = { query: 'needle', filters: { scopePaths: ['/a'] } }
it('round-trips an offset within its own generation and query', () => {
const key = sessionSearchPageKey(request)
expect(decodeSessionSearchCursor(encodeSessionSearchCursor(7, 40, key), 7, key)).toBe(40)
})
it('keys a request by what changes its ranking, and not by its page size', () => {
expect(sessionSearchPageKey({ ...request, limit: 5 })).toBe(
sessionSearchPageKey({ ...request, limit: 50 })
)
expect(sessionSearchPageKey({ ...request, scope: 'conversation' })).not.toBe(
sessionSearchPageKey(request)
)
})
it('reads a filter list in any order as the same request', () => {
expect(sessionSearchPageKey({ query: 'a', filters: { agents: ['claude', 'codex'] } })).toBe(
sessionSearchPageKey({ query: 'a', filters: { agents: ['codex', 'claude'] } })
)
})
it.each([
['a negative offset', encodeSessionSearchCursor(1, -1, 'k'), 1],
['a non-integer offset', Buffer.from('{"g":1,"o":1.5,"k":"k"}').toString('base64url'), 1],
['a payload that is not an object', Buffer.from('"nope"').toString('base64url'), undefined],
['text that is not base64url JSON', 'zzz!!', undefined]
])('rejects %s as malformed, still naming the index generation', (_name, cursor, claimed) => {
// The caller has to know which snapshot it was refused against whatever was
// wrong with the cursor, and the generation it claimed whenever that
// survived parsing.
try {
decodeSessionSearchCursor(cursor, 7, 'k')
expect.unreachable('a malformed cursor is not an empty one')
} catch (error) {
const rejected = error as SessionSearchCursorError
expect(rejected.rejection).toBe('malformed')
expect(rejected.actualGeneration).toBe(7)
expect(rejected.expectedGeneration).toBe(claimed)
}
})
})
describe('the candidate limit is a tunable default, and says when it cut', () => {
it('does not claim truncation when every session fits', async () => {
const { engine } = await withSessions(5, { sessionCandidateLimit: 600 })
expect(engine.search({ query: 'needle' }).truncated.candidates).toBe(false)
})
it('claims truncation, and ranks only what it retrieved, at the limit', async () => {
const { engine } = await withSessions(10, { sessionCandidateLimit: 4 })
const result = engine.search({ query: 'needle', limit: 100 })
expect(result.truncated.candidates).toBe(true)
expect(result.hits).toHaveLength(4)
})
it('applies the same limit to an operator-only page', async () => {
const { engine } = await withSessions(10, { sessionCandidateLimit: 4 })
const result = engine.search({ query: 'repo:app', limit: 100 })
expect(result.truncated.candidates).toBe(true)
expect(result.hits).toHaveLength(4)
})
it('says it gave up when the operator walk stopped scanning, not that it is done', async () => {
// The shape that reads as a confident empty answer: the only match sits
// past the walk's ceiling, so the walk stops having found nothing. Zero
// hits and `truncated.candidates` false would tell a caller there is
// nothing to find, which is a different claim from "I stopped looking".
// The walk reads a page at a time and gives up past a ceiling of
// `candidateLimit` x 20, so the corpus has to be deeper than one page for
// the ceiling to be what ends it. The only match is the oldest session.
const deep = 600
const { db, engine } = await open('ss-engine-sparse-deep', { sessionCandidateLimit: 2 })
for (let id = 1; id <= deep; id++) {
addSyntheticSession(db, {
id,
cwd: id === deep ? '/repo/needleonly' : '/repo/app',
updatedAt: new Date(Date.UTC(2026, 8, 9) - id * 60_000).toISOString()
})
}
const result = engine.search({ query: 'repo:needleonly' })
expect(result.hits).toHaveLength(0)
expect(result.truncated.candidates).toBe(true)
})
it('does not claim it gave up when the walk really did read everything', async () => {
const { db, engine } = await open('ss-engine-sparse-shallow', { sessionCandidateLimit: 600 })
addSyntheticSession(db, { id: 1, cwd: '/repo/app' })
const result = engine.search({ query: 'repo:nothing-here' })
expect(result.hits).toHaveLength(0)
expect(result.truncated.candidates).toBe(false)
})
})
describe('the response carries the snapshot it was built from', () => {
it('reports the index generation on every result', async () => {
const { db, engine, store } = await withSessions(3)
const before = engine.search({ query: 'needle' }).generation
expect(before).toBe(readIndexGeneration(db))
store.removeFile('/synthetic/1.jsonl')
const after = engine.search({ query: 'needle' }).generation
expect(after).toBe(readIndexGeneration(db))
expect(after).toBeGreaterThan(before)
})
})
@@ -0,0 +1,216 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { ensureSessionParseCacheLoaded } from '../ai-vault/session-parse-cache-persistence'
import {
cursorChatMetaRefusals,
withCursorChatMetaScan
} from '../ai-vault/session-scanner-cursor-chat-meta'
import { recordSessionScanIssue } from '../ai-vault/session-scan-issues'
import {
mergeDegradedRoots,
scanIssueDegradedRoots,
unreadableRoots,
type SessionSearchDegradedRoot
} from './session-search-degraded-roots'
import { retireDeletedSessionSearchSources } from './session-search-deleted-sources'
import type { SessionSearchDirectoryReader } from './session-search-directory-listings'
import { runSessionSearchIndexPass } from './session-search-index-pass'
import {
discoverSessionSearchCandidates,
isUnderScanRoot,
sessionSearchEmptiedRoots,
sessionSearchRootListings,
type SessionSearchScanRoots
} from './session-search-scan-roots'
import type { SessionSearchFileRow, SessionSearchStore } from './session-search-store'
import { sessionSearchEnumeratedContainers } from './session-search-synthetic-sources'
/**
* Rows a cycle proves present or gone, newest first.
*
* Why bounded and why newest first: a cycle lists the newest N per agent, so
* every older row it holds is undiscovered and would otherwise be walked every
* twenty seconds. Newest first is what makes the guarantee hold — a transcript
* recent enough for the window to cover is recent enough to be in this slice,
* so its deletion is proven on the very next cycle whenever it happened.
*/
const RETIREMENT_ROWS_PER_CYCLE = 512
/**
* Directories either pass may read proving deletions.
*
* The bound on the walk is readdirs, not rows: rows sharing a directory are one
* read and then map lookups, and a directory that answers an error answers it
* once for every row under it. Counting rows instead let one unreadable
* directory hold the whole walk for as long as it stayed unreadable.
*/
const RETIREMENT_DIRECTORIES_PER_PASS = 512
export type SessionSearchPassArgs = {
store: SessionSearchStore
roots: SessionSearchScanRoots
/** A sweep lists every root; a cycle lists the newest N per agent. */
full: boolean
recentPerAgent: number
/** Real roots that listed transcripts on the previous pass; undefined before the first. */
previousRootsWithFiles?: ReadonlySet<string>
/** True once the pass is out of wall time; reads stop, everything else finishes. */
overdue?: () => boolean
/** One readdir per directory for the whole pass, shared by every step. */
listings: SessionSearchDirectoryReader
signal?: AbortSignal
}
export type SessionSearchPassResult = {
/** Real roots this pass listed transcripts under, for the next pass to compare against. */
rootsWithFiles: Set<string>
degradedRoots: SessionSearchDegradedRoot[]
/** False when the pass was cut short; its conclusions are not to be recorded. */
completed: boolean
/**
* True when the deadline stopped the reads with candidates still owed.
*
* The caller's one use for it: a cycle lists the newest N per agent, so a
* backlog outside that window is only *visible* to a sweep. Without this a
* first run would index the recency window in its opening pass and then crawl,
* making progress only on the periodic sweep every five minutes.
*/
outOfTime: boolean
}
/**
* One pass. Four steps, the same four whether it sweeps or cycles.
*
* 1. **Discover.** The only filesystem walk: every root on a sweep, the newest
* N per agent on a cycle. Everything below is decided from what it returns.
* 2. **Decide and read.** Per candidate, its stat against its row. Reads stop
* at the deadline and nothing is recorded about what was left, because being
* owed is a fact about the row and not an entry in a queue.
* 3. **Retire.** Candidates are the rows this pass's discovery did not return,
* inside the scope that discovery covered. The stateless walk proves each
* one gone, present or unverifiable; only `gone` deletes.
* 4. **Report.** Root health for this pass. The counts are a query, made by the
* caller against the same rows, so nothing here is tallied.
*
* The pass keeps nothing. Everything it learns is either on a row or in the
* result the caller compares against the next pass.
*/
export async function runSessionSearchPass(
args: SessionSearchPassArgs
): Promise<SessionSearchPassResult> {
const { store, signal } = args
if (args.full) {
// Every sweep opens with the purge, so a window narrower than the last
// instance held is applied by the first sweep of this one.
await store.purgeOlderThan(store.retentionCutoff, signal)
}
await ensureSessionParseCacheLoaded()
return withCursorChatMetaScan(async () => {
const swept = await discoverSessionSearchCandidates(args.roots, {
limitPerAgent: args.full ? Number.POSITIVE_INFINITY : args.recentPerAgent,
signal
})
const issues: AiVaultScanIssue[] = [...swept.issues]
let completed = true
let outOfTime = false
const rows = new Map(store.files().map((row) => [row.path, row]))
try {
const read = await runSessionSearchIndexPass(store, swept.candidates, {
signal,
rows,
overdue: args.overdue
})
outOfTime = read.outOfTime
} catch (error) {
if (!signal?.aborted) {
throw error
}
completed = false
}
const listings = sessionSearchRootListings(args.roots, swept.discoveries)
const roots = listings.map((listing) => listing.root)
const rootsWithFiles = new Set(
listings.filter((listing) => listing.files > 0).map((listing) => listing.root)
)
// Undefined, not empty, before any pass has recorded one: an empty set is a
// real observation and this is the absence of one.
const previousRootsWithFiles = args.previousRootsWithFiles
// A pass cut short saw part of the machine, so its silence about a path is
// not evidence; it retires nothing and publishes no verdicts.
const retirement = completed
? await retireDeletedSessionSearchSources({
store,
paths: retirementCandidates(rows, swept, roots, args.full),
roots,
// Only a sweep enumerates without a per-agent limit, so only a sweep
// may prove a synthetic row's container holds it no longer.
enumeratedContainers: args.full
? sessionSearchEnumeratedContainers(swept.candidates, issues)
: undefined,
emptiedRoots: previousRootsWithFiles
? sessionSearchEmptiedRoots(previousRootsWithFiles, rootsWithFiles)
: new Set(),
listings: args.listings,
directoryLimit: RETIREMENT_DIRECTORIES_PER_PASS,
signal
})
: { retired: [], unverifiable: [], unchecked: [], degradedRoots: [] }
for (const refusal of cursorChatMetaRefusals()) {
// One issue per refused chats root, not one per Cursor transcript.
recordSessionScanIssue(issues, {
agent: 'cursor',
path: refusal.chatsRoot,
message: refusal.message
})
}
// Roots that listed no transcripts and cannot be listed either: the walker
// swallows a readdir failure, so this is the only place it surfaces.
const unlistable = completed
? await unreadableRoots(
roots.filter((root) => !rootsWithFiles.has(root)),
args.listings,
signal
)
: []
return {
rootsWithFiles,
degradedRoots: mergeDegradedRoots(
scanIssueDegradedRoots(roots, issues),
retirement.degradedRoots,
unlistable
),
completed,
outOfTime
}
})
}
/**
* Rows this pass's discovery did not return, inside the scope it covered.
*
* A sweep covers everything, so every undiscovered row is a candidate. A cycle
* covers the newest N per agent, so it may only judge rows under a root it
* actually listed, and it takes the newest of those: an older row is not
* evidence of anything a cycle looked for, and the next sweep is what reaches
* it. This is the whole of what used to be a watch set carried between passes.
*/
function retirementCandidates(
rows: ReadonlyMap<string, SessionSearchFileRow>,
swept: { candidates: readonly { file: { path: string } }[] },
roots: readonly string[],
full: boolean
): string[] {
const discovered = new Set(swept.candidates.map((candidate) => candidate.file.path))
const undiscovered = [...rows.values()].filter((row) => !discovered.has(row.path))
if (full) {
return undiscovered.map((row) => row.path)
}
return undiscovered
.filter((row) => roots.some((root) => isUnderScanRoot(row.path, root)))
.sort((left, right) => right.mtimeMs - left.mtimeMs)
.slice(0, RETIREMENT_ROWS_PER_CYCLE)
.map((row) => row.path)
}
@@ -1,61 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
import { logSessionSearchQuery } from './session-search-query-log'
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
async function open(options = {}): Promise<SessionSearchHarness> {
harness = await openSessionSearchHarness('ss-query-log', options)
addSyntheticSession(harness.db, { id: 1, text: 'needle' })
return harness
}
function loggedQueries(harness: SessionSearchHarness): string[] {
return (
harness.db.prepare('SELECT query FROM search_log ORDER BY id').all() as { query: string }[]
).map((row) => row.query)
}
it('writes nothing on the query path unless the caller asked for a log', async () => {
const opened = await open()
opened.engine.search({ query: 'needle' })
expect(loggedQueries(opened)).toEqual([])
})
it('records the query and its route when logging is on', async () => {
const opened = await open({ logQueries: true })
opened.engine.search({ query: 'needle' })
const rows = opened.db.prepare('SELECT query, route, hits FROM search_log').all() as {
query: string
route: string
hits: number
}[]
expect(rows).toEqual([{ query: 'needle', route: 'or', hits: 1 }])
})
it('stores the query as typed, the way the index stores content as written', async () => {
// PR 2 decided the index does not redact: it is a second copy of plaintext
// the user already holds under their own home directory. The same holds for
// what they typed into the search box.
const opened = await open({ logQueries: true })
opened.engine.search({ query: 'Bearer abcdefghijklmnopqrstuvwxyz012345' })
expect(loggedQueries(opened)[0]).toBe('Bearer abcdefghijklmnopqrstuvwxyz012345')
})
it('keeps the newest N and drops the rest, so the log cannot grow with use', async () => {
const opened = await open()
// The real ceiling is 5,000; the trim is the same statement at any size.
for (let n = 0; n < 12; n++) {
logSessionSearchQuery(opened.db, { query: `q${n}`, route: 'or', hits: 0, durationMs: 1 }, 5)
}
expect(loggedQueries(opened)).toEqual(['q7', 'q8', 'q9', 'q10', 'q11'])
})
@@ -1,30 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
export const SEARCH_LOG_LIMIT = 5000
/**
* Local-only telemetry the eval set is rebuilt from.
*
* The query is stored as typed, for the reason PR 2 gives for not redacting
* transcript content: this file sits beside an index that already holds the
* user's own plaintext, so a second copy of what they typed is not a new
* exposure. What may leave the machine is a transport policy and belongs where
* the wire is.
*
* Nothing enables this by default: the engine writes a row only when its caller
* asked for it, because a log write on the query path is a write on what is
* otherwise a read-only lane. Who turns it on is PR 3b's settings decision.
*/
export function logSessionSearchQuery(
db: SyncDatabase,
entry: { query: string; route: string; hits: number; durationMs: number },
limit: number = SEARCH_LOG_LIMIT
): void {
db.prepare(
'INSERT INTO search_log(ts, query, route, hits, duration_ms) VALUES (?, ?, ?, ?, ?)'
).run(new Date().toISOString(), entry.query, entry.route, entry.hits, entry.durationMs)
db.prepare(
`DELETE FROM search_log WHERE id <= (
SELECT id FROM search_log ORDER BY id DESC LIMIT 1 OFFSET ?)`
).run(limit)
}
@@ -1,84 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
andExpression,
isLiteralQuery,
orExpression,
phraseExpression,
planSessionSearchQuery,
quoteFtsTerm
} from './session-search-query-planner'
describe('literal shape decides whether the phrase route is even tried', () => {
it.each([
'resolveTerminalPath',
'src/main/foo-bar.ts',
'MAX_RETRY_COUNT',
'kern.tty.ptmx_max',
'#19687',
'STA-4850',
'"exact words here"',
'TypeError: undefined',
'foo() {'
])('treats %s as quoting something from a transcript', (query) => {
expect(isLiteralQuery(query)).toBe(true)
})
it.each(['why is the terminal slow', 'how do I resume a session', 'relay capacity'])(
'treats %s as prose',
(query) => {
expect(isLiteralQuery(query)).toBe(false)
}
)
})
describe('the body is what the phrase and AND routes see', () => {
it('drops stop words from prose so the AND route is not defeated by "the"', () => {
expect(planSessionSearchQuery('why is the relay dropping frames').body).toEqual([
'relay',
'dropping',
'frames'
])
})
it('keeps stop words inside a literal, where they are part of what was quoted', () => {
// The literal shape is `foo.ts`; dropping `the` would change what was typed.
expect(planSessionSearchQuery('the foo.ts file').body).toEqual(['the', 'foo.ts', 'file'])
})
it('keeps a query that is nothing but stop words rather than answering nothing', () => {
expect(planSessionSearchQuery('how do I').body).toEqual(['how', 'do', 'I'])
})
it('has no terms for a query with no searchable token', () => {
expect(planSessionSearchQuery(' ... ').terms).toEqual([])
})
})
describe('the OR fallback fans an identifier out into its pieces', () => {
it('adds the split pieces after the whole term, never in place of it', () => {
const plan = planSessionSearchQuery('resolveTerminalPath')
expect(plan.terms[0]).toBe('resolveTerminalPath')
expect(plan.terms).toContain('terminal')
expect(plan.terms).toContain('path')
// `resolve` is not a stop word, so the whole identifier is reachable by piece.
expect(plan.terms).toContain('resolve')
})
it('leaves an ordinary word alone', () => {
expect(planSessionSearchQuery('relay').terms).toEqual(['relay'])
})
})
describe('FTS5 expressions quote every term', () => {
it('quotes punctuation that would otherwise be syntax', () => {
expect(quoteFtsTerm('cli.mjs')).toBe('"cli.mjs"')
expect(quoteFtsTerm('C++')).toBe('"C++"')
expect(quoteFtsTerm('say "hi"')).toBe('"say ""hi"""')
})
it('builds one phrase, an AND chain, and an OR chain from the same terms', () => {
expect(phraseExpression(['alpha', 'beta'])).toBe('"alpha beta"')
expect(andExpression(['alpha', 'beta'])).toBe('"alpha" AND "beta"')
expect(orExpression(['alpha', 'beta'])).toBe('"alpha" OR "beta"')
})
})
@@ -1,140 +0,0 @@
import type { SessionSearchScope } from './session-search-engine-types'
import { identifierShadowTerms } from './session-search-identifier-split'
// Tokens exactly as the unicode61 tokenizer with `_ . - / +` tokenchars emits them.
const INDEX_TOKEN = /[\p{L}\p{N}\p{M}\p{Co}_./+-]+/gu
const STOP_WORDS = new Set(
(
'a an and are as at be but by for from how i if in into is it its of on or that the this to ' +
'was were what when where which who why with you your we my me do does did not no can could ' +
'should would about our us they them there their has have had been being so such then than ' +
"these those there's im ive dont"
).split(' ')
)
const MAX_BODY_TERMS = 48
const MAX_TERMS = 64
// A query that quotes something from a transcript: camelCase, SCREAMING_SNAKE,
// a dotted or snake_case name, a path, a filename, a PR number, a ticket, code
// punctuation, or an error word.
const LITERAL_SHAPE =
/[A-Za-z0-9_]*[a-z][A-Z][A-Za-z0-9_]*|\b[A-Z][A-Z0-9]{2,}(_[A-Z0-9]+)+\b|\b\w{2,}[._]\w{2,}\b|\b[\w.-]+\/[\w/.-]+\b|\b\w+\.(ts|tsx|js|jsx|py|rs|go|json|md|sh|yml|yaml|toml|c|cc|h|java|sql)\b|#\d{3,}|\b[A-Z]{2,6}-\d{2,}\b|[(){};=]|::|->|--\w|\b(Error|Exception|Traceback|error:|warning:)\b/
const QUOTED = /"[^"]{3,}"|'[^']{3,}'/
export type SessionSearchQueryPlan = {
literal: boolean
/**
* The query had more terms than the planner will search. What is dropped is
* the tail, so a match that only the last term would have found is missed;
* the caller is told rather than handed a confident empty answer.
*/
truncated: boolean
/** Deduplicated index-faithful terms for the OR fallback, incl. identifier pieces. */
terms: string[]
/** Query-order tokens minus stop words: the phrase / AND candidate. */
body: string[]
}
export function isLiteralQuery(query: string): boolean {
return QUOTED.test(query) || LITERAL_SHAPE.test(query)
}
/**
* The tokenizer contract, unfolded: the same boundaries FTS5 draws for
* `unicode61 tokenchars '_.-/+'`. Pinned against real `fts5vocab` output in
* session-search-fts5-contract.test.ts, which is what makes it safe to plan a
* query without asking SQLite.
*/
export function indexTokens(query: string, limit = Number.POSITIVE_INFINITY): string[] {
const out: string[] = []
for (const match of query.matchAll(INDEX_TOKEN)) {
const token = match[0]
// Separators alone (`--`, `...`) are a token to FTS5 but never a search term.
if (/[\p{L}\p{N}\p{Co}]/u.test(token)) {
out.push(token)
if (out.length >= limit) {
break
}
}
}
return out
}
/**
* `literal` overrides the shape test. Typo repair re-plans the query it
* corrected, and a corrected spelling can look like ordinary prose even though
* what was typed was a literal: `parseJsonn(the, data)` has the punctuation that
* makes it literal, `parsejson the data` does not. Without the override the
* re-plan would drop `the` as a stop word, so the repaired query would search
* for less than the original asked for and `repairedTerms` would report a body
* the user never typed.
*/
export function planSessionSearchQuery(
query: string,
literal = isLiteralQuery(query)
): SessionSearchQueryPlan {
// One past the cap, so the plan can tell a query that just fits from one that
// was cut. `indexTokens` stops at its limit, so it cannot be asked afterwards.
const overCap = indexTokens(query, MAX_BODY_TERMS + 1)
const truncated = overCap.length > MAX_BODY_TERMS
const raw = overCap.slice(0, MAX_BODY_TERMS)
let body = literal ? raw : raw.filter((token) => !STOP_WORDS.has(token.toLowerCase()))
if (body.length < 2) {
body = raw
}
const terms = [...new Set(body)]
const extra: string[] = []
for (const term of terms) {
for (const piece of identifierShadowTerms(term, 12)) {
if (!terms.includes(piece) && !STOP_WORDS.has(piece) && !extra.includes(piece)) {
extra.push(piece)
}
}
}
return {
literal,
truncated,
terms: [...terms, ...extra].slice(0, MAX_TERMS),
body: body.slice(0, MAX_BODY_TERMS)
}
}
// Why: `cli.mjs`, `foo-bar`, and `C++` are all FTS5 syntax errors unquoted.
export function quoteFtsTerm(term: string): string {
return `"${term.replaceAll('"', '""')}"`
}
export function phraseExpression(terms: readonly string[]): string {
return quoteFtsTerm(terms.join(' '))
}
export function andExpression(terms: readonly string[]): string {
return terms.map(quoteFtsTerm).join(' AND ')
}
export function orExpression(terms: readonly string[]): string {
return terms.map(quoteFtsTerm).join(' OR ')
}
/**
* What a scope is, now that there is one FTS table.
*
* `conversation` used to be a second table holding a copy of the two prose
* columns. It is a column filter instead: PR 2 measured the filter at
* 1.16-1.36x the p95 of the dedicated table on a 105 MB corpus, against a 2x
* bar, and the table cost a tenth of the index to maintain.
*
* It lives beside the other expression builders, and not with the retrieval
* that uses it, because the typo repair has to ask the same question of the
* same scope and importing it from there is a cycle.
*
* The filter binds to the whole expression, so it is applied here and nowhere
* else — `{cols}: (a AND b)` filters both terms, while a prefix pasted in front
* of a bare `a AND b` would filter only `a` and quietly search tool output for
* the rest.
*/
const CONVERSATION_COLUMNS = '{user_text assistant_text}'
export function scopedExpression(scope: SessionSearchScope, expression: string): string {
return scope === 'all' ? expression : `${CONVERSATION_COLUMNS}: (${expression})`
}
@@ -1,79 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import {
SESSION_SEARCH_GENERATION_SQL,
SESSION_SEARCH_GENERATION_TRIGGERS
} from './session-search-index-generation'
/** An engine feature the index on disk cannot serve. */
export type SessionSearchUnavailableFeature = 'typo-repair'
const QUERY_SCHEMA_SQL = `
-- The typo repair's whole dictionary. Why the index's own vocabulary and not a
-- word list: it can never suggest a term this index does not hold, and it needs
-- no model. fts5vocab is a view over the FTS5 b-tree, so it costs no extra rows.
CREATE VIRTUAL TABLE IF NOT EXISTS messages_vocab USING fts5vocab(messages_fts, 'row');
-- Locally logged queries, stored as typed, bounded. Nothing writes here unless a
-- caller opts in; the eval set is rebuilt from it (see session-search-query-log).
CREATE TABLE IF NOT EXISTS search_log(
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
query TEXT NOT NULL,
route TEXT NOT NULL,
hits INTEGER NOT NULL,
duration_ms REAL NOT NULL
);
${SESSION_SEARCH_GENERATION_SQL}`
/** Everything the SQL above creates, so a missing one is what triggers a re-run. */
const OWNED = ['messages_vocab', 'search_log', ...SESSION_SEARCH_GENERATION_TRIGGERS]
/**
* The vocabulary's target. Creating a fts5vocab table over a missing FTS table
* succeeds and every query against it then fails, so the feature's health is
* this name's presence rather than the vocabulary's own.
*/
const VOCABULARY_SOURCE = 'messages_fts'
const PROBED = [...OWNED, VOCABULARY_SOURCE]
/**
* Creates whatever of the engine's own schema is missing, and reports what it
* still cannot serve.
*
* These objects are the query engine's, not the store's. Nothing on the write
* path reads any of them, so under the stack's YAGNI rule they do not belong in
* PR 2's schema, and an index built by a process that never opens an engine
* carries none of their cost. None of them needs a schema version either: every
* one is derived from what PR 2 already holds, so re-creating them over any of
* its files is correct, while a version bump would throw a whole index away to
* add a view over its own b-tree.
*
* Run per search, not once per engine. A capability is a fact about the file
* rather than about this object: another handle can rebuild the index under a
* live connection, so a verdict taken in a constructor is wrong for the rest of
* the engine's life in both directions — it would keep reaching for a table
* that went away and never pick one back up when it returned. The steady-state
* cost is the single indexed `sqlite_master` lookup below.
*
* A create that throws is not caught. The only way to reach one is an index
* whose `files` table is gone, which is a rebuild in flight — and an engine
* over that cannot report a hit's source either, so there is nothing to degrade
* to. Losing only the vocabulary's source is the case worth surviving, and that
* one is reported rather than thrown.
*/
export function ensureSessionSearchQuerySchema(
db: SyncDatabase
): readonly SessionSearchUnavailableFeature[] {
const present = presentNames(db)
if (OWNED.some((name) => !present.has(name))) {
db.exec(QUERY_SCHEMA_SQL)
}
return present.has(VOCABULARY_SOURCE) ? [] : ['typo-repair']
}
function presentNames(db: SyncDatabase): Set<string> {
const rows = db
.prepare(`SELECT name FROM sqlite_master WHERE name IN (${PROBED.map(() => '?').join(',')})`)
.all(...PROBED) as { name: string }[]
return new Set(rows.map((row) => row.name))
}
@@ -0,0 +1,117 @@
import { expect, it } from 'vitest'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type { SessionSearchIndexedFile } from './session-search-file-cursor'
import {
SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT,
sessionSearchReadDecision
} from './session-search-read-decision'
import type { SessionSearchFileRow } from './session-search-store'
const PATH = '/transcripts/one.jsonl'
const MTIME = 1_740_000_000_000
function candidate(overrides: Partial<SessionFileCandidate['file']> = {}): SessionFileCandidate {
return {
agent: 'claude',
codexHome: null,
file: {
path: PATH,
mtimeMs: MTIME,
modifiedAt: new Date(MTIME).toISOString(),
sizeBytes: 100,
...overrides
}
}
}
function row(overrides: Partial<SessionSearchFileRow> = {}): SessionSearchFileRow {
return {
path: PATH,
identity: null,
mtimeMs: MTIME,
sizeBytes: 100,
state: 'current',
failCount: 0,
failedMtimeMs: null,
...overrides
}
}
const cursor: SessionSearchIndexedFile = { byteOffset: 100, mtimeMs: MTIME, sizeBytes: 100 }
function decide(args: {
file?: Partial<SessionFileCandidate['file']>
row?: SessionSearchFileRow | undefined
cursor?: SessionSearchIndexedFile | null
cutoffMs?: number | null
}) {
return sessionSearchReadDecision({
candidate: candidate(args.file),
row: 'row' in args ? args.row : row(),
cursor: 'cursor' in args ? (args.cursor ?? null) : cursor,
cutoffMs: args.cutoffMs ?? null
})
}
it('reads a path the index holds nothing for, and lets the reader continue where it can', () => {
// Not `whole`: there is no span this index has to reach past, and the first
// enablement inside a running app has a warm list cursor to make use of.
expect(decide({ row: undefined })).toBe('any')
})
it('skips a file the index already covers at this stat', () => {
expect(decide({})).toBe('skip')
})
it('reads a file whose stat moved, however it moved', () => {
expect(decide({ file: { mtimeMs: MTIME + 1 } })).toBe('any')
// Grown without its mtime moving: a same-second append, or a restored stamp.
expect(decide({ file: { sizeBytes: 200 } })).toBe('any')
})
it('reads a file outside the retention window not at all', () => {
expect(decide({ row: undefined, cutoffMs: MTIME + 1 })).toBe('skip')
// And retention wins over everything else that would have asked for a read.
expect(decide({ row: row({ state: 'due' }), cutoffMs: MTIME + 1 })).toBe('skip')
})
it('reads a row owed a whole read from the start', () => {
expect(decide({ row: row({ state: 'due' }) })).toBe('whole')
})
it('reads whole rather than appending onto a cursor that continues nothing', () => {
// A different file at the same name: the identity check hands back no cursor.
expect(decide({ cursor: null })).toBe('whole')
// A chunked read that committed a prefix and no offset any append continues.
expect(decide({ cursor: { byteOffset: null, mtimeMs: MTIME, sizeBytes: 100 } })).toBe('whole')
// Shorter than the index read to, so this is not that file any more.
expect(decide({ file: { sizeBytes: 40 }, cursor })).toBe('whole')
})
it('retries a failed read until it has failed enough times at one stat', () => {
for (let failures = 1; failures < SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT; failures++) {
expect(
decide({ row: row({ state: 'failed', failCount: failures, failedMtimeMs: MTIME }) })
).toBe('any')
}
expect(
decide({
row: row({
state: 'failed',
failCount: SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT,
failedMtimeMs: MTIME
})
})
).toBe('skip')
})
it('starts trying again the moment a held-out file changes', () => {
// The stat is the whole release condition, so nothing has to remember when
// the failures happened or schedule a retry.
expect(
decide({
file: { mtimeMs: MTIME + 1 },
row: row({ state: 'failed', failCount: 9, failedMtimeMs: MTIME })
})
).toBe('any')
})
@@ -0,0 +1,100 @@
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type { SessionParseReadRequirement } from '../ai-vault/session-scanner-parse-cache'
import { requiresWholeRead, type SessionSearchIndexedFile } from './session-search-file-cursor'
import type { SessionSearchFileRow } from './session-search-store'
/**
* Failures at one unchanged stat before a file is left alone.
*
* Three rather than one, because a single failure is often a transcript being
* rewritten under the read; three at the same mtime is not. The retry policy is
* the stat itself: an edit, a restore, or a `touch` after a `chmod` all move it,
* and nothing else does, so no timer is needed and none is kept.
*/
export const SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT = 3
/**
* What a pass owes one candidate: nothing, a read, or a read from the start.
*
* `any` and `whole` are the reader's own lanes. `whole` drops the session
* list's resume point, which is the only way to reach a span this index never
* saw; `any` asks for some bytes and lets the reader continue where it can,
* which is what the first enablement inside a running app needs — a warm list
* cursor sitting at the file's current stat would otherwise open nothing.
*/
export type SessionSearchReadDecision = 'skip' | SessionParseReadRequirement
/**
* The whole of the indexer's decide step, as a function of the candidate's stat
* and the row the store holds for it. No pass state, no queue, no memory: the
* same inputs give the same answer on the first pass after a restart as on the
* hundredth of a long-running process, which is what lets a deadline cut a pass
* short with nothing to record. What did not get read is still owed, because
* being owed is a fact about the row.
*/
export function sessionSearchReadDecision(args: {
candidate: SessionFileCandidate
/** The file table's row, or undefined when the index holds nothing for it. */
row: SessionSearchFileRow | undefined
/** The cursor for this candidate's identity; null when it is not continuable. */
cursor: SessionSearchIndexedFile | null
/** Oldest transcript mtime worth holding rows for, or null for all history. */
cutoffMs: number | null
}): SessionSearchReadDecision {
const { candidate, row, cursor, cutoffMs } = args
const file = candidate.file
// Retention first: a file outside the window is not worth reading whatever
// else is true of it, and the purge is what removes any row it still has.
if (cutoffMs !== null && file.mtimeMs < cutoffMs) {
return 'skip'
}
if (!row) {
// Nothing held for this path. Not `whole`, because the reader can continue
// from wherever it likes: there is no span this index has to reach past.
return 'any'
}
if (heldOut(row, file.mtimeMs)) {
return 'skip'
}
if (row.state === 'due') {
// The index is behind on a span no append reaches: a declined append, or a
// window that widened to admit this file.
return 'whole'
}
if (cursor === null || requiresWholeRead(cursor)) {
// A different file at the same name, or a chunked read that left a prefix
// and no cursor. Appending onto either would splice two spans together.
return 'whole'
}
const size = file.sizeBytes
if (typeof size === 'number' && cursor.byteOffset !== null && cursor.byteOffset > size) {
// Shorter than the index read to: this is not the file that cursor came from.
return 'whole'
}
if (row.state === 'failed') {
// Still within its retries, or the stat moved since it last failed.
return 'any'
}
return statMatches(row, file) ? 'skip' : 'any'
}
/**
* True when this file has failed enough times at exactly this stat to stop
* trying. The stat is the whole release condition, so a file nobody touches is
* never read again and one that changes is read on the next pass that sees it.
*/
function heldOut(row: SessionSearchFileRow, mtimeMs: number): boolean {
return (
row.state === 'failed' &&
row.failCount >= SESSION_SEARCH_FAILURES_BEFORE_HELD_OUT &&
row.failedMtimeMs === mtimeMs
)
}
/** The row already describes the file as it is now. */
function statMatches(row: SessionSearchFileRow, file: SessionFileCandidate['file']): boolean {
return (
row.mtimeMs === file.mtimeMs &&
(row.sizeBytes === null || file.sizeBytes === undefined || row.sizeBytes === file.sizeBytes)
)
}
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest'
import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy'
const NOW = 1_740_000_000_000
it('treats a fractional or non-positive day count as no bound at all', () => {
// A day count that floors to zero would read as "all history" in one place
// and "cutoff is now" in the other; both sides answer null.
expect(sessionSearchHistoryCutoffMs(0.4, NOW)).toBeNull()
expect(sessionSearchHistoryCutoffMs(0, NOW)).toBeNull()
expect(sessionSearchHistoryCutoffMs(-30, NOW)).toBeNull()
expect(sessionSearchHistoryCutoffMs(30, NOW)).toBe(NOW - 30 * 86_400_000)
// Clamped rather than unbounded: a caller asking for three thousand years of
// history gets the ceiling, not an mtime before the epoch.
expect(sessionSearchHistoryCutoffMs(999_999, NOW)).toBe(NOW - 3_650 * 86_400_000)
})
// The cutoff is read from the clock on every pass, not frozen at construction:
// a purge and the accept check that follows it must not disagree about where
// the window is, or the sweep deletes rows the next candidate re-indexes.
it('moves the cutoff with the clock', () => {
const later = NOW + 86_400_000
expect(sessionSearchHistoryCutoffMs(30, later)).toBe(
(sessionSearchHistoryCutoffMs(30, NOW) ?? 0) + 86_400_000
)
})
@@ -0,0 +1,25 @@
const DAY_MS = 86_400_000
const HISTORY_DAYS_MAX = 3_650
/**
* The retention window, as the indexer's callers state it and as the store
* consumes it. Settings storage is PR 3b's problem; this is the arithmetic.
*/
function normalizeSessionSearchHistoryDays(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null
}
// Why floor then re-check: a fractional day floors to 0, which reads as "all
// history" on one side and "now" on the other; make the two agree.
const days = Math.floor(value)
return days <= 0 ? null : Math.min(HISTORY_DAYS_MAX, days)
}
/** The oldest transcript mtime worth indexing; null means no bound. */
export function sessionSearchHistoryCutoffMs(
historyDays: number | null,
nowMs: number
): number | null {
const days = normalizeSessionSearchHistoryDays(historyDays)
return days === null ? null : nowMs - days * DAY_MS
}
@@ -1,251 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionSearchRoute, SessionSearchScope } from './session-search-engine-types'
import type { MessageRow, SessionRow } from './session-search-hit-ranking'
import {
andExpression,
orExpression,
phraseExpression,
planSessionSearchQuery,
scopedExpression,
type SessionSearchQueryPlan
} from './session-search-query-planner'
import type { SessionRowFilter } from './session-search-row-filter'
import { SessionSearchTypoRepair } from './session-search-typo-repair'
// The operator-only walk: rows per page, and how far past a full candidate set
// it will read before giving up on finding more matches.
const RECENT_PAGE_ROWS = 512
// Ids per `loadSessions` statement, with room to spare for the filter's own
// bound values beside them.
const SESSION_ID_BATCH = 500
const RECENT_SCAN_FACTOR = 20
// Measured: user 3 / assistant 2 / tool 1 / identifiers 1 (MRR 0.503 vs 0.475 flat).
const FULL_WEIGHTS = '3.0, 2.0, 1.0, 1.0'
// The conversation scope zeroes the two columns its filter already excludes.
// Measured, and stated because it is easy to over-read: these zeros change no
// score. FTS5's bm25 sums over the columns the query matched, and the filter
// has already kept the match out of those two, so the same rows come back with
// `1.0, 1.0` here. They are a statement of what the scope means, not the fence
// that enforces it — `scopedExpression` is the fence.
const CONVERSATION_WEIGHTS = '3.0, 2.0, 0.0, 0.0'
export type RetrievalScope = {
scope: SessionSearchScope
sort: 'relevance' | 'newest'
filter: SessionRowFilter
/**
* `repo:` / `path:`, which SQL cannot express. Applied over retrieved rows;
* see session-search-row-filter for why it cannot be pushed down.
*/
matchesOperators: (session: SessionRow) => boolean
/**
* Sessions retrieved before ranking cuts the page. See
* docs/reference/agent-session-search-query-tuning.md for the measurements
* behind the default; it is an option because the right value depends on how
* large an index is and no single number is right for every host.
*/
candidateLimit: number
}
export type Retrieved = {
rows: MessageRow[]
route: SessionSearchRoute
/** The plan the rows were actually retrieved by; snippets highlight from it. */
plan: SessionSearchQueryPlan
repairedTerms?: string[]
}
/**
* The bm25 weights a scope ranks with. The conversation pair stays here rather
* than beside `scopedExpression`, because weights are a property of this SQL
* and nothing else asks for them.
*/
export function scopedWeights(scope: SessionSearchScope): string {
return scope === 'all' ? FULL_WEIGHTS : CONVERSATION_WEIGHTS
}
/** The FTS half of a search: the route ladder and the SQL each rung runs. */
export class SessionSearchRetrieval {
/** Null when this index has no vocabulary to repair against; the rung is skipped. */
private readonly typoRepair: SessionSearchTypoRepair | null
constructor(
private readonly db: SyncDatabase,
canRepairTypos = true
) {
this.typoRepair = canRepairTypos ? new SessionSearchTypoRepair(db) : null
}
/**
* The route ladder: phrase, then AND for a literal-looking query, then typo
* repair, then OR.
*
* Repair runs before the OR fallback rather than after it fails. A typo next
* to a common word would otherwise be masked: the common word alone retrieves
* plenty of rows over OR, so nothing would ever look like a miss worth
* repairing.
*/
run(plan: SessionSearchQueryPlan, scope: RetrievalScope): Retrieved {
const exact = this.literal(plan, scope)
if (exact) {
return { ...exact, plan }
}
const repaired = this.repair(plan, scope.scope)
const effective = repaired ?? plan
const literal = repaired ? this.literal(repaired, scope) : null
const found = literal ?? {
rows: this.match(orExpression(effective.terms), scope),
route: 'or' as const
}
return {
rows: found.rows,
route: repaired ? (`typo+${found.route}` as SessionSearchRoute) : found.route,
plan: effective,
...(repaired ? { repairedTerms: repaired.body } : {})
}
}
/**
* Newest sessions the constraints allow: what an operator-only query names.
*
* Walked in pages rather than taken in one `LIMIT`, because the operators are
* applied in JS. A single cut of the newest N would hand ranking whatever
* happened to be recent and then throw most of it away, so `repo:x` on a busy
* index could answer with nothing while plenty matched. The walk is bounded
* both ways: it stops at a full candidate set, and at a ceiling on rows read.
*/
recent(scope: RetrievalScope): { sessions: SessionRow[]; incomplete: boolean } {
const { conditions, values } = scope.filter
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const page = this.db.prepare(
`SELECT * FROM sessions ${where}
ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`
)
const ceiling = scope.candidateLimit * RECENT_SCAN_FACTOR
const sessions: SessionRow[] = []
let scanned = 0
// Why the flag and not a count: both caps mean the same thing to a caller —
// a session it never saw may have matched — and only the loop knows which
// of them ended it. Reporting rows read instead let the engine infer
// completeness from a full candidate set alone, so giving up at the ceiling
// with nothing found looked exactly like a search that found nothing.
let incomplete = false
while (sessions.length < scope.candidateLimit) {
if (scanned >= ceiling) {
incomplete = true
break
}
const rows = page.all(...values, RECENT_PAGE_ROWS, scanned) as SessionRow[]
if (rows.length === 0) {
break
}
scanned += rows.length
for (const row of rows) {
if (sessions.length < scope.candidateLimit && scope.matchesOperators(row)) {
sessions.push(row)
}
}
}
return { sessions, incomplete: incomplete || sessions.length >= scope.candidateLimit }
}
/**
* Read in batches, because the id list is as long as the candidate limit and
* every id is a bound parameter, so a single statement scales with a knob the
* tuning doc invites a host to raise.
*
* Not a fix for a reachable failure, and worth saying so: SQLite has bound
* `SQLITE_MAX_VARIABLE_NUMBER` at 32,766 since 3.32, every runtime this stack
* supports is past that, and the measured limit on this one is higher still.
* A candidate limit that large is not a configuration anyone would choose.
* The batch is here so the ceiling belongs to this file rather than to
* whichever SQLite the process happened to link.
*/
loadSessions(ids: readonly number[], scope: RetrievalScope): SessionRow[] {
const rows: SessionRow[] = []
for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) {
const batch = ids.slice(start, start + SESSION_ID_BATCH)
const conditions = [`id IN (${batch.map(() => '?').join(',')})`, ...scope.filter.conditions]
rows.push(
...(this.db
.prepare(`SELECT * FROM sessions WHERE ${conditions.join(' AND ')}`)
.all(...batch, ...scope.filter.values) as SessionRow[])
)
}
return rows.filter((row) => scope.matchesOperators(row))
}
private repair(
plan: SessionSearchQueryPlan,
scope: SessionSearchScope
): SessionSearchQueryPlan | null {
if (!this.typoRepair) {
return null
}
const typoRepair = this.typoRepair
let changed = false
const body = plan.body.map((term) => {
// Repaired inside the scope the search will run in, so a spelling only
// tool output carries neither suppresses a repair nor becomes one.
const fix = typoRepair.correct(term, scope)
if (fix && fix !== term.toLowerCase()) {
changed = true
return fix
}
return term
})
// The repair changes spellings, not the query's character: the re-plan is
// told what the original decided so a corrected literal keeps every term it
// was typed with.
return changed ? planSessionSearchQuery(body.join(' '), plan.literal) : null
}
/** Phrase, then AND, for literal-looking queries; null when neither matches. */
private literal(
plan: SessionSearchQueryPlan,
scope: RetrievalScope
): { rows: MessageRow[]; route: 'phrase' | 'and' } | null {
if (!plan.literal || plan.body.length === 0) {
return null
}
// A one-token literal (`resolveTerminalPath`, `src/a/b.ts`) is its own
// phrase: the tokenizer keeps it whole, so the exact token is the cheap,
// precise first try before the identifier pieces fan out over OR.
const phrase = this.match(phraseExpression(plan.body), scope)
if (phrase.length > 0) {
return { rows: phrase, route: 'phrase' }
}
if (plan.body.length < 2) {
return null
}
const and = this.match(andExpression(plan.body), scope)
return and.length > 0 ? { rows: and, route: 'and' } : null
}
private match(expression: string, scope: RetrievalScope): MessageRow[] {
const { filter, sort, candidateLimit } = scope
const eligible = filter.conditions.length
? ` AND m.session_row_id IN (SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')})`
: ''
const matched = `SELECT messages_fts.rowid AS rowid,
-bm25(messages_fts, ${scopedWeights(scope.scope)}) AS score,
m.session_row_id, m.role, m.ts, s.updated_at
FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?${eligible}`
// Why: collapse to one row per session BEFORE the candidate limit, on both
// sort orders, so a single long session cannot occupy the whole page.
// `max(score)` makes SQLite pick that session's best row for the bare columns.
// Cost of grouping instead of a bounded top-N sorter, measured: ~1.75x
// (49.6 vs 28.6 ms at 80k matching rows, 183.6 vs 104.1 ms at 240k) and a
// temp b-tree over every match. No inner LIMIT can bound it: the CTE has no
// order, so any cut drops whole sessions rather than their surplus rows.
const order = sort === 'newest' ? 'updated_at DESC, score DESC' : 'score DESC'
const sql = `WITH matched AS MATERIALIZED (${matched})
SELECT rowid, max(score) AS score, session_row_id, role, ts FROM matched
GROUP BY session_row_id ORDER BY ${order} LIMIT ${candidateLimit}`
return this.db
.prepare(sql)
.all(scopedExpression(scope.scope, expression), ...filter.values) as MessageRow[]
}
}
@@ -1,137 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionSearchFilters } from './session-search-engine-types'
import { cwdKey } from './session-search-file-records'
import { sessionRowFilter } from './session-search-row-filter'
import {
openSessionSearchIndexFile,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
let index: SessionSearchIndexFile | null = null
afterEach(async () => {
await index?.close()
index = null
})
async function openIndex(): Promise<SyncDatabase> {
index = await openSessionSearchIndexFile('ss-row-filter')
return index.db
}
function addSession(
db: SyncDatabase,
id: number,
cwd: string | null,
overrides: { agent?: string; updatedAt?: string } = {}
): void {
db.prepare(
`INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,updated_at,resume_command)
VALUES (?,?,?,?,'fixture',?,?,?,'')`
).run(
id,
overrides.agent ?? 'claude',
String(id),
`/synthetic/${id}`,
cwd,
cwdKey(cwd),
overrides.updatedAt ?? '2026-09-01T00:00:00.000Z'
)
}
function selected(db: SyncDatabase, filters: SessionSearchFilters = {}): number[] {
const filter = sessionRowFilter(filters)
const where = filter.conditions.length > 0 ? `WHERE ${filter.conditions.join(' AND ')}` : ''
return (
db.prepare(`SELECT id FROM sessions ${where} ORDER BY id`).all(...filter.values) as {
id: number
}[]
).map((row) => row.id)
}
describe('a cwd scope is the sidebar key, or anything below it', () => {
it.each([
['C:\\Work\\App', 'c:/work/app', true],
['C:\\Work\\App\\src', 'c:/work/app', true],
['/work/APP/src', '/work/app', false],
['/work/caf\u00e9', '/work/cafe\u0301', true],
['/work/app-other', '/work/app', false],
['/work/a_b/src', '/work/a_b', true],
['/work/axb/src', '/work/a_b', false],
// Roots: `/` is the one key that is already a separator, which is where a
// range bound is easiest to get wrong. A Windows key is not under POSIX `/`.
['/', '/', true],
['/work/app', '/', true],
['C:\\Work\\App', '/', false],
['C:\\', 'C:\\', true],
['C:\\Work\\App', 'C:\\', true]
])('scopes %s under %s: %s', async (cwd, scope, expected) => {
const db = await openIndex()
addSession(db, 1, cwd)
expect(selected(db, { scopePaths: [scope] })).toEqual(expected ? [1] : [])
})
it('never matches a session whose transcript recorded no cwd', async () => {
const db = await openIndex()
addSession(db, 1, null)
expect(selected(db, { scopePaths: ['/work'] })).toEqual([])
expect(selected(db)).toEqual([1])
})
it('keeps a WSL UNC workspace distinct from the bare Linux spelling', async () => {
// PR 2 decided cwd_key does not qualify a Linux path with its distro: the
// collision is real but every SSH host has it too, and the fix is a column
// naming the execution host, not a key only some hosts spell differently.
const db = await openIndex()
addSession(db, 1, '\\\\wsl.localhost\\Ubuntu\\home\\ada\\app')
addSession(db, 2, '/home/ada/app')
expect(selected(db, { scopePaths: ['\\\\wsl$\\Ubuntu\\home\\ada'] })).toEqual([1])
expect(selected(db, { scopePaths: ['/home/ada/app'] })).toEqual([2])
expect(selected(db, { scopePaths: ['\\\\wsl$\\Debian\\home\\ada\\app'] })).toEqual([])
})
})
describe('caller filters', () => {
it('narrows by agent, and by updated-at floor', async () => {
const db = await openIndex()
addSession(db, 1, '/work/app', { agent: 'claude', updatedAt: '2026-09-01T00:00:00.000Z' })
addSession(db, 2, '/work/app', { agent: 'codex', updatedAt: '2026-09-05T00:00:00.000Z' })
expect(selected(db, { agents: ['codex'] })).toEqual([2])
expect(selected(db, { since: '2026-09-03T00:00:00.000Z' })).toEqual([2])
expect(selected(db, { agents: ['claude'], since: '2026-09-03T00:00:00.000Z' })).toEqual([])
})
it('applies the retention cutoff through the files table', async () => {
const db = await openIndex()
addSession(db, 1, '/work/app')
addSession(db, 2, '/work/app')
db.prepare(
"INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('a',0,100,1)"
).run()
db.prepare(
"INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES ('b',0,500,2)"
).run()
const filter = sessionRowFilter({}, 300)
const rows = db
.prepare(`SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}`)
.all(...filter.values) as { id: number }[]
expect(rows.map((row) => row.id)).toEqual([2])
})
})
it('plans a cwd scope as a seek on sessions_cwd_key, never a scan', async () => {
const db = await openIndex()
const filter = sessionRowFilter({ scopePaths: ['/work/app'] })
const plan = (
db
.prepare(
`EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE ${filter.conditions.join(' AND ')}`
)
.all(...filter.values) as { detail: string }[]
).map((row) => row.detail)
expect(plan.join(' | ')).toContain('sessions_cwd_key')
expect(plan.some((detail) => detail.startsWith('SEARCH'))).toBe(true)
expect(plan.some((detail) => detail.startsWith('SCAN sessions'))).toBe(false)
})
@@ -1,90 +0,0 @@
import { cwdKey } from './session-search-file-records'
import type { SessionSearchFilters } from './session-search-engine-types'
/** SQL fragments for the `sessions` WHERE clause; every condition is ANDed. */
export type SessionRowFilter = {
conditions: string[]
values: (string | number)[]
}
// Stored identity: `cwdKey` is the sidebar's `folderGroupKey` without its prefix,
// so a scope term and an indexed session are keyed by one function, never two.
const CWD = 'cwd_key'
/**
* The narrowings SQL can express exactly, in one place, so retrieval, the
* operator-only page and the session load cannot drift apart. These conditions
* run over `sessions` itself. Reachability is not here and is not a condition:
* it is the INNER JOIN to `sessions` that every retrieval carries, which is
* what makes a message row a purge has not reclaimed yet unreadable.
*
* `repo:` and `path:` are deliberately absent. What they mean is the predicate
* the sessions panel applies (`matchesAiVaultQueryOperators`), and SQL cannot
* express it: LIKE folds ASCII and nothing else, so `path:CAFÉ` would miss
* `café`; `path:` searches the transcript path as well as the working
* directory, so `path:jsonl` would miss every session; and `repo:` compares the
* last two path segments, not one. A second spelling that came close would be a
* query meaning different things in the list and in the index, so the engine
* applies the panel's own predicate over the rows it retrieves instead.
*
* `scopePaths` stays here because it is exact: a prefix range over the key
* `cwdKey` produces, which folds exactly where the execution host folds —
* Windows drives, never a POSIX directory name.
*/
export function sessionRowFilter(
filters: SessionSearchFilters,
cutoffMs: number | null = null
): SessionRowFilter {
const filter: SessionRowFilter = { conditions: [], values: [] }
if (cutoffMs !== null) {
filter.conditions.push('id IN (SELECT session_row_id FROM files WHERE mtime_ms >= ?)')
filter.values.push(cutoffMs)
}
if (filters.agents && filters.agents.length > 0) {
filter.conditions.push(`agent IN (${filters.agents.map(() => '?').join(',')})`)
filter.values.push(...filters.agents)
}
if (filters.since) {
filter.conditions.push('updated_at >= ?')
filter.values.push(filters.since)
}
if (filters.scopePaths && filters.scopePaths.length > 0) {
// Several scopes mean any of them; every other narrowing is ANDed on.
const present = filters.scopePaths
.map((scope) => scopeCondition(filter, scope))
.filter((condition) => condition !== null)
if (present.length > 0) {
filter.conditions.push(`(${present.join(' OR ')})`)
}
}
return filter
}
/** A scope the caller could not key is a scope nothing is inside of. */
function scopeCondition(filter: SessionRowFilter, scope: string): string | null {
const key = cwdKey(scope)
return key === null ? null : insideCondition(filter, key)
}
/**
* `key` itself, or anything below it. Why a half-open range and not
* `substr(key, 1, length(?)) = ?`: only `>=`/`<` can seek `sessions_cwd_key`;
* the substr form scans it. The bound is the child prefix with its last byte
* incremented, so it stops at the end of that prefix and nowhere else. The two
* arms cannot merge: one range over the bare key would also swallow a sibling
* like `/work/app-other`. No wildcards, so `%`/`_` in a folder name are literal.
*
* The filesystem root is the one key that already ends in a separator, and
* appending a second one would bound the range at `//`, which sorts below every
* real child; `cwdKey` keeps it as `/` for exactly this reason.
*/
function insideCondition(filter: SessionRowFilter, key: string): string {
const children = key.endsWith('/') ? key : `${key}/`
filter.values.push(key, children, nextAfterPrefix(children))
return `(${CWD} = ? OR (${CWD} >= ? AND ${CWD} < ?))`
}
/** The first string that sorts after every string starting with `prefix`. */
function nextAfterPrefix(prefix: string): string {
return prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1)
}
@@ -0,0 +1,58 @@
import { expect, it } from 'vitest'
import { delimiter, join } from 'node:path'
import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types'
import { sessionSearchRootListings } from './session-search-scan-roots'
const STATE = '/tmp/ss-roots/openclaw-state'
const LEGACY = '/tmp/ss-roots/openclaw-legacy'
function file(path: string): SessionFileDiscovery['files'][number] {
return { path, mtimeMs: 0, modifiedAt: new Date(0).toISOString() }
}
it('splits a merged discovery into the real directories behind it', () => {
const current = join(STATE, 'agents')
const legacy = join(LEGACY, 'agents')
const listings = sessionSearchRootListings(
{ openclawStateDir: STATE, openclawLegacyStateDir: LEGACY },
[
{
agent: 'openclaw',
// What discovery reports for an agent whose roots are alternates.
rootDir: [current, legacy].join(delimiter),
files: [
file(join(current, 'a', 'sessions', 'one.jsonl')),
file(join(current, 'a', 'sessions', 'two.jsonl')),
file(join(legacy, 'b', 'sessions', 'three.jsonl'))
]
}
]
)
const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files]))
expect(byRoot[current]).toBe(2)
expect(byRoot[legacy]).toBe(1)
// The joined string is never reported as a directory.
expect(listings.every((one) => !one.root.includes(delimiter))).toBe(true)
})
it('attributes a file by path segment, not by string prefix', () => {
const agents = join(STATE, 'agents')
const legacy = join(LEGACY, 'agents')
const listings = sessionSearchRootListings(
{ openclawStateDir: STATE, openclawLegacyStateDir: LEGACY },
[
{
agent: 'openclaw',
rootDir: [agents, legacy].join(delimiter),
// A sibling directory whose name merely starts with a root's name. It
// is under no root, so it belongs to none of them.
files: [file(join(`${agents}-old`, 'b', 'sessions', 'two.jsonl'))]
}
]
)
const byRoot = Object.fromEntries(listings.map((one) => [one.root, one.files]))
expect(byRoot[agents]).toBe(0)
expect(byRoot[legacy]).toBe(0)
})
@@ -0,0 +1,135 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources'
import { normalizedWslHomeDirs } from '../ai-vault/session-scanner-roots'
import { sessionCandidatesFromDiscoveries } from '../ai-vault/session-scanner-candidates'
import { discoverAiVaultSessionSources } from '../ai-vault/session-scanner-source-discovery'
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery
} from '../ai-vault/session-scanner-types'
/** One real directory a scan walked, and what it listed there. */
export type SessionSearchRootListing = { root: string; files: number }
/**
* Where the indexer looks. The caller resolves these so the index enumerates
* exactly the trees the session list does; the indexer owns the bounds
* (`limit`, `limitPerAgent`, `unlimited`) and its own cancellation, so those
* are not the caller's to set.
*/
export type SessionSearchScanRoots = Omit<
AiVaultScanOptions,
'signal' | 'limit' | 'unlimited' | 'limitPerAgent' | 'scopePaths'
>
export type SessionSearchDiscovery = {
/** Newest first, Codex hardlink aliases collapsed, exactly as a list scan sees them. */
candidates: SessionFileCandidate[]
discoveries: SessionFileDiscovery[]
issues: AiVaultScanIssue[]
}
/**
* The discovery half of a list scan, without the parse. `limitPerAgent` is the
* sidebar's own recency rule (`SessionNewestFiles` keeps the newest N per root);
* passing Infinity is what makes a sweep whole.
*/
export async function discoverSessionSearchCandidates(
roots: SessionSearchScanRoots,
args: { limitPerAgent: number; signal?: AbortSignal }
): Promise<SessionSearchDiscovery> {
const issues: AiVaultScanIssue[] = []
const options: AiVaultScanOptions = { ...roots, signal: args.signal }
const discoveries = await discoverAiVaultSessionSources({
options,
limitPerAgent: args.limitPerAgent,
issues
})
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
return { candidates, discoveries, issues }
}
/**
* Containment on path segments, not on string prefix, and on both separators:
* discovery joins with the platform's, a configured root can arrive spelled
* with the other, and `/a/agents-old` is not inside `/a/agents`.
*/
export function isUnderScanRoot(path: string, root: string): boolean {
return root.length > 0 && (path.startsWith(`${root}/`) || path.startsWith(`${root}\\`))
}
/**
* The real directories behind a scan's discoveries, with their file counts.
*
* Why this exists: an agent whose roots are alternates for one install reports
* them as a single discovery whose `rootDir` is every path joined by the
* platform's path delimiter. That string is not a directory. Health probes
* readdir it and get ENOENT, a containment check never matches a file under it,
* and a scan issue recorded against a real root never equals it — so the fence
* meant to protect an unmounted tree is inert for exactly the agent most likely
* to have one. Splitting the joined string back apart would be worse: a
* directory may legally contain the delimiter. The constituent paths come from
* the same source table discovery read.
*/
export function sessionSearchRootListings(
roots: SessionSearchScanRoots,
discoveries: readonly SessionFileDiscovery[]
): SessionSearchRootListing[] {
const wslHomeDirs = normalizedWslHomeDirs(roots.wslHomeDirs)
const counts = new Map<string, number>()
for (const discovery of discoveries) {
const constituents = constituentRoots(roots, wslHomeDirs, discovery)
for (const root of constituents) {
counts.set(root, counts.get(root) ?? 0)
}
for (const file of discovery.files) {
const owner = owningRoot(constituents, file.path)
if (owner !== null) {
counts.set(owner, (counts.get(owner) ?? 0) + 1)
}
}
}
return [...counts].map(([root, files]) => ({ root, files }))
}
function constituentRoots(
roots: SessionSearchScanRoots,
wslHomeDirs: readonly string[],
discovery: SessionFileDiscovery
): string[] {
const declared = AI_VAULT_AGENT_SOURCES[discovery.agent]?.rootDirs(roots, wslHomeDirs) ?? []
if (declared.includes(discovery.rootDir)) {
return [discovery.rootDir]
}
// Either a merged discovery, whose rootDir is the joined string, or a source
// that builds its own discoveries (OpenCode, Antigravity) and reports a real
// directory that this table does not list.
return declared.length > 0 ? declared : [discovery.rootDir]
}
function owningRoot(constituents: readonly string[], path: string): string | null {
let owner: string | null = null
for (const root of constituents) {
if (isUnderScanRoot(path, root) && (owner === null || root.length > owner.length)) {
owner = root
}
}
return owner
}
/**
* Roots that listed transcripts on the previous pass and list none on this one.
*
* The one bit of memory the retirement walk gets, and what it buys: a root that
* blinks empty for a single pass is unverifiable rather than proven gone, so a
* sync client swapping a directory out cannot retire a tree. It is deliberately
* not evidence that survives the process — see the invariant block in
* `session-search-deleted-sources.ts` for what that costs and why.
*/
export function sessionSearchEmptiedRoots(
previous: ReadonlySet<string>,
current: ReadonlySet<string>
): Set<string> {
return new Set([...previous].filter((root) => !current.has(root)))
}
@@ -57,7 +57,18 @@ CREATE TABLE IF NOT EXISTS files(
byte_offset INTEGER NOT NULL,
mtime_ms REAL NOT NULL,
size_bytes INTEGER,
session_row_id INTEGER
session_row_id INTEGER,
-- What this row still owes a reader, so that nothing has to be remembered
-- between passes. 'current': the rows match the file at the stat recorded
-- here. 'due': the index is behind on content it cannot reach by appending,
-- so the next pass reads the file whole. 'failed': the last read did not
-- commit, and the two columns below are what stop it being retried for ever.
state TEXT NOT NULL DEFAULT 'current',
fail_count INTEGER NOT NULL DEFAULT 0,
-- The mtime the failures were observed at. A file that fails at one stat is
-- left alone once it has failed enough times, and only a change to this stat
-- can mean the file itself changed, so it is the whole retry policy.
failed_mtime_ms REAL
);
-- Retention walks the expiring end of this column; without it that is a full scan and a sort.
CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms);
@@ -1,137 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
// `repo:` and `path:` have to mean one thing. The sessions panel and the index
// answer from different stores by different mechanisms, so the only way to keep
// them equal is for both to run the same predicate; this asserts they do, over
// the shapes where a second SQL spelling went wrong.
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
type Fixture = { id: number; cwd: string; filePath: string; text: string }
const SESSIONS: Fixture[] = [
{
id: 1,
cwd: '/Users/Ada/orca/session-search',
filePath: '/Users/Ada/.claude/projects/a/one.jsonl',
text: 'harbor pilot manifest'
},
{
id: 2,
cwd: '/Users/ada/work/café',
filePath: '/Users/ada/.codex/sessions/two.jsonl',
text: 'harbor dock crane'
},
{
id: 3,
cwd: '/srv/other/service',
filePath: '/srv/.claude/projects/b/three.jsonl',
text: 'harbor manifest beta'
},
{
id: 4,
cwd: 'C:\\Work\\Orca\\App',
filePath: 'C:\\Users\\Ada\\.claude\\four.jsonl',
text: 'harbor windows lane'
}
]
// Each of these matched in the panel and missed in the index while the engine
// tried to say `repo:` / `path:` in SQL.
const QUERIES = [
'harbor path:jsonl',
'harbor repo:orca/session-search',
'harbor path:CAFÉ',
'harbor path:/Users/Ada/orca',
'harbor repo:app',
'harbor repo:Orca/App',
'harbor path:.codex',
'harbor path:/srv repo:other/service',
'harbor repo:session-search path:jsonl',
'harbor path:"/Users/ada/work"',
'harbor repo:nothing-here',
'harbor path:one.jsonl path:two.jsonl',
'harbor'
]
function asSession(fixture: Fixture): AiVaultSession {
const at = '2026-09-01T00:00:00.000Z'
return {
id: String(fixture.id),
executionHostId: 'local',
agent: 'claude',
sessionId: String(fixture.id),
title: 'fixture',
cwd: fixture.cwd,
branch: null,
model: null,
filePath: fixture.filePath,
codexHome: null,
createdAt: at,
updatedAt: at,
modifiedAt: at,
messageCount: 1,
totalTokens: 0,
previewMessages: [{ role: 'user', text: fixture.text }],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: '',
subagent: null
} as AiVaultSession
}
/** The panel's own answer, operators only: free text is FTS in the index. */
function sidebarIds(query: string): string[] {
const operatorsOnly = query
.split(/\s+/)
.filter((token) => /^(repo|path):/i.test(token))
.join(' ')
return filterAiVaultSessions(SESSIONS.map(asSession), {
query: operatorsOnly,
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePaths: [],
hideEmptySessions: false
})
.map((session) => session.sessionId)
.sort()
}
it.each(QUERIES)('answers %s the way the sessions panel does', async (query) => {
harness = await openSessionSearchHarness('ss-sidebar-parity')
for (const fixture of SESSIONS) {
addSyntheticSession(harness.db, {
id: fixture.id,
cwd: fixture.cwd,
text: fixture.text,
filePath: fixture.filePath,
sessionFilePath: fixture.filePath
})
}
const engineIds = harness.engine
.search({ query, limit: 100 })
.hits.map((hit) => hit.sessionId)
.sort()
expect(engineIds).toEqual(sidebarIds(query))
})
it('is not vacuous: these queries do select, and reject, real sessions', () => {
// A parity suite where every query matched everything, or nothing, would pass
// against any predicate at all.
const answers = QUERIES.map((query) => sidebarIds(query).length)
expect(answers.some((count) => count > 0 && count < SESSIONS.length)).toBe(true)
expect(answers.some((count) => count === 0)).toBe(true)
})
@@ -1,112 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import {
SESSION_SEARCH_SNIPPET_MARK_CLOSE,
SESSION_SEARCH_SNIPPET_MARK_OPEN
} from './session-search-engine-types'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
// A snippet has to name which of a row's four columns matched, and the marks
// FTS5 wraps a match in are the only signal. Searching the marked text for the
// public `[[` reads a transcript's own brackets as a highlight — and transcripts
// are full of them, because a bash `[[ -f x ]]` and numpy's `[[1, 2]]` are
// exactly the sort of thing an agent session holds. Whether a column matched is
// the difference between two renderings of the same text instead.
let harness: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
harness = null
})
const BASH = 'run this: if [[ -f /home/me/.aws/credentials ]]; then cat it; fi'
const TOOL = 'zebrafish appears only in the tool output here'
it('shows the column that matched, not the one that happens to contain brackets', async () => {
harness = await openSessionSearchHarness('ss-snippet-marks')
// Session 1's match is in tool output while its user turn holds a bash test
// expression; session 2 is the same match with no brackets anywhere.
addSyntheticSession(harness.db, { id: 1, text: BASH, toolText: TOOL })
addSyntheticSession(harness.db, { id: 2, text: 'run this script please', toolText: TOOL })
const hits = harness.engine.search({ query: 'zebrafish' }).hits
expect(hits).toHaveLength(2)
for (const hit of hits) {
expect(hit.evidence?.snippet).toContain(
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
)
expect(hit.evidence?.snippet).not.toContain('credentials')
}
})
it('falls back to any column for an identifier-only match, brackets or not', async () => {
// `zebra` reaches this row only through the identifier shadow column, which is
// what column -1 exists for. The user turn holds numpy output, so a bracket
// scan would have stopped at it and shown a column with no match in it.
harness = await openSessionSearchHarness('ss-snippet-marks-fallback')
addSyntheticSession(harness.db, {
id: 1,
text: 'numpy printed [[1, 2], [3, 4]] before the call',
toolText: 'zebra-fish-count = 4'
})
const [hit] = harness.engine.search({ query: 'zebra' }).hits
expect(hit?.evidence?.snippet).toContain(
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebra${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
)
expect(hit?.evidence?.snippet).not.toContain('numpy')
})
it('leaves a transcript’s own brackets in the text it shows', async () => {
// The marks are rewritten from private-use code points at the very end, so a
// row that both matches and contains `[[` keeps its own characters.
harness = await openSessionSearchHarness('ss-snippet-marks-literal')
addSyntheticSession(harness.db, { id: 1, text: `zebrafish ${BASH}` })
const [hit] = harness.engine.search({ query: 'zebrafish' }).hits
expect(hit?.evidence?.snippet).toContain(
`${SESSION_SEARCH_SNIPPET_MARK_OPEN}zebrafish${SESSION_SEARCH_SNIPPET_MARK_CLOSE}`
)
expect(hit?.evidence?.snippet).toContain('[[ -f')
})
it('picks by comparison, so a private-use code point in content cannot pose as a mark', async () => {
// The marks are private-use code points, and a transcript may hold one:
// agent output carries Nerd Font glyphs, which live in the same block. So the
// column is chosen by comparing a marked rendering against an unmarked one,
// not by looking for a mark in the text.
harness = await openSessionSearchHarness('ss-snippet-marks-private-use')
addSyntheticSession(harness.db, {
id: 1,
text: 'the \uE000 glyph a font printed here',
toolText: TOOL
})
const [hit] = harness.engine.search({ query: 'zebrafish' }).hits
expect(hit?.evidence?.snippet).toContain('zebrafish')
expect(hit?.evidence?.snippet).not.toContain('glyph')
})
it('truncates on the last real mark, not on a bracket the transcript wrote', async () => {
// Over the character ceiling the snippet is cut, and it must not cut between
// an open mark and its close. Finding that open mark by searching for `[[`
// stops at the transcript's own bracket instead and throws away everything
// after it.
harness = await openSessionSearchHarness('ss-snippet-marks-truncation')
const long = (letter: string): string =>
Array.from({ length: 5 }, () => `${letter.repeat(55)}/tail`).join(' ')
addSyntheticSession(harness.db, {
id: 1,
text: `zebrafish ${long('p')} [[ ${long('q')}`
})
const snippet = harness.engine.search({ query: 'zebrafish' }).hits[0]?.evidence?.snippet ?? ''
expect(snippet).toContain('[[zebrafish]]')
// The cut is the character ceiling, so the text after the transcript's own
// bracket survives up to it.
expect(snippet).toContain('qqqqq')
})
@@ -1,126 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import {
SESSION_SEARCH_SNIPPET_MARK_CLOSE,
SESSION_SEARCH_SNIPPET_MARK_OPEN
} from './session-search-engine-types'
import {
orExpression,
scopedExpression,
type SessionSearchQueryPlan
} from './session-search-query-planner'
import type { SessionSearchScope } from './session-search-engine-types'
// What FTS5 wraps a match in before this module rewrites it to the public
// marks. Private-use code points, and not `[[`, because two different jobs here
// have to tell a mark from content: choosing the column to show, and refusing
// to cut a snippet between an open mark and its close. Transcripts contain
// `[[` — a bash `[[ -f x ]]`, numpy's `[[1, 2]]` — and a mark the content can
// forge makes both of those decisions wrong on real text.
const MARK_OPEN = '\uE000'
const MARK_CLOSE = '\uE001'
const SNIPPET_TOKENS = 12
// Why a ceiling on top of the token count: a transcript chunk can be 8000
// characters with no separator in it, which FTS5 reports as one token, so
// "twelve tokens" is not by itself a bound on what a hit carries.
const SNIPPET_MAX_CHARS = 512
export type SessionSearchSnippet = {
text: string
truncated: boolean
}
export const EMPTY_SNIPPET: SessionSearchSnippet = { text: '', truncated: false }
/**
* The window of one message that shows why it matched.
*
* The expression is the plan's OR form rather than the route's, so a hit found
* through typo repair is marked with the repaired terms it was actually
* retrieved by, and a phrase hit still marks each of its words.
*/
export function sessionSearchSnippet(
db: SyncDatabase,
scope: SessionSearchScope,
rowid: number,
plan: SessionSearchQueryPlan
): SessionSearchSnippet {
// Why: the identifier shadow column is word soup; a hit that also matches in a
// prose column should be shown from there. Column -1 (any column) is the
// fallback for rows that only matched through the shadow column.
//
// The same four for every scope, because the scope is already in the
// expression below. A conversation snippet cannot come out of `tool_text` for
// the reason the search could not: the row has to match
// `{user_text assistant_text}: …` before any of these columns is read, and a
// row that matches under that filter carries its mark in column 0 or 1. A
// second list here would be a guard with nothing left to guard, and the two
// would mask each other's mistakes.
const columns = [0, 1, 2, -1]
// Each column twice: once marked, once with empty marks. Whether a column
// matched is then the difference between two renderings of the same text,
// which content cannot forge — searching the marked one for a mark reads a
// transcript's own `[[` as a highlight and shows a column that matched
// nothing.
const select = columns
.flatMap((column, index) => [
`snippet(messages_fts, ${column}, '${MARK_OPEN}', '${MARK_CLOSE}', '…', ${SNIPPET_TOKENS}) AS c${index}`,
`snippet(messages_fts, ${column}, '', '', '…', ${SNIPPET_TOKENS}) AS p${index}`
])
.join(', ')
try {
// Why the subselect: a bound `rowid = ?` or `rowid IN (?)` next to MATCH is
// silently ignored by the FTS5 planner, which then returns the first match
// in the table. Why the join to `sessions`: retrieval proved this rowid
// belonged to a live session, but a purge can commit between that statement
// and this one, and a message row outlives its session row until the drain
// reaches it. INNER, never LEFT — this is the last read before content is
// returned to a caller.
const row = db
.prepare(
`SELECT ${select} FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? AND messages_fts.rowid IN (SELECT ?)`
)
.get(scopedExpression(scope, orExpression(plan.terms)), rowid) as
| Record<string, string>
| undefined
if (!row) {
return EMPTY_SNIPPET
}
// A snippet with nothing highlighted tells the user nothing; omit it.
const marked = columns
.map((_column, index) => row[`c${index}`])
.find((text, index) => text !== undefined && text !== row[`p${index}`])
return marked === undefined ? EMPTY_SNIPPET : publicMarks(truncateSnippet(marked))
} catch {
return EMPTY_SNIPPET
}
}
/** The internal marks, swapped for the ones a caller sees, once and at the end. */
function publicMarks(snippet: SessionSearchSnippet): SessionSearchSnippet {
return {
...snippet,
text: snippet.text
.replaceAll(MARK_OPEN, SESSION_SEARCH_SNIPPET_MARK_OPEN)
.replaceAll(MARK_CLOSE, SESSION_SEARCH_SNIPPET_MARK_CLOSE)
}
}
/** Cut on a code-point boundary, and never between a mark and its close. */
export function truncateSnippet(text: string): SessionSearchSnippet {
if (text.length <= SNIPPET_MAX_CHARS) {
return { text, truncated: false }
}
const points = [...text]
if (points.length <= SNIPPET_MAX_CHARS) {
return { text, truncated: false }
}
const cut = points.slice(0, SNIPPET_MAX_CHARS).join('')
const opened = cut.lastIndexOf(MARK_OPEN)
// An open mark with no close hands the renderer something it can never close.
const balanced = opened !== -1 && !cut.includes(MARK_CLOSE, opened) ? cut.slice(0, opened) : cut
return { text: balanced, truncated: true }
}
@@ -1,40 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionSearchSourcePresence } from './session-search-engine-types'
/**
* Where each session's source stands, read from the index's own `files` table.
*
* Why not a stat: a search page of 20 hits would be 20 filesystem round trips
* on the query path, and on an SSH or WSL host each one can block for as long
* as the connection takes to answer — the reviewer's F11. The index already
* records what discovery last proved about every file it read, so the query
* path reads that instead of asking the disk again.
*
* The vocabulary is deliberately short of `missing`. A row here means the index
* holds a live file record for the session, which is `present`. No row means
* this read cannot tell whether the source is gone or merely unrecorded, and
* loss of contact is never evidence of absence
* (docs/reference/ssh-execution-boundary.md), so it is `unverifiable`. Proving
* a deletion is the indexer's job and it retires the session's rows outright.
*/
export function sessionSourcePresence(
db: SyncDatabase,
sessionRowIds: readonly number[]
): Map<number, SessionSearchSourcePresence> {
const presence = new Map<number, SessionSearchSourcePresence>(
sessionRowIds.map((id) => [id, 'unverifiable' as const])
)
if (sessionRowIds.length === 0) {
return presence
}
const rows = db
.prepare(
`SELECT DISTINCT session_row_id FROM files
WHERE session_row_id IN (${sessionRowIds.map(() => '?').join(',')})`
)
.all(...sessionRowIds) as { session_row_id: number }[]
for (const row of rows) {
presence.set(row.session_row_id, 'present')
}
return presence
}
@@ -0,0 +1,265 @@
import { chmod, rm, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { SessionSearchIndexer } from './session-search-indexer'
import {
FakeSessionSearchClock,
openSessionSearchIndexerHarness,
writeClaudeTranscript,
type SessionSearchIndexerHarness
} from './session-search-indexer-test-fixture'
/*
* S1-S5: the store is the only memory.
*
* Every question the indexer answers between passes -- what is owed a read,
* what has failed and how often, what it holds and therefore what may have been
* deleted, what to report -- is a row in the `files` table. These tests check
* that from outside the object: a second connection, hand-written SQL, and the
* clock. Two things outlive a pass and are not rows, and both are named here:
* the timer, and one bit per root for the retirement walk's grace.
*/
const INTERVAL_MS = 20_000
const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0
const FIRST = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const SECOND = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
const THIRD = 'cccccccc-dddd-4eee-8fff-000000000000'
let harness: SessionSearchIndexerHarness
let clock: FakeSessionSearchClock
let indexer: SessionSearchIndexer | null
let errors: unknown[]
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
errors = []
clock = new FakeSessionSearchClock()
harness = await openSessionSearchIndexerHarness('ss-memory')
indexer = null
})
afterEach(async () => {
indexer?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await harness.cleanup()
})
function newIndexer(
overrides: Partial<ConstructorParameters<typeof SessionSearchIndexer>[0]> = {}
): SessionSearchIndexer {
indexer = new SessionSearchIndexer({
databasePath: harness.databasePath,
roots: harness.roots,
historyDays: null,
clock,
reconcileIntervalMs: INTERVAL_MS,
onError: (error) => errors.push(error),
...overrides
})
return indexer
}
function transcriptPath(name: string): string {
return join(harness.claudeProjectDir, `${name}.jsonl`)
}
async function nextCycle(): Promise<void> {
clock.advance(INTERVAL_MS)
await indexer?.settled()
}
/** The whole `files` table as a second connection sees it, ordered for comparison. */
function fileTable(): unknown[] {
return harness.read((db: SyncDatabase) =>
db
.prepare(
`SELECT path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id,
state, fail_count, failed_mtime_ms
FROM files ORDER BY path`
)
.all()
)
}
// S1. The status is a query. A counter kept beside the rows is what needs a
// rule about when to reset, and every such rule this feature grew was wrong.
it('S1: reports exactly what a hand-written query over the rows reports', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND)
await newIndexer().start()
const bySql = (): Record<string, number> =>
Object.fromEntries(
(
harness.read((db: SyncDatabase) =>
db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all()
) as { state: string; n: number }[]
).map((row) => [row.state, Number(row.n)])
)
const reported = indexer?.status()
const counted = bySql()
expect(reported?.filesIndexed).toBe(counted.current ?? 0)
expect(reported?.filesDue).toBe(counted.due ?? 0)
expect(reported?.filesFailed).toBe(counted.failed ?? 0)
expect(reported?.filesIndexed).toBe(2)
// And it stays a query: delete a row behind the indexer's back and the very
// next call reports the table, not a number it remembered.
harness.write((db: SyncDatabase) =>
db.prepare('DELETE FROM files WHERE path = ?').run(transcriptPath(FIRST))
)
expect(indexer?.status().filesIndexed).toBe(1)
})
// S2. A deletion is proven by comparing the rows against what discovery
// returned, so the moment it happened does not matter. Every boundary a pass
// has is a moment a file can go.
it('S2: retires a file deleted right after the opening sweep', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND)
await newIndexer().start()
await rm(transcriptPath(FIRST))
await nextCycle()
expect(fileTable()).toHaveLength(1)
})
it('S2: retires a file deleted right after a cycle', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND)
await newIndexer().start()
await nextCycle()
await rm(transcriptPath(FIRST))
await nextCycle()
expect(fileTable()).toHaveLength(1)
})
it('S2: retires a file deleted right after a periodic sweep', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND)
await newIndexer({ fullSweepEveryCycles: 2 }).start()
await nextCycle()
await nextCycle()
// The third pass is the periodic sweep; the file goes the moment it ends.
await nextCycle()
await rm(transcriptPath(FIRST))
await nextCycle()
expect(fileTable()).toHaveLength(1)
})
it('S2: retires a file deleted while a pass was out of time', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['going'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['staying'], SECOND)
await writeClaudeTranscript(transcriptPath(THIRD), ['also staying'], THIRD)
// One transcript a pass: the opening sweep leaves two of the three unread.
clock.costPerNowMs = 1_000
await newIndexer({ passDeadlineMs: 1_000 }).start()
expect(fileTable()).toHaveLength(1)
await rm(transcriptPath(FIRST))
await nextCycle()
await nextCycle()
// Read what it could, and proved the deletion in the same pass it was still
// catching up in: retirement is not what the deadline bounds.
expect((fileTable() as { path: string }[]).map((row) => row.path)).not.toContain(
transcriptPath(FIRST)
)
})
// S3. The stat is the whole retry policy: a file that fails at one stat stops
// being read, and only a change to that stat starts it again.
it.skipIf(!CAN_DENY_READ)(
'S3: stops reading a file that fails three times at one stat',
async () => {
const path = transcriptPath(FIRST)
await writeClaudeTranscript(path, ['behind the wrong mode bits'], FIRST)
await chmod(path, 0o000)
try {
await newIndexer().start()
for (let cycle = 0; cycle < 4; cycle++) {
await nextCycle()
}
const row = harness.read((db: SyncDatabase) =>
db.prepare('SELECT state, fail_count AS failCount FROM files WHERE path = ?').get(path)
) as { state: string; failCount: number }
// Three, not four and not seven: the pass after the third costs nothing.
expect(row).toEqual({ state: 'failed', failCount: 3 })
expect(indexer?.status()).toMatchObject({ filesFailed: 1, phase: 'degraded' })
// Only the stat releases it.
await chmod(path, 0o644)
const later = new Date(Date.now() + 60_000)
await utimes(path, later, later)
await nextCycle()
expect(indexer?.status()).toMatchObject({ filesIndexed: 1, filesFailed: 0 })
} finally {
await chmod(path, 0o644)
}
}
)
// S4. Two passes over an unchanged filesystem leave the table byte for byte as
// they found it. Anything that drifted would be state the rows do not hold.
it('S4: leaves the file table identical across passes with no change on disk', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND)
await newIndexer({ fullSweepEveryCycles: 2 }).start()
const afterSweep = fileTable()
await nextCycle()
expect(fileTable()).toEqual(afterSweep)
await nextCycle()
expect(fileTable()).toEqual(afterSweep)
// Including across the periodic sweep, which reads the same rows again.
await nextCycle()
expect(fileTable()).toEqual(afterSweep)
expect(errors).toEqual([])
})
// S5. Nothing a close interrupts needs repairing: the next instance reads the
// rows as they stand and decides from them alone.
it('S5: leaves the store consistent when a close interrupts a pass', async () => {
await writeClaudeTranscript(transcriptPath(FIRST), ['one'], FIRST)
await writeClaudeTranscript(transcriptPath(SECOND), ['two'], SECOND)
await writeClaudeTranscript(transcriptPath(THIRD), ['three'], THIRD)
newIndexer()
let closed = false
clock.onNow = () => {
if (closed || fileTable().length === 0) {
return
}
closed = true
indexer?.close()
}
await indexer?.start()
await indexer?.settled()
clock.onNow = null
const interrupted = fileTable()
expect(interrupted.length).toBeGreaterThan(0)
expect(interrupted.length).toBeLessThan(3)
expect(errors).toEqual([])
// A new instance over the same database: no repair pass, no recovery, just
// the rows and what they say is owed.
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await newIndexer().start()
expect(indexer?.status()).toMatchObject({ filesIndexed: 3, filesDue: 0, filesFailed: 0 })
})
+130 -69
View File
@@ -13,10 +13,36 @@ import {
import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete'
import { openSessionSearchDatabase } from './session-search-schema'
// A paused store keeps recording what it declined, so the set needs a ceiling.
// Above it the oldest record goes and the drop is counted, because a re-read set
// that silently forgets is worse than one that says it is incomplete.
export const STALE_PATH_LIMIT = 20_000
/**
* What a row still owes a reader.
*
* `current`: the rows match the file at the stat this row records.
* `due`: the index is behind on a span it cannot reach by appending, so the
* next pass must read the file whole.
* `failed`: the last read did not commit; `failCount` and `failedMtimeMs` are
* what stop it being retried for ever.
*/
export type SessionSearchFileState = 'current' | 'due' | 'failed'
/**
* One row of the index's own file table.
*
* This is the indexer's whole memory between passes: what it holds, at what
* stat, and what each row still owes. Nothing it decides is answered from
* anywhere else, which is why a second connection can check its status.
*/
export type SessionSearchFileRow = {
path: string
identity: SessionSearchFileIdentity
mtimeMs: number
sizeBytes: number | null
state: SessionSearchFileState
failCount: number
failedMtimeMs: number | null
}
/** How many rows are in each state; the whole of the indexer's progress report. */
export type SessionSearchStateCounts = { current: number; due: number; failed: number }
/**
* Owns the index database. PR 2 scope: the write half only — the transcript
@@ -27,12 +53,7 @@ export class SessionSearchStore {
private readonly db: SyncDatabase
private readonly writer: SessionSearchIndexWriter
private closed = false
private acceptingWrites = true
private retentionCutoffMs: number | null = null
// Files this index knows it is behind on. Filled by a declined or abandoned
// read; PR 3's indexer drains it. Nothing here schedules the re-read.
private readonly stale = new Map<string, SessionFileCandidate>()
private droppedStalePaths = 0
// One drain at a time. A replace that commits while one is running asks for
// another pass rather than starting a second walk of the same rows.
private draining = false
@@ -104,23 +125,26 @@ export class SessionSearchStore {
return this.db
}
setAcceptingWrites(accept: boolean): void {
this.acceptingWrites = accept
}
/** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */
setRetentionCutoffMs(cutoffMs: number | null): void {
this.retentionCutoffMs = cutoffMs
}
/** Whether this candidate is new enough to be worth holding rows for at all. */
private withinRetention(candidate: SessionFileCandidate): boolean {
return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs
/** The cutoff a caller's own decide step compares a candidate's mtime against. */
get retentionCutoff(): number | null {
return this.retentionCutoffMs
}
/** Whether a write for this candidate may start right now. */
acceptsCandidate(candidate: SessionFileCandidate): boolean {
return !this.closed && this.acceptingWrites && this.withinRetention(candidate)
/**
* Whether this candidate is new enough to hold rows for.
*
* Enforced here as well as in the indexer's decide step, and not only there:
* the consumer observes every read the session list makes, not only the ones
* the index asked for, so a sidebar scan of a transcript outside the window
* would otherwise index rows the next purge deletes again.
*/
private withinRetention(candidate: SessionFileCandidate): boolean {
return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs
}
indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null {
@@ -139,7 +163,7 @@ export class SessionSearchStore {
previousByteOffset: number,
identity?: () => TranscriptSessionIdentity | null
): SessionSearchFileWrite | null {
if (!this.acceptsCandidate(candidate)) {
if (this.closed || !this.withinRetention(candidate)) {
return null
}
try {
@@ -150,10 +174,14 @@ export class SessionSearchStore {
}
}
/**
* A read that landed. Written after the commit rather than inside it: the
* transaction owns the rows and the cursor, and a crash between the two
* leaves a row that says `failed` over content that is in fact current, which
* the next pass fixes by reading a file it did not have to.
*/
writeCommitted(candidate: SessionFileCandidate): void {
// Why: a list scan queues every file the backfill has not reached yet; once
// one lands, a later pass must not re-read the whole queue.
this.stale.delete(candidate.file.path)
this.setFileState(candidate.file.path, 'current')
}
reportWriteFailure(error: unknown): void {
@@ -161,55 +189,89 @@ export class SessionSearchStore {
}
/**
* Records a file whose content the index is behind on, for a later whole
* re-read. Recorded while paused too: a pause is exactly the window in which
* reads are declined, so refusing to remember them would lose every file the
* pause covered.
*/
markStale(candidate: SessionFileCandidate): void {
if (this.closed || !this.withinRetention(candidate)) {
return
}
// Re-inserting moves the path to the end, so the oldest record is the one
// dropped when a long pause overruns the bound.
this.stale.delete(candidate.file.path)
this.stale.set(candidate.file.path, candidate)
while (this.stale.size > STALE_PATH_LIMIT) {
const oldest = this.stale.keys().next()
if (oldest.done) {
break
}
this.stale.delete(oldest.value)
this.droppedStalePaths += 1
}
}
/**
* Files the index knew it was behind on and could not keep a record of. A
* non-zero count means the re-read set is incomplete, so coverage cannot be
* reported as whole until a full pass runs.
*/
get droppedPendingFileCount(): number {
return this.droppedStalePaths
}
/**
* Hands the re-read set to its scheduler and clears it.
* Every row this index holds. The candidate list for retirement and the whole
* of the status, read in one query so that no pass has to carry either.
*
* These paths are behind, not merely dirty: the index declined their last read
* because it covered a span the index never saw. Re-dispatching a scan is not
* enough on its own, because the reader picks `append` from the session list's
* resume point and the consumer will decline again. The caller must pass each
* path to `requestWholeTranscriptRead` first.
* The cursor is deliberately not here: whether a row can be continued is
* `indexedFile`'s question, and one spelling of the half-written sentinel is
* enough.
*/
takeStale(): SessionFileCandidate[] {
const candidates = [...this.stale.values()]
this.stale.clear()
return candidates
files(): SessionSearchFileRow[] {
return (
this.db
.prepare(
`SELECT path, dev, ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes,
state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs
FROM files`
)
.all() as (Omit<SessionSearchFileRow, 'identity'> & {
dev: number | null
ino: number | null
})[]
).map((row) => ({
path: row.path,
identity:
typeof row.dev === 'number' && typeof row.ino === 'number'
? { dev: row.dev, ino: row.ino }
: null,
mtimeMs: row.mtimeMs,
sizeBytes: row.sizeBytes,
state: row.state,
failCount: row.failCount,
failedMtimeMs: row.failedMtimeMs
}))
}
get pendingFileCount(): number {
return this.stale.size
/**
* Moves a row's read state.
*
* `failed` also counts the failure and records the stat it happened at, which
* is what lets the next pass tell "this file has never worked" from "this
* file has changed since it last failed". A path with no row is a no-op: the
* next pass reads it because the index holds nothing for it.
*/
setFileState(path: string, state: SessionSearchFileState, atMtimeMs?: number): void {
try {
if (state === 'failed') {
// Inserted when there is no row, because the common unreadable file is
// one the index never managed to hold: a transcript behind the wrong
// mode bits fails on its very first read, and with nowhere to write the
// count it would be read again on every pass for the life of the
// process. The cursor is zero and there is no session, which is what
// "the index holds nothing for this file" already looks like.
this.db
.prepare(
`INSERT INTO files(path, byte_offset, mtime_ms, state, fail_count, failed_mtime_ms)
VALUES (?, 0, ?, 'failed', 1, ?)
ON CONFLICT(path) DO UPDATE SET
state = 'failed',
fail_count = files.fail_count + 1,
failed_mtime_ms = excluded.failed_mtime_ms`
)
.run(path, atMtimeMs ?? 0, atMtimeMs ?? null)
return
}
this.db
.prepare(
'UPDATE files SET state = ?, fail_count = 0, failed_mtime_ms = NULL WHERE path = ?'
)
.run(state, path)
} catch (error) {
this.onError(error)
}
}
/** Rows per state. The status is this query and the pass's own degraded roots. */
stateCounts(): SessionSearchStateCounts {
const rows = this.db.prepare('SELECT state, count(*) AS n FROM files GROUP BY state').all() as {
state: SessionSearchFileState
n: number
}[]
const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0 }
for (const row of rows) {
counts[row.state] = Number(row.n)
}
return counts
}
/**
@@ -218,7 +280,6 @@ export class SessionSearchStore {
* (docs/reference/ssh-execution-boundary.md).
*/
removeFile(path: string): void {
this.stale.delete(path)
try {
this.writer.removeFile(path)
} catch (error) {
@@ -0,0 +1,56 @@
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { splitOpenCodeSqliteCandidate } from '../ai-vault/session-scanner-opencode-sqlite-paths'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
/**
* A row whose path names a container and an entry inside it rather than a file
* of its own. OpenCode's SQLite sessions are the one shape today
* (`<opencode.db>#<sessionId>`), which is why this reads through that source's
* own splitter rather than reinventing the encoding.
*/
export type SessionSearchSyntheticSource = { container: string; id: string }
export function splitSyntheticSessionSource(path: string): SessionSearchSyntheticSource | null {
const openCode = splitOpenCodeSqliteCandidate(path)
return openCode ? { container: openCode.dbPath, id: openCode.sessionId } : null
}
/**
* Which containers a pass enumerated in full, and every id each of them held.
*
* This is the synthetic equivalent of a directory listing, and it has to meet
* the same bar before the retirement walk may prove anything from it:
*
* - **Exhaustive.** Only a sweep enumerates without a per-agent limit. A cycle
* asks for the newest N, so an id it did not return may simply be the N+1th.
* Callers that are not a census do not build this at all.
* - **Successful.** A container a scan issue names could not be read, and a
* read that failed returns no ids rather than an error the walk can see. A
* named container is left out, so its rows stay unverifiable.
* - **Non-empty.** A container that returned nothing is not evidence that it
* holds nothing: a database whose schema this scanner no longer recognises
* returns an empty list with no error at all, and believing it would retire
* every session in one pass. The cost is one stale row per container whose
* last entry the user deletes, until the container gains an entry or goes.
*/
export function sessionSearchEnumeratedContainers(
candidates: readonly SessionFileCandidate[],
issues: readonly AiVaultScanIssue[]
): Map<string, Set<string>> {
const containers = new Map<string, Set<string>>()
for (const candidate of candidates) {
const synthetic = splitSyntheticSessionSource(candidate.file.path)
if (!synthetic) {
continue
}
const ids = containers.get(synthetic.container) ?? new Set<string>()
ids.add(synthetic.id)
containers.set(synthetic.container, ids)
}
for (const issue of issues) {
if (issue.kind !== 'notice') {
containers.delete(issue.path)
}
}
return containers
}
@@ -1,80 +0,0 @@
import { describe, expect, it } from 'vitest'
import type SyncDatabase from '../sqlite/sync-database'
import { openSessionSearchIndexFile } from './session-search-index-test-fixture'
import { ensureSessionSearchQuerySchema } from './session-search-query-schema'
import { SessionSearchTypoRepair } from './session-search-typo-repair'
/** A session row the planted messages below hang off, so a repair can see them. */
function addSession(db: SyncDatabase, id: number): void {
db.prepare(
`INSERT INTO sessions(id,agent,session_id,file_path,title,resume_command)
VALUES (?, 'claude', ?, '/synthetic/fixture', 'typo fixture', '')`
).run(id, String(id))
}
function addTerm(db: SyncDatabase, sessionRowId: number, term: string): void {
const rowid = db
.prepare("INSERT INTO messages(session_row_id, role) VALUES (?, 'user')")
.run(sessionRowId).lastInsertRowid
db.prepare('INSERT INTO messages_fts(rowid, user_text) VALUES (?, ?)').run(Number(rowid), term)
}
describe('typo repair policy', () => {
it.each([
{ input: 'coalesces', candidate: 'coalesced', copies: 2, exact: true, expected: null },
{ input: 'coalescs', candidate: 'coalesces', copies: 1, exact: false, expected: null },
{ input: 'coalescs', candidate: 'coalesces', copies: 2, exact: false, expected: 'coalesces' },
{ input: 'café', candidate: 'cafe', copies: 1, exact: false, expected: null },
{ input: 'car', candidate: 'cars', copies: 2, exact: false, expected: null },
{ input: 'calm', candidate: 'clam', copies: 2, exact: false, expected: null }
])(
'repairs $input to $expected with $copies postings (exact=$exact)',
async ({ input, candidate, copies, exact, expected }) => {
const index = await openSessionSearchIndexFile('ss-typo-policy')
try {
ensureSessionSearchQuerySchema(index.db)
addSession(index.db, 1)
for (let i = 0; i < copies; i++) {
addTerm(index.db, 1, candidate)
}
if (exact) {
addTerm(index.db, 1, input)
}
expect(new SessionSearchTypoRepair(index.db).correct(input, 'all')).toBe(expected)
} finally {
await index.close()
}
}
)
// A purge cuts a session loose in one transaction and reclaims its rows over
// many, so the vocabulary can still list a term whose only rows nothing can
// reach. Abandoning the prefix at that term would lose a repair the rest of
// the index can already serve.
it('falls through to the best candidate a reader can still reach', async () => {
const index = await openSessionSearchIndexFile('ss-typo-orphaned')
try {
const { db } = index
ensureSessionSearchQuerySchema(db)
addSession(db, 1)
// `coalesces` scores higher against `coalescs` than `coalesced` does, and
// shares its prefix, so only the fall-through can reach the reachable one.
// Session 2 is never created: these rows are what an unfinished purge
// leaves behind, and the vocabulary counts them all the same.
for (const [term, session] of [
['coalesces', 2],
['coalesces', 2],
['coalesced', 1],
['coalesced', 1]
] as const) {
addTerm(db, session, term)
}
expect(db.prepare("SELECT doc FROM messages_vocab WHERE term='coalesces'").get()).toEqual({
doc: 2
})
expect(new SessionSearchTypoRepair(db).correct('coalescs', 'all')).toBe('coalesced')
} finally {
await index.close()
}
})
})
@@ -1,163 +0,0 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionSearchScope } from './session-search-engine-types'
import { quoteFtsTerm, scopedExpression } from './session-search-query-planner'
// Why: a query term with zero postings is usually a typo. The index's own
// vocabulary (fts5vocab) is the dictionary, so repair needs no model and can
// never suggest a word the index does not contain. Measured MRR 0.553 → 0.566.
const MIN_TERM_LENGTH = 4
const MAX_TERM_LENGTH = 40
const LENGTH_SLACK = 2
const MIN_DOC_FREQUENCY = 2
const MIN_SIMILARITY = 0.82
const MAX_CANDIDATES = 4000
// Candidates counted against live rows per prefix before giving up on it. Only
// reached for a term the scope has no posting for, which is the rare case.
const MAX_VISIBILITY_PROBES = 8
// How far a live count walks before it stops caring. It exists to break ties
// between candidates of equal similarity, and the difference between a term in
// sixty-four rows and one in six thousand does not change which is the better
// repair — but reading either in full would.
const MAX_COUNTED_ROWS = 64
// Longest common subsequence length; the indel distance is len(a)+len(b)-2·LCS.
function commonSubsequenceLength(a: string, b: string): number {
let previous = Array.from<number>({ length: b.length + 1 }).fill(0)
let current = Array.from<number>({ length: b.length + 1 }).fill(0)
for (let i = 1; i <= a.length; i += 1) {
for (let j = 1; j <= b.length; j += 1) {
current[j] =
a.charCodeAt(i - 1) === b.charCodeAt(j - 1)
? previous[j - 1] + 1
: Math.max(previous[j], current[j - 1])
}
;[previous, current] = [current, previous]
}
return previous[b.length]
}
/** Normalized indel similarity in [0, 1], the scale rapidfuzz's `fuzz.ratio` uses. */
function similarity(a: string, b: string): number {
const total = a.length + b.length
return total === 0 ? 1 : (2 * commonSubsequenceLength(a, b)) / total
}
/**
* Spelling repair over the index's own vocabulary.
*
* The vocabulary proposes and a scoped count disposes. `messages_vocab` is a
* view over the whole FTS b-tree: it has no column filter, because fts5vocab is
* per table, and it counts rows whose session a purge already cut loose. So
* every decision that reaches the plan — whether a term is already spelled
* right, whether a candidate is eligible, and which of two equally close
* candidates wins — is taken from a `messages_fts MATCH` under the same column
* filter retrieval uses, joined to `sessions`.
*
* That is not tidiness. Reading the vocabulary directly made the repair depend
* on rows the search could never return: tool output suppressed a
* conversation-scope repair and supplied suggestions the scope would never
* show, and retention's orphan drain silently changed which word a query was
* repaired to.
*
* The cost is one bounded count per candidate examined, at most
* `MAX_VISIBILITY_PROBES` per prefix, and only for a term the scope has no
* posting for. See docs/reference/agent-session-search-query-tuning.md.
*/
export class SessionSearchTypoRepair {
private readonly liveRows: ReturnType<SyncDatabase['prepare']>
private readonly candidatesByPrefix: ReturnType<SyncDatabase['prepare']>
constructor(db: SyncDatabase) {
this.liveRows = db.prepare(
`SELECT count(*) AS rows FROM (
SELECT m.id FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? LIMIT ${MAX_COUNTED_ROWS})`
)
// fts5vocab is ordered by term, so a prefix range plus a length band is a
// bounded scan and no sort. Ordered by term rather than by `doc`: the
// ordering decides which candidates survive the limit, and `doc` counts
// rows no reader can see, so the drain reclaiming them moved the cut.
this.candidatesByPrefix = db.prepare(
`SELECT term FROM messages_vocab
WHERE term >= ? AND term < ? AND length(term) BETWEEN ? AND ?
ORDER BY term LIMIT ?`
)
}
/** Live rows carrying this term inside `scope`, counted no further than it matters. */
private countRows(term: string, scope: SessionSearchScope): number {
const row = this.liveRows.get(scopedExpression(scope, quoteFtsTerm(term))) as { rows: number }
return row.rows
}
/** Whether a live row inside `scope` holds this term. */
hasPostings(term: string, scope: SessionSearchScope): boolean {
return this.countRows(term, scope) > 0
}
/** Returns the closest indexed term, or null when `term` exists or nothing is close enough. */
correct(term: string, scope: SessionSearchScope): string | null {
const lowered = term.toLowerCase()
if (lowered.length < MIN_TERM_LENGTH || lowered.length > MAX_TERM_LENGTH) {
return null
}
if (this.hasPostings(lowered, scope)) {
return null
}
// Two-letter prefix first (a typo rarely hits both), then the transposed
// pair, then the bare first letter as the wide fallback.
const prefixes = [lowered.slice(0, 2), lowered[1] + lowered[0], lowered[0]]
for (const prefix of prefixes) {
const best = this.bestVisible(lowered, prefix, scope)
if (best) {
return best
}
}
return null
}
/**
* The closest candidate at `prefix` that this scope can actually answer with.
*
* Ranking is pure CPU, so the walk is bounded rather than the count: the
* closest term can be one the scope never shows, and abandoning the prefix
* there would lose a repair the rest of the index can serve. Ties on
* similarity go to the more common word, which is the same prior the
* vocabulary's `doc` used to supply — counted live here so the answer does
* not move when a purge reclaims rows nothing could reach.
*/
private bestVisible(lowered: string, prefix: string, scope: SessionSearchScope): string | null {
const counted = this.ranked(lowered, prefix)
.slice(0, MAX_VISIBILITY_PROBES)
.map((candidate) => ({ ...candidate, rows: this.countRows(candidate.term, scope) }))
.filter((candidate) => candidate.rows >= MIN_DOC_FREQUENCY)
if (counted.length === 0) {
return null
}
// Already sorted by similarity; a stable sort keeps that and orders the ties.
return counted.sort((left, right) => right.score - left.score || right.rows - left.rows)[0]!
.term
}
/** Candidates similar enough to be a repair, closest first. */
private ranked(lowered: string, prefix: string): { term: string; score: number }[] {
return this.candidates(prefix, lowered.length)
.map((row) => ({ term: row.term, score: similarity(lowered, row.term) }))
.filter((candidate) => candidate.score >= MIN_SIMILARITY)
.sort((left, right) => right.score - left.score || (left.term < right.term ? -1 : 1))
}
private candidates(prefix: string, length: number): { term: string }[] {
const last = prefix.charCodeAt(prefix.length - 1)
const upper = prefix.slice(0, -1) + String.fromCharCode(last + 1)
return this.candidatesByPrefix.all(
prefix,
upper,
Math.max(MIN_TERM_LENGTH - 1, length - LENGTH_SLACK),
length + LENGTH_SLACK,
MAX_CANDIDATES
) as { term: string }[]
}
}
@@ -1,71 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import {
addSyntheticSession,
openSessionSearchHarness,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
// Typo repair used to read `messages_vocab` and probe `messages_fts` with no
// column filter, so tool output decided whether a conversation-scoped query was
// repaired — in both directions. A tool row carrying the misspelling made the
// query look correctly spelled and suppressed the repair; a tool row carrying a
// rare word offered it as the suggestion, naming in `repairedTerms` a string
// from a column the scope will never show.
let harness: SessionSearchHarness | null = null
let control: SessionSearchHarness | null = null
afterEach(async () => {
await harness?.close()
await control?.close()
harness = null
control = null
})
it('repairs a conversation query the same way with or without a tool row', async () => {
harness = await openSessionSearchHarness('ss-typo-scope-suppress')
addSyntheticSession(harness.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 })
// A second session whose tool output happens to contain the misspelling.
addSyntheticSession(harness.db, {
id: 2,
text: 'ran the linter',
toolText: 'warning: unknown symbol resolveterminalpth in build log',
rows: 2,
role: 'assistant'
})
// The same index without that one tool row.
control = await openSessionSearchHarness('ss-typo-scope-control')
addSyntheticSession(control.db, { id: 1, text: 'we changed resolveTerminalPath today', rows: 2 })
addSyntheticSession(control.db, { id: 2, text: 'ran the linter' })
const request = { query: 'resolveterminalpth', scope: 'conversation' } as const
const withTool = harness.engine.search(request)
const clean = control.engine.search(request)
expect(clean.planner.repairedTerms).toEqual(['resolveterminalpath'])
expect(clean.hits.map((hit) => hit.sessionId)).toEqual(['1'])
expect(withTool.planner.repairedTerms).toEqual(clean.planner.repairedTerms)
expect(withTool.hits.map((hit) => hit.sessionId)).toEqual(clean.hits.map((hit) => hit.sessionId))
})
it('never repairs a conversation query onto a word only tool output holds', async () => {
harness = await openSessionSearchHarness('ss-typo-scope-leak')
addSyntheticSession(harness.db, {
id: 1,
text: 'ran the deploy',
toolText: 'AWS_SESSION_TOKEN=quicksilverfox expired',
rows: 2,
role: 'assistant'
})
addSyntheticSession(harness.db, { id: 2, text: 'ordinary prose about nothing' })
const narrowed = harness.engine.search({ query: 'quicksilverfx', scope: 'conversation' })
expect(narrowed.planner.repairedTerms).toBeUndefined()
expect(narrowed.hits).toEqual([])
// The same query over the whole corpus still finds it, which is the scope
// doing its job rather than the repair being broken.
const wide = harness.engine.search({ query: 'quicksilverfx', scope: 'all' })
expect(wide.planner.repairedTerms).toEqual(['quicksilverfox'])
expect(wide.hits.map((hit) => hit.sessionId)).toEqual(['1'])
})
@@ -0,0 +1,87 @@
import type { SessionSearchClock, SessionSearchTimerHandle } from './session-search-clock'
export type SessionSearchWorkLoopOptions = {
clock: SessionSearchClock
intervalMs: number
/** A task that threw for a reason other than its own abort. */
onFailure: (error: unknown) => void
}
/**
* Runs the indexer's passes one at a time, on an interval, until it is closed.
*
* Separate from the indexer because it is the part with no opinion about
* transcripts: a task chain that never overlaps itself, a timer that only ever
* has one pending tick, and a close that cancels both. Arming inside the chain
* rather than beside it is what makes `settled` mean "everything queued so far
* has finished, including the re-arm", which is what a fake-clock test needs.
*/
export class SessionSearchWorkLoop {
private timer: SessionSearchTimerHandle | null = null
private controller: AbortController | null = null
private chain: Promise<void> = Promise.resolve()
private closed = false
constructor(private readonly options: SessionSearchWorkLoopOptions) {}
/** Everything queued so far. Never rejects: a task's failure is reported, not thrown. */
get settled(): Promise<void> {
return this.chain
}
/** Queues `work` behind whatever is running, then re-arms the interval. */
queue(work: (signal: AbortSignal) => Promise<void>, tick: () => void): Promise<void> {
const chained = this.chain
.then(
() => this.run(work),
() => this.run(work)
)
.then(() => this.arm(tick))
this.chain = chained
return chained
}
/**
* Stops the timer, the task in flight and everything queued behind it. Nothing
* queued before this call may run afterwards: that is what lets the indexer
* close its store here and know no pass will reach for it.
*/
close(): void {
this.closed = true
if (this.timer !== null) {
this.options.clock.clearTimeout(this.timer)
this.timer = null
}
this.controller?.abort()
}
private arm(tick: () => void): void {
if (this.closed || this.timer !== null) {
return
}
this.timer = this.options.clock.setTimeout(() => {
this.timer = null
tick()
}, this.options.intervalMs)
}
private async run(work: (signal: AbortSignal) => Promise<void>): Promise<void> {
if (this.closed) {
return
}
const controller = new AbortController()
this.controller = controller
try {
await work(controller.signal)
} catch (error) {
// An aborted task is a close, never a failure.
if (!controller.signal.aborted) {
this.options.onFailure(error)
}
} finally {
if (this.controller === controller) {
this.controller = null
}
}
}
}
@@ -26,6 +26,7 @@ import {
import {
readResumableTranscript,
readWholeTranscript,
requestWholeTranscriptRead,
type TranscriptReadStats
} from './session-transcript-reader'
@@ -104,29 +105,67 @@ export function createSessionParseStats(): SessionParseStats {
export async function parseAgentSessionFileCached(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
stats?: SessionParseStats,
requireRead?: SessionParseReadRequirement
): Promise<AiVaultSession | null> {
// The whole lookup-read-store sequence runs in the lane: a concurrent parse of
// the same path shares this entry's resume point and its message channel.
return inSessionParseFileLane(candidate.file.path, () =>
parseCachedInLane(candidate, platform, stats)
parseCachedInLane(candidate, platform, stats, requireRead)
)
}
/**
* What a caller other than the session list needs out of this parse.
*
* `any`: some bytes must be read. A cursor already at the file's current stat
* is dropped so the reader opens it; one that is merely behind is left alone,
* because an append is a read.
*
* `whole`: the file must be re-read from zero, for a consumer whose own cursor
* covers a span this one does not.
*
* Why it is a parameter and not two calls around this one: the decision reads
* cache state and then changes it, so outside the per-path lane an overlapping
* list parse can store its entry in between and the forced read silently
* degrades to a reuse.
*/
export type SessionParseReadRequirement = 'any' | 'whole'
/**
* True when this cursor already sits at the transcript's current stat, so a
* parse would reuse the cached fold and read no bytes at all.
*/
function sessionParseCacheCoversTranscript(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
): boolean {
const { file } = candidate
const entry = getSessionParseCacheEntry(file.path)
return (
entry !== undefined &&
entry.platform === platform &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
)
}
async function parseCachedInLane(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
stats?: SessionParseStats,
requireRead?: SessionParseReadRequirement
): Promise<AiVaultSession | null> {
const { file } = candidate
if (
requireRead === 'whole' ||
(requireRead === 'any' && sessionParseCacheCoversTranscript(candidate, platform))
) {
requestWholeTranscriptRead(file.path)
}
const entry = getSessionParseCacheEntry(file.path)
const transcriptUnchanged =
entry !== undefined &&
entry.platform === platform &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
if (transcriptUnchanged) {
if (entry !== undefined && sessionParseCacheCoversTranscript(candidate, platform)) {
if (sidecarUnchanged(entry.sidecar, file.sidecar)) {
return reuseCachedSession(candidate, entry, stats)
}
@@ -2,14 +2,14 @@ import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { RpcContext, RpcMethod } from '../rpc/core'
import { eraseRpcMethods, type RpcContext, type RpcMethod } from '../rpc/core'
import { NOTIFICATION_METHODS } from '../rpc/methods/notifications'
import { DeviceRegistry } from '../device-registry'
import { OrcaRuntimeRpcServer } from '../runtime-rpc'
import { OrcaRuntimeService } from '../orca-runtime'
function method(name: string): RpcMethod {
const found = NOTIFICATION_METHODS.find((candidate) => candidate.name === name)
const found = eraseRpcMethods(NOTIFICATION_METHODS).find((candidate) => candidate.name === name)
if (!found || 'stream' in found) {
throw new Error(`${name} is not a one-shot RPC method`)
}
@@ -0,0 +1,114 @@
// The preserved types are the whole point of defineMethod, so they are asserted here: if a name
// widens to `string` or a result to `unknown`, these assertions fail at typecheck, not at runtime.
import { describe, expect, expectTypeOf, it } from 'vitest'
import { z } from 'zod'
import {
buildRegistry,
defineMethod,
defineStreamingMethod,
eraseRpcMethods,
isStreamingMethod,
type RpcContext,
type RpcMethod,
type RpcStreamingMethod
} from './core'
import type { ALL_RPC_METHODS } from './methods'
import { STATUS_METHODS } from './methods/status'
import type { HOST_CAPABILITY_METHODS } from './methods/host-capabilities'
const ProbeParams = z.object({ id: z.string(), count: z.number().optional() })
const probe = defineMethod({
name: 'test.typedProbe',
params: ProbeParams,
handler: (params) => ({ id: params.id, count: params.count ?? 0 })
})
const schemalessProbe = defineMethod({
name: 'test.schemalessProbe',
params: null,
handler: () => ['a', 'b']
})
const streamingProbe = defineStreamingMethod({
name: 'test.streamingProbe',
params: ProbeParams,
handler: async (params, _ctx, emit) => {
emit(params.id)
}
})
type ByName<TMethods, TName extends string> = Extract<TMethods, { name: TName }>
describe('defineMethod preserves the declared contract', () => {
it('keeps the literal method name', () => {
expectTypeOf(probe.name).toEqualTypeOf<'test.typedProbe'>()
expectTypeOf(streamingProbe.name).toEqualTypeOf<'test.streamingProbe'>()
expect(probe.name).toBe('test.typedProbe')
})
it('keeps the producer result type', () => {
expectTypeOf(probe.handler).returns.toEqualTypeOf<{ id: string; count: number }>()
expectTypeOf(schemalessProbe.handler).returns.toEqualTypeOf<string[]>()
})
it('infers parsed params from the schema, and `void` without one', () => {
expectTypeOf(probe.handler)
.parameter(0)
.toEqualTypeOf<{ id: string; count?: number | undefined }>()
expectTypeOf(schemalessProbe.handler).parameter(0).toEqualTypeOf<void>()
expectTypeOf(streamingProbe.handler)
.parameter(0)
.toEqualTypeOf<{ id: string; count?: number | undefined }>()
expectTypeOf(probe.params).toEqualTypeOf<typeof ProbeParams>()
})
it('keeps a registered method addressable by its literal name', () => {
type StatusGet = ByName<(typeof STATUS_METHODS)[number], 'status.get'>
type ListDistros = ByName<(typeof HOST_CAPABILITY_METHODS)[number], 'host.wsl.listDistros'>
expectTypeOf<StatusGet>().not.toBeNever()
expectTypeOf<StatusGet['handler']>().returns.toExtend<{ runtimeId: string }>()
expectTypeOf<ListDistros['handler']>().returns.toEqualTypeOf<Promise<string[]>>()
// The manifest is the erasure boundary's input, so the literal names have to survive it too.
expectTypeOf<ByName<(typeof ALL_RPC_METHODS)[number], 'status.get'>>().not.toBeNever()
})
})
describe('eraseRpcMethods is the registry boundary', () => {
it('erases to the shape the dispatcher calls, keeping the streaming split', () => {
expectTypeOf(eraseRpcMethods([probe])).toEqualTypeOf<readonly RpcMethod[]>()
expectTypeOf(eraseRpcMethods([streamingProbe])).toEqualTypeOf<readonly RpcStreamingMethod[]>()
expectTypeOf(eraseRpcMethods(STATUS_METHODS)).toEqualTypeOf<readonly RpcMethod[]>()
expectTypeOf(eraseRpcMethods([probe])[0]!.handler)
.parameter(0)
.toEqualTypeOf<unknown>()
})
it('returns the same methods, so nothing about the runtime value changes', () => {
const erased = eraseRpcMethods([probe, streamingProbe])
expect(erased[0]).toBe(probe)
expect(erased[1]).toBe(streamingProbe)
})
it('produces methods the registry accepts and the dispatcher can invoke', async () => {
const registry = buildRegistry([probe, streamingProbe, ...STATUS_METHODS])
const registered = registry.get('test.typedProbe')
expect(registered).toBe(probe)
expect(registry.get('status.get')).toBe(STATUS_METHODS[0])
expect(isStreamingMethod(registry.get('test.streamingProbe')!)).toBe(true)
expect(registered && isStreamingMethod(registered)).toBe(false)
// The dispatcher parses params itself and then calls the erased handler with `unknown`.
const parsed: unknown = probe.params.parse({ id: 'a' })
expect(
registered && !isStreamingMethod(registered)
? await registered.handler(parsed, {} as RpcContext)
: undefined
).toEqual({ id: 'a', count: 0 })
})
it('rejects a duplicate name before erasure hides it', () => {
expect(() => buildRegistry([probe, probe])).toThrow('duplicate_rpc_method:test.typedProbe')
})
})
+80 -35
View File
@@ -119,28 +119,27 @@ export type RpcContext = {
) => () => void
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => unknown
export type RpcHandler<TParams, TResult> = (params: TParams, ctx: RpcContext) => TResult
// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: unknown, ctx: RpcContext) => unknown
// Why: a schema-less method takes no params, so its handler must not be able to read the first argument.
type RpcParsedParams<TSchema extends ZodType | null> = TSchema extends ZodType
? TSchema['_output']
: void
// Why: the authored shape — literal name, params schema, and producer result all survive for compile-time contracts.
export type RpcTypedMethod<TName extends string, TSchema extends ZodType | null, TResult> = {
readonly name: TName
readonly params: TSchema
readonly handler: RpcHandler<RpcParsedParams<TSchema>, TResult>
}
type DefineMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineMethod<TSchema extends ZodType | null>(
spec: DefineMethodSpec<TSchema>
): RpcMethod {
export function defineMethod<TName extends string, TSchema extends ZodType | null, TResult>(
spec: RpcTypedMethod<TName, TSchema, TResult>
): RpcTypedMethod<TName, TSchema, TResult> {
return {
name: spec.name,
params: spec.params,
handler: spec.handler as RpcMethod['handler']
handler: spec.handler
}
}
@@ -150,6 +149,53 @@ export type RpcStreamingHandler<TParams> = (
emit: (result: unknown) => void
) => Promise<void>
// Why: emitted values stay `unknown` — the emit callback is an input, so there is no return position to infer them from.
export type RpcTypedStreamingMethod<TName extends string, TSchema extends ZodType | null> = {
readonly name: TName
readonly params: TSchema
readonly stream: true
readonly handler: RpcStreamingHandler<RpcParsedParams<TSchema>>
}
export function defineStreamingMethod<TName extends string, TSchema extends ZodType | null>(
spec: Omit<RpcTypedStreamingMethod<TName, TSchema>, 'stream'>
): RpcTypedStreamingMethod<TName, TSchema> {
return {
name: spec.name,
params: spec.params,
stream: true,
handler: spec.handler
}
}
// Why `never` params: it makes the declaration a supertype of every parsed-params handler, so typed methods
// travel to the registry boundary — and only there get erased — without a cast in each methods module.
export type RpcMethodDeclaration = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: never, ctx: RpcContext) => unknown
}
export type RpcStreamingMethodDeclaration = {
readonly name: string
readonly params: ZodType | null
readonly stream: true
readonly handler: (
params: never,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
}
export type RpcAnyMethodDeclaration = RpcMethodDeclaration | RpcStreamingMethodDeclaration
// Why: RpcMethod is the registry's erased view; the dispatcher parses params itself and hands handlers `unknown`.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: unknown, ctx: RpcContext) => unknown
}
// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path.
export type RpcStreamingMethod = {
readonly name: string
@@ -162,34 +208,33 @@ export type RpcStreamingMethod = {
) => Promise<void>
}
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineStreamingMethod<TSchema extends ZodType | null>(
spec: DefineStreamingMethodSpec<TSchema>
): RpcStreamingMethod {
return {
name: spec.name,
params: spec.params,
stream: true,
handler: spec.handler as RpcStreamingMethod['handler']
}
}
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
// Why the overloads: erasure drops the parsed-params type, not the one-shot/streaming split the dispatcher routes on.
export function eraseRpcMethods(methods: readonly RpcMethodDeclaration[]): readonly RpcMethod[]
export function eraseRpcMethods(
methods: readonly RpcStreamingMethodDeclaration[]
): readonly RpcStreamingMethod[]
export function eraseRpcMethods(
methods: readonly RpcAnyMethodDeclaration[]
): readonly RpcAnyMethod[]
// Why: the one place the parsed-params type is dropped — contravariance makes it uncastable by assignment, and
// the dispatcher only ever calls a handler with an already-parsed `unknown`. Runtime value is untouched.
export function eraseRpcMethods(
methods: readonly RpcAnyMethodDeclaration[]
): readonly RpcAnyMethod[] {
return methods as readonly RpcAnyMethod[]
}
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
return 'stream' in method && method.stream === true
}
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
export function buildRegistry(methods: readonly RpcAnyMethodDeclaration[]): RpcRegistry {
const registry = new Map<string, RpcAnyMethod>()
for (const method of methods) {
for (const method of eraseRpcMethods(methods)) {
if (registry.has(method.name)) {
throw new Error(`duplicate_rpc_method:${method.name}`)
}
@@ -1,7 +1,7 @@
import { compile, type ZodType } from 'zod'
import {
formatZodError,
type RpcAnyMethod,
type RpcAnyMethodDeclaration,
type RpcEnvelopeMeta,
type RpcRequest,
type RpcResponse
@@ -12,7 +12,7 @@ const compiledParams = new WeakMap<ZodType, ZodType>()
export function parseRpcRequestParams(
request: RpcRequest,
method: RpcAnyMethod,
method: RpcAnyMethodDeclaration,
meta: RpcEnvelopeMeta
): { value: unknown; error?: undefined } | { value?: undefined; error: RpcResponse } {
if (method.params === null) {
+5 -2
View File
@@ -1,7 +1,7 @@
import {
buildRegistry,
isStreamingMethod,
type RpcAnyMethod,
type RpcAnyMethodDeclaration,
type RpcEnvelopeMeta,
type RpcRegistry,
type RpcRequest,
@@ -24,7 +24,10 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing'
import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher'
import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation'
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
export type DispatcherOptions = {
runtime: OrcaRuntimeService
methods?: readonly RpcAnyMethodDeclaration[]
}
type DispatchCallOptions = RpcDispatchStreamingOptions
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod } from '../core'
import { eraseRpcMethods, isStreamingMethod } from '../core'
import { ACCOUNT_METHODS } from './accounts'
function method(name: string) {
const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name)
const found = eraseRpcMethods(ACCOUNT_METHODS).find((candidate) => candidate.name === name)
if (!found) {
throw new Error(`Missing method ${name}`)
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineMethod, defineStreamingMethod } from '../core'
import {
AccountsUnsubscribeParams,
AddClaudeFromConfigDirParams,
@@ -24,7 +24,7 @@ let accountsSubscriptionSeq = 0
// captures an already-authenticated CLAUDE_CONFIG_DIR (no PTY) so the local
// `orca account add` CLI can register accounts on a headless host; it is gated
// to the local runtime connection, never a mobile device token. See #1438.
export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
export const ACCOUNT_METHODS = [
defineMethod({
name: 'accounts.list',
params: ListAccountsParams,
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod, type RpcContext } from '../core'
import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core'
const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({
installForRuntimeHomeSerializedMock: vi.fn(),
@@ -23,7 +23,7 @@ const RUNTIME_HOME =
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
function prepareMethod() {
const method = AGENT_HOOK_METHODS.find(
const method = eraseRpcMethods(AGENT_HOOK_METHODS).find(
(candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane'
)
if (!method || isStreamingMethod(method)) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params'
export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [
export const AGENT_HOOK_METHODS = [
defineMethod({
name: 'agentHooks.prepareCodexForWslPane',
params: PrepareCodexForWslPaneParams,
@@ -10,7 +10,7 @@ import {
parseAgentSessionOperationTimestamp
} from '../../../../shared/agent-session-host-authority'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { defineMethod, type RpcAnyMethod } from '../core'
import { defineMethod } from '../core'
import {
CreateAgentSessionParams,
EnsureAgentSessionParams
@@ -58,7 +58,7 @@ function assertOperationTimestampWithinFutureSkew(clientOperationId: string): vo
}
}
export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [
export const AGENT_SESSION_METHODS = [
defineMethod({
name: 'terminal.ensureAgentSession',
params: EnsureAgentSessionParams,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
@@ -15,7 +15,7 @@ import {
} from '../../../../shared/rpc-contract/ai-vault-params'
export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams }
export const AI_VAULT_METHODS: RpcMethod[] = [
export const AI_VAULT_METHODS = [
defineMethod({
name: 'aiVault.resolveSessionTitles',
params: AiVaultSessionTitlesParams,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, type RpcAnyMethod } from '../core'
import { defineMethod } from '../core'
import {
ArtifactsDeleteParams,
ListOptions,
@@ -6,7 +6,7 @@ import {
WriteRequest
} from '../../../../shared/rpc-contract/artifacts-params'
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
export const ARTIFACT_METHODS = [
defineMethod({
name: 'artifacts.list',
params: ListOptions,
@@ -4,14 +4,14 @@
* current callers also receive owner metadata.
*/
import { describe, expect, it, vi } from 'vitest'
import type { RpcContext, RpcRequest } from '../core'
import { eraseRpcMethods, type RpcContext, type RpcRequest } from '../core'
import { RpcDispatcher } from '../dispatcher'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { AUTOMATION_METHODS } from './automations'
import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
function method(name: string) {
const found = AUTOMATION_METHODS.find((entry) => entry.name === name)
const found = eraseRpcMethods(AUTOMATION_METHODS).find((entry) => entry.name === name)
if (!found?.params) {
throw new Error(`missing method ${name}`)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition'
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import { defineMethod, type RpcContext } from '../core'
import {
AutomationCreate,
AutomationId,
@@ -25,7 +25,7 @@ function mutationOwner(
return context.runtime.automationOwnerPrecondition(id) ?? undefined
}
export const AUTOMATION_METHODS: RpcMethod[] = [
export const AUTOMATION_METHODS = [
defineMethod({
name: 'automation.list',
params: AutomationList,
@@ -7,7 +7,7 @@ import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/proto
import { getBrowserClientDownloadTransferStore } from '../../browser-client-download-transfer-store'
import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance'
import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry'
import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core'
import { defineMethod, type RpcContext } from '../core'
type FileChannelAuthorityParams = {
browserHostClientId: string
@@ -64,7 +64,7 @@ function requireFileChannelPage(
return page
}
export const BROWSER_CLIENT_FILE_CHANNEL_METHODS: RpcAnyMethod[] = [
export const BROWSER_CLIENT_FILE_CHANNEL_METHODS = [
defineMethod({
name: 'browser.clientHost.fileChannel.read',
params: BrowserClientFileChannelReadParams,
@@ -14,9 +14,9 @@ import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-regist
import { adoptRuntimeBrowserClientPagesFromInventory } from '../../runtime-browser-client-page-adoption'
import { recoverUnavailableRuntimeBrowserClientPages } from '../../runtime-browser-client-page-recovery'
import { releaseRuntimeBrowserClientPageRecord } from '../../runtime-browser-client-page-release'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineMethod, defineStreamingMethod } from '../core'
export const BROWSER_CLIENT_HOST_METHODS: RpcAnyMethod[] = [
export const BROWSER_CLIENT_HOST_METHODS = [
defineStreamingMethod({
name: 'browser.clientHost.attach',
params: BrowserClientHostAttachParams,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { BrowserTarget } from '../schemas'
import {
Check,
@@ -35,7 +35,7 @@ import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-crea
import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods'
import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params'
export const BROWSER_CORE_METHODS: RpcMethod[] = [
export const BROWSER_CORE_METHODS = [
defineMethod({
name: 'browser.snapshot',
params: BrowserTarget,
@@ -1,4 +1,4 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation'
import { BrowserTarget } from '../schemas'
import {
@@ -23,7 +23,7 @@ import {
} from './browser-schemas'
import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-params'
export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
export const BROWSER_EXTRA_METHODS = [
defineMethod({
name: 'browser.cookie.get',
params: CookieGet,
@@ -12,14 +12,14 @@ import {
BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance'
import { defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineStreamingMethod } from '../core'
const outboundMemoryBudgets = new BrowserNetworkTunnelOutboundMemoryBudgetRegistry()
export function createBrowserNetworkTunnelMethods(
memoryBudgets: BrowserNetworkTunnelOutboundMemoryBudgetRegistry = outboundMemoryBudgets,
resolveExecutionRoute: BrowserNetworkExecutionRouteResolver = resolveBrowserNetworkExecutionRoute
): RpcAnyMethod[] {
) {
return [
defineStreamingMethod({
name: 'network.browserTunnel',
@@ -1,11 +1,11 @@
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineMethod, defineStreamingMethod } from '../core'
import { Screencast } from './browser-schemas'
import { BrowserError } from '../../../browser/browser-error'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types'
import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory'
import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-screencast-params'
export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [
export const BROWSER_SCREENCAST_METHODS = [
defineStreamingMethod({
name: 'browser.screencast',
params: Screencast,
@@ -1,8 +1,8 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation'
import { Fill, KeyboardInsert, Type } from './browser-schemas'
export const BROWSER_TEXT_METHODS: RpcMethod[] = [
export const BROWSER_TEXT_METHODS = [
defineMethod({
name: 'browser.fill',
params: Fill,
@@ -1,11 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core'
import {
eraseRpcMethods,
isStreamingMethod,
type RpcContext,
type RpcStreamingMethod
} from '../core'
// Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it.
import { ALL_RPC_METHODS } from './index'
const subscribeMethod = ALL_RPC_METHODS.find(
const subscribeMethod = eraseRpcMethods(ALL_RPC_METHODS).find(
(method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method)
) as RpcStreamingMethod
@@ -1,11 +1,11 @@
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry'
import { getPublicSshState } from '../../public-ssh-state'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineMethod, defineStreamingMethod } from '../core'
import { ClientEventsUnsubscribeParams } from '../../../../shared/rpc-contract/client-events-params'
let clientEventSubscriptionSeq = 0
export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
export const CLIENT_EVENT_METHODS = [
defineStreamingMethod({
name: 'runtime.clientEvents.subscribe',
params: null,
+2 -2
View File
@@ -1,6 +1,6 @@
import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import {
NativeChatSessionOptionsMutation,
PRBotAuthorOverrideUpdate,
@@ -12,7 +12,7 @@ import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas'
import { TerminalQuickCommandsUpdate } from './terminal-quick-command-rpc-schema'
export const CLIENT_UI_METHODS: RpcMethod[] = [
export const CLIENT_UI_METHODS = [
defineMethod({
name: 'settings.get',
params: null,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import { defineMethod, type RpcContext } from '../core'
import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file'
import { randomUUID } from 'node:crypto'
import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance'
@@ -95,7 +95,7 @@ function assertValidBase64Content(value: string): void {
}
}
export const CLIPBOARD_METHODS: RpcMethod[] = [
export const CLIPBOARD_METHODS = [
defineMethod({
name: 'clipboard.saveImageAsTempFile',
params: SaveImageAsTempFile,
@@ -27,6 +27,7 @@ vi.mock('../../../computer/macos-computer-use-permissions', () => ({
}))
import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer'
import { eraseRpcMethods } from '../core'
describe('computer action RPC methods', () => {
beforeEach(() => {
@@ -269,7 +270,7 @@ describe('computer action RPC methods', () => {
})
function findMethod(name: string) {
const method = COMPUTER_METHODS.find((candidate) => candidate.name === name)
const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name)
if (!method) {
throw new Error(`missing method ${name}`)
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildRegistry } from '../core'
import { eraseRpcMethods, buildRegistry } from '../core'
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../../../shared/clipboard-text'
const computerMocks = vi.hoisted(() => ({
@@ -249,7 +249,7 @@ describe('computer RPC methods', () => {
})
function findMethod(name: string) {
const method = COMPUTER_METHODS.find((candidate) => candidate.name === name)
const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name)
if (!method) {
throw new Error(`missing method ${name}`)
}
+2 -2
View File
@@ -6,7 +6,7 @@ import {
callComputerSidecarSnapshot,
resetComputerSidecarForTest
} from '../../../computer/sidecar-client'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import {
Click,
ComputerObserveTarget,
@@ -31,7 +31,7 @@ export function resetComputerSessionsForTest(): void {
resetComputerSidecarForTest()
}
export const COMPUTER_METHODS: RpcMethod[] = [
export const COMPUTER_METHODS = [
defineMethod({
name: 'computer.capabilities',
params: ComputerCapabilitiesParams,
+2 -2
View File
@@ -1,6 +1,6 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
export const DIAGNOSTICS_METHODS: RpcMethod[] = [
export const DIAGNOSTICS_METHODS = [
defineMethod({
name: 'diagnostics.memory',
params: null,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import path from 'node:path'
import { z } from 'zod'
import {
@@ -32,7 +32,7 @@ const InstallParams = z.object({
worktree: z.string().optional()
})
export const EMULATOR_METHODS: RpcMethod[] = [
export const EMULATOR_METHODS = [
defineMethod({
name: 'emulator.list',
params: ListParams,
@@ -1,4 +1,4 @@
import { defineMethod, type RpcAnyMethod } from '../core'
import { defineMethod } from '../core'
import {
FileCommitUpload,
FileCopy,
@@ -33,7 +33,7 @@ function sshMutationArguments(
]
}
export const FILE_MUTATION_METHODS: RpcAnyMethod[] = [
export const FILE_MUTATION_METHODS = [
defineMethod({
name: 'files.write',
params: FileWrite,
@@ -1,11 +1,11 @@
import { defineMethod, type RpcAnyMethod } from '../core'
import { defineMethod } from '../core'
import { remoteFileContentBudget } from './files-remote-content-budget'
import {
TerminalArtifactFile,
TerminalArtifactFileWrite
} from '../../../../shared/rpc-contract/files-terminal-artifact-params'
export const FILE_TERMINAL_ARTIFACT_METHODS: RpcAnyMethod[] = [
export const FILE_TERMINAL_ARTIFACT_METHODS = [
defineMethod({
name: 'files.readTerminalArtifact',
params: TerminalArtifactFile,
+2 -2
View File
@@ -1,4 +1,4 @@
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { defineMethod, defineStreamingMethod } from '../core'
import { runFileWatchStream } from './file-watch-stream-lifecycle'
import { FILE_MUTATION_METHODS } from './files-mutation-methods'
import { remoteFileContentBudget } from './files-remote-content-budget'
@@ -21,7 +21,7 @@ import {
let filesWatchSubscriptionSeq = 0
export const FILE_METHODS: RpcAnyMethod[] = [
export const FILE_METHODS = [
defineMethod({
name: 'files.list',
params: WorktreeSelector,
@@ -1,4 +1,4 @@
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod } from '../core'
import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context'
import {
FolderWorkspaceCreate,
@@ -7,7 +7,7 @@ import {
FolderWorkspaceUpdate
} from '../../../../shared/rpc-contract/folder-workspace-params'
export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [
export const FOLDER_WORKSPACE_METHODS = [
defineMethod({
name: 'folderWorkspace.list',
params: null,

Some files were not shown because too many files have changed in this diff Show More