From 366118be0f9ad9dd5b1ed67967e3fa2feddfcbfa Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 15 Sep 2026 22:04:07 -0400 Subject: [PATCH] feat(session-search): report how many messages an index holds The status contract gains an optional messagesIndexed, read from the store beside the file-state counts and cached the same way, so a settings row can say what is searchable rather than how many files were opened. Optional on the wire: a paired server that predates the field degrades to a session count. --- .../session-search-indexer.test.ts | 11 +++++++++ .../ai-vault-search/session-search-indexer.ts | 6 ++++- .../session-search-service.test.ts | 3 ++- .../ai-vault-search/session-search-store.ts | 23 +++++++++++++++---- src/shared/ai-vault-search-contract.ts | 2 ++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/main/ai-vault-search/session-search-indexer.test.ts b/src/main/ai-vault-search/session-search-indexer.test.ts index 5985628ba2d..d2a744b1004 100644 --- a/src/main/ai-vault-search/session-search-indexer.test.ts +++ b/src/main/ai-vault-search/session-search-indexer.test.ts @@ -61,6 +61,14 @@ function newIndexer( return indexer } +/** What the index holds, counted the way `status()` counts it. */ +function indexedMessageCount(): number { + const row = harness.read((db: SyncDatabase) => + db.prepare('SELECT count(*) AS n FROM messages').get() + ) + return row && typeof row === 'object' && 'n' in row && typeof row.n === 'number' ? row.n : -1 +} + /** Sessions a published-view read returns for one term, the only legal shape. */ function sessionsMatching(term: string): string[] { return harness.read((db: SyncDatabase) => @@ -256,6 +264,9 @@ it('resumes after close and reopen without re-reading what it already indexed', // `filesIndexed` is the count of rows the index holds at their current stat, // so it stays 2. That nothing was opened again is the read loop's own test. expect(reopened.status()).toMatchObject({ filesIndexed: 2, filesDue: 0 }) + // The same number the pane shows as "messages searchable", read from the rows rather than counted as they land. + expect(indexedMessageCount()).toBeGreaterThan(0) + expect(reopened.status().messagesIndexed).toBe(indexedMessageCount()) expect( harness.read((db: SyncDatabase) => db.prepare('SELECT count(*) AS n FROM messages').get()) ).toEqual(indexedRows) diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts index d70d3718f9c..24ad67d2a15 100644 --- a/src/main/ai-vault-search/session-search-indexer.ts +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -36,6 +36,8 @@ export type SessionSearchIndexStatus = { filesDue: number /** Rows whose last read did not commit. */ filesFailed: number + /** Messages the index holds across every indexed row. */ + messagesIndexed: number degradedRoots: SessionSearchDegradedRoot[] lastReconcileAt: number | null /** When a whole-machine sweep last finished; null until one has. */ @@ -187,13 +189,15 @@ export class SessionSearchIndexer { const settled = (this.closed ? this.lastCounts : this.readCounts()) ?? { current: 0, due: 0, - failed: 0 + failed: 0, + messages: 0 } return { phase: this.phase(settled), filesIndexed: settled.current, filesDue: settled.due, filesFailed: settled.failed, + messagesIndexed: settled.messages, degradedRoots: this.degradedRoots.map((root) => ({ ...root })), lastReconcileAt: this.lastReconcileAt, lastSweepCompletedAt: this.lastSweepCompletedAt diff --git a/src/main/ai-vault-search/session-search-service.test.ts b/src/main/ai-vault-search/session-search-service.test.ts index 8d990bcda5c..773af34ba83 100644 --- a/src/main/ai-vault-search/session-search-service.test.ts +++ b/src/main/ai-vault-search/session-search-service.test.ts @@ -24,7 +24,8 @@ async function fixture() { // degradedRoots is re-stated because the contract type leaves `root` optional // for relay redaction, while the indexer always names the root it degraded. const indexer = { - status: () => ({ ...status, degradedRoots: [] }), + // messagesIndexed is optional on the wire and required of an indexer, which has read the rows. + status: () => ({ ...status, messagesIndexed: 0, degradedRoots: [] }), reconcile: vi.fn(async () => {}) } const service = createSessionSearchService({ engine: harness.engine, indexer }) diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 435fa16ce8a..7aa4a94900a 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -41,8 +41,13 @@ export type SessionSearchFileRow = { 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 } +/** How many rows are in each state, plus the indexed message total; the whole of the indexer's progress report. */ +export type SessionSearchStateCounts = { + current: number + due: number + failed: number + messages: number +} /** * Owns the index database. PR 2 scope: the write half only — the transcript @@ -266,19 +271,29 @@ export class SessionSearchStore { } } - /** Rows per state. The status is this query and the pass's own degraded roots. */ + /** Rows per state and indexed messages. The status is these queries 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 } + const counts: SessionSearchStateCounts = { current: 0, due: 0, failed: 0, messages: 0 } for (const row of rows) { counts[row.state] = Number(row.n) } + counts.messages = this.messageCount() return counts } + /** Messages the index holds. Read with the file states so both describe one moment. */ + private messageCount(): number { + const row: unknown = this.db.prepare('SELECT count(*) AS n FROM messages').get() + if (row && typeof row === 'object' && 'n' in row && typeof row.n === 'number') { + return row.n + } + return 0 + } + /** * Drops a source's rows. Only a proven deletion may call this: an unreadable * source is `unverifiable`, not `missing`, and keeps its rows diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts index dcdbfd8771d..c745aea8532 100644 --- a/src/shared/ai-vault-search-contract.ts +++ b/src/shared/ai-vault-search-contract.ts @@ -111,6 +111,8 @@ export const AiVaultSearchStatusSchema = z.object({ filesIndexed: z.number().int().nonnegative(), filesDue: z.number().int().nonnegative(), filesFailed: z.number().int().nonnegative(), + // Optional: a host that predates this field degrades to a session count only. + messagesIndexed: z.number().int().nonnegative().optional(), // `root` is a host path, withheld over the relay; the array length is the count. degradedRoots: z.array(z.object({ root: z.string().optional(), reason: z.string() })), lastReconcileAt: z.number().nullable(),