Session History: the Workspace tab stops re-reading every transcript to find its sessions (#30469)

* perf(session-history): remember each transcripts folder so Workspace scans stop re-reading files

* refactor(session-history): keep the scope cwd table in its own module

* test(session-history): type the hoisted read log without an assertion

* test(session-history): type the collected session folders

* chore(wsl-move): list the parse cache scope_cwds table

* Keep scoped session selection current with cache authority

* Consolidate scope cache imports in regression test

* Run scope cache SQLite regression tests under Node

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
Brennan Benson
2026-10-19 01:25:58 -07:00
committed by GitHub
co-authored by OrcaWin Orca Worker
parent 2db7d5aac4
commit da58ee0875
18 changed files with 940 additions and 149 deletions
@@ -19,6 +19,8 @@ export const NODE_RUNTIME_INCLUDE = [
'src/main/ai-vault/session-parse-cache-large-scan.test.ts',
'src/main/ai-vault/session-parse-cache-metadata-checkpoint.test.ts',
'src/main/ai-vault/session-parse-cache-persistence.test.ts',
'src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts',
'src/main/ai-vault/session-scanner-scope-walk.test.ts',
// Electron main bridges streams with Node's implementation.
'src/main/host/electron-http-client.test.ts',
'src/main/daemon/daemon-init-provider-installation.test.ts',
@@ -9,8 +9,17 @@ import {
removeVersionedCacheDatabase
} from '../sqlite/versioned-cache-database'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type {
PersistedSessionParseCacheEntry,
SessionScopeCwdLookup,
SessionScopeCwdStamp
} from './session-parse-cache-store'
import {
ensureScopeCwdsTable,
readScopeCwdRows,
writeScopeCwdRows
} from './session-scope-cwds-table'
import { parsePersistedSidecar } from './session-sidecar-stat'
// Bump when the row layout or cached session semantics change; a mismatched
// database is dropped whole.
@@ -68,6 +77,21 @@ export class SessionParseCacheDatabase {
}, undefined)
}
/** Each path's parse-row cwd and stored scope cwd, without decoding whole sessions. */
readScopeCwds(paths: readonly string[]) {
const parsed = this.run((db) => readScopeCwdRows(db, paths, 'parsed'), undefined)
const read = this.run((db) => readScopeCwdRows(db, paths, 'read'), undefined)
const found = parsed ?? new Map<string, SessionScopeCwdLookup>()
for (const [path, row] of read ?? []) {
found.set(path, { ...found.get(path), ...row })
}
return { found, parsed: parsed !== undefined, read: read !== undefined }
}
writeScopeCwds(rows: Iterable<[string, SessionScopeCwdStamp]>, seenAt: number): void {
this.run((db) => writeScopeCwdRows(db, rows, seenAt), undefined)
}
/**
* One transaction; a caller chunks large writes so the event loop gets turns
* between them. `keepExisting` leaves rows already present untouched.
@@ -105,7 +129,10 @@ export class SessionParseCacheDatabase {
}
delete(path: string): void {
this.run((db) => db.prepare('DELETE FROM rows WHERE path = ?').run(path), undefined)
this.run((db) => {
db.prepare('DELETE FROM rows WHERE path = ?').run(path)
db.prepare('DELETE FROM scope_cwds WHERE path = ?').run(path)
}, undefined)
}
/** Oldest first, so repeated calls work through every stale row outside `excludeGlob`. */
@@ -114,7 +141,8 @@ export class SessionParseCacheDatabase {
(db) =>
db
.prepare(
'SELECT path FROM rows WHERE seen_at < ? AND path NOT GLOB ? ORDER BY seen_at LIMIT ?'
`SELECT path FROM (SELECT path, seen_at FROM rows UNION ALL SELECT path, seen_at FROM scope_cwds)
WHERE seen_at < ? AND path NOT GLOB ? GROUP BY path ORDER BY MIN(seen_at) LIMIT ?`
)
.all(cutoff, excludeGlob, limit)
.flatMap((row) => (typeof row.path === 'string' ? [row.path] : [])),
@@ -126,18 +154,23 @@ export class SessionParseCacheDatabase {
deleteSeenBefore(cutoff: number, glob: string): boolean {
return this.run(
(db) =>
Number(
db.prepare('DELETE FROM rows WHERE seen_at < ? AND path GLOB ?').run(cutoff, glob).changes
) > 0,
['rows', 'scope_cwds']
.map((table) =>
Number(
db.prepare(`DELETE FROM ${table} WHERE seen_at < ? AND path GLOB ?`).run(cutoff, glob)
.changes
)
)
.some((changes) => changes > 0),
false
)
}
markSeen(path: string, seenAt: number): void {
this.run(
(db) => db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path),
undefined
)
this.run((db) => {
db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path)
db.prepare('UPDATE scope_cwds SET seen_at = ? WHERE path = ?').run(seenAt, path)
}, undefined)
}
/** Hands freed pages back to the file system after deletes. */
@@ -189,7 +222,8 @@ function openDatabase(path: string): SyncDatabase {
version: SESSION_PARSE_CACHE_SCHEMA_VERSION,
sql: SCHEMA_SQL,
directoryMode: PRIVATE_DIRECTORY_MODE,
pageSize: PAGE_SIZE
pageSize: PAGE_SIZE,
afterSchema: ensureScopeCwdsTable
})
hardenSqliteDatabaseFiles(path)
return db
@@ -233,19 +267,3 @@ export function parsePersistedSessionParseCacheRow(
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
// of the rows that have a sibling and nothing at all for the rest.
function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
if (value === 'none' || value === 'unknown') {
return value
}
if (!isRecord(value)) {
return undefined
}
const { path, mtimeMs, sizeBytes } = value
return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number'
? { path, mtimeMs, sizeBytes }
: undefined
}
@@ -11,8 +11,11 @@ import { splitOpenCodeSqliteCandidate } from '../../shared/opencode-sqlite-sessi
import { SessionParseCacheDatabase } from './session-parse-cache-database'
import { importLegacySessionParseCacheJson } from './session-parse-cache-legacy-json'
import {
scopeCwdStampOf,
setSessionParseCacheColdTier,
type PersistedSessionParseCacheEntry
type PersistedSessionParseCacheEntry,
type SessionScopeCwdRead,
type SessionScopeCwdStamp
} from './session-parse-cache-store'
const WRITE_DEBOUNCE_MS = 1_500
@@ -41,6 +44,7 @@ let owner = false
// undefined: not opened yet; null: unavailable for this process's life.
let database: SessionParseCacheDatabase | null | undefined
const pendingWrites = new Map<string, PersistedSessionParseCacheEntry>()
const pendingScopeCwds = new Map<string, SessionScopeCwdStamp>()
let writeTimer: NodeJS.Timeout | null = null
let pendingSince: number | null = null
@@ -55,7 +59,13 @@ export function configureSessionParseCachePersistence(
export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void {
configureSessionParseCachePersistence(next)
owner = true
setSessionParseCacheColdTier({ read: readRow, stored: queueWrite, deleted: deleteRow })
setSessionParseCacheColdTier({
read: readRow,
stored: queueWrite,
deleted: deleteRow,
readScopeCwds,
storedScopeCwd: queueScopeCwdWrite
})
}
export function getSessionParseCachePersistenceOptions(): SessionParseCachePersistenceOptions | null {
@@ -65,6 +75,7 @@ export function getSessionParseCachePersistenceOptions(): SessionParseCachePersi
export function resetSessionParseCachePersistenceForTests(): void {
clearWriteTimer()
pendingWrites.clear()
pendingScopeCwds.clear()
database?.close()
database = undefined
options = null
@@ -89,6 +100,16 @@ export async function flushSessionParseCachePersist(): Promise<void> {
await yieldToEventLoop()
}
}
while (pendingScopeCwds.size > 0) {
const chunk = [...pendingScopeCwds].slice(0, WRITE_CHUNK_ROWS)
for (const [path] of chunk) {
pendingScopeCwds.delete(path)
}
openedDatabase()?.writeScopeCwds(chunk, Date.now())
if (pendingScopeCwds.size > 0) {
await yieldToEventLoop()
}
}
}
// Opened on first need, so Electron main (which never looks rows up) never opens it.
@@ -151,9 +172,40 @@ function readRow(path: string): PersistedSessionParseCacheEntry | undefined {
return pendingWrites.get(path) ?? openedDatabase()?.read(path)
}
function readScopeCwds(paths: readonly string[]): SessionScopeCwdRead {
const result: SessionScopeCwdRead = openedDatabase()?.readScopeCwds(paths) ?? {
found: new Map(),
parsed: false,
read: false
}
const found = result.found
for (const path of paths) {
const row = pendingWrites.get(path)
const read = pendingScopeCwds.get(path)
if (row !== undefined || read !== undefined) {
found.set(path, {
...found.get(path),
...(row === undefined ? {} : { parsed: scopeCwdStampOf(row) }),
...(read === undefined ? {} : { read })
})
}
}
return result
}
function queueScopeCwdWrite(path: string, stamp: SessionScopeCwdStamp): void {
pendingScopeCwds.delete(path)
pendingScopeCwds.set(path, stamp)
scheduleFlush()
}
function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void {
pendingWrites.delete(path)
pendingWrites.set(path, entry)
scheduleFlush()
}
function scheduleFlush(): void {
const now = Date.now()
pendingSince ??= now
if (writeTimer) {
@@ -169,6 +221,7 @@ function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void
// Immediate, so a queued write or a later lookup can't bring the row back.
function deleteRow(path: string): void {
pendingWrites.delete(path)
pendingScopeCwds.delete(path)
openedDatabase()?.delete(path)
}
+108 -2
View File
@@ -54,12 +54,29 @@ export type PersistedSessionParseCacheEntry = Omit<
'resume' | 'foldSession' | 'skippedRecords'
>
// A transcript version's cwd for scope membership: null means the version is
// known to have none, so no scope holds it.
export type SessionScopeCwdStamp = Pick<
PersistedSessionParseCacheEntry,
'mtimeMs' | 'sizeBytes' | 'platform' | 'sidecar'
> & { cwd: string | null }
// `parsed` (from a parse row) outranks `read` (learned without a parse).
export type SessionScopeCwdLookup = { parsed?: SessionScopeCwdStamp; read?: SessionScopeCwdStamp }
export type SessionScopeCwdRead = {
found: Map<string, SessionScopeCwdLookup>
parsed: boolean
read: boolean
}
// The database behind this map (session-parse-cache-persistence.ts). A row the
// map's bounds drop is read back from it rather than re-parsed.
export type SessionParseCacheColdTier = {
read(path: string): PersistedSessionParseCacheEntry | undefined
stored(path: string, entry: PersistedSessionParseCacheEntry): void
deleted(path: string): void
readScopeCwds?(paths: readonly string[]): SessionScopeCwdRead
storedScopeCwd?(path: string, stamp: SessionScopeCwdStamp): void
}
const cache = new Map<string, SessionParseCacheEntry>()
@@ -70,10 +87,19 @@ const listedPaths = new Set<string>()
// count-bounded sets.
const unlistedPaths = new Set<string>()
const resumePaths = new Set<string>()
// Scope cwds learned without a parse, for the paths the latest scope walk found.
const readScopeCwds = new Map<string, SessionScopeCwdStamp>()
const scopeKnowledge = new Map<string, SessionScopeCwdStamp | 'absent' | 'changed' | 'unresolved'>()
let coldTier: SessionParseCacheColdTier | null = null
export function setSessionParseCacheColdTier(next: SessionParseCacheColdTier | null): void {
coldTier = next
clearSessionScopeCwdKnowledge()
readScopeCwds.clear()
}
export function clearSessionScopeCwdKnowledge(): void {
scopeKnowledge.clear()
}
export function resetSessionParseCacheForTests(): void {
@@ -81,6 +107,8 @@ export function resetSessionParseCacheForTests(): void {
listedPaths.clear()
unlistedPaths.clear()
resumePaths.clear()
readScopeCwds.clear()
clearSessionScopeCwdKnowledge()
coldTier = null
}
@@ -90,6 +118,8 @@ export function invalidateSessionParseCacheEntry(path: string): void {
unlistedPaths.delete(path)
resumePaths.delete(path)
cache.delete(path)
readScopeCwds.delete(path)
scopeKnowledge.delete(path)
coldTier?.deleted(path)
}
@@ -100,7 +130,7 @@ export function noteSessionParseCacheListed(path: string): void {
export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined {
const entry = cache.get(path)
if (entry !== undefined || coldTier === null) {
if (entry !== undefined || coldTier === null || scopeKnowledge.get(path) === 'unresolved') {
return entry
}
const persisted = coldTier.read(path)
@@ -117,10 +147,86 @@ export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry
export function peekSessionParseCacheEntry(
path: string
): PersistedSessionParseCacheEntry | undefined {
return cache.get(path) ?? coldTier?.read(path)
return (
cache.get(path) ??
(scopeKnowledge.get(path) === 'unresolved' ? undefined : coldTier?.read(path))
)
}
/** Scope cwds for many paths at once: memory first, then one batched database read. */
export function lookupSessionScopeCwds(paths: readonly string[]): void {
const cold = paths.filter((path) => !cache.has(path))
const stored =
coldTier === null || cold.length === 0
? { found: new Map<string, SessionScopeCwdLookup>(), parsed: true, read: true }
: coldTier.readScopeCwds?.(cold)
for (const path of paths) {
const row = stored?.found.get(path)
const parsed = row?.parsed ?? (stored?.parsed ? 'absent' : 'unresolved')
scopeKnowledge.set(path, cache.has(path) ? 'changed' : parsed)
const read = row?.read
if (read && !readScopeCwds.has(path)) {
readScopeCwds.set(path, read)
}
}
}
export function resolveSessionScopeCwd(
path: string,
matches: (stamp: SessionScopeCwdStamp) => boolean
): { cwd: string | null } | 'read' | 'parse' {
const hot = cache.get(path)
let parsed = hot ? scopeCwdStampOf(hot) : scopeKnowledge.get(path)
if (parsed === 'changed') {
const stored = coldTier?.readScopeCwds?.([path]).found.get(path)
parsed = stored?.parsed ?? 'unresolved'
scopeKnowledge.set(path, parsed)
if (stored?.read && !readScopeCwds.has(path)) {
readScopeCwds.set(path, stored.read)
}
}
if (parsed === undefined || parsed === 'unresolved') {
scopeKnowledge.set(path, 'unresolved')
return 'parse'
}
for (const stamp of [typeof parsed === 'object' ? parsed : undefined, readScopeCwds.get(path)]) {
if (stamp && matches(stamp)) {
return { cwd: stamp.cwd }
}
}
return 'read'
}
export function storeSessionScopeCwd(path: string, stamp: SessionScopeCwdStamp): void {
readScopeCwds.set(path, stamp)
coldTier?.storedScopeCwd?.(path, stamp)
}
/** Keeps in memory only the read cwds of transcripts the latest scope walk found. */
export function pruneSessionScopeCwds(discoveredPaths: ReadonlySet<string>): void {
for (const map of [readScopeCwds, scopeKnowledge]) {
for (const path of map.keys()) {
if (!discoveredPaths.has(path)) {
map.delete(path)
}
}
}
}
export function scopeCwdStampOf(entry: PersistedSessionParseCacheEntry): SessionScopeCwdStamp {
return {
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
cwd: entry.session?.cwd ?? null,
...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar })
}
}
export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void {
if (scopeKnowledge.has(path)) {
scopeKnowledge.set(path, 'changed')
}
const previous = cache.get(path)
const changed =
previous === undefined ||
@@ -68,6 +68,16 @@ function isMissingSessionPathError(error: unknown): boolean {
return code === 'ENOENT' || code === 'ENOTDIR'
}
/** The session's cwd from its manifest alone; null when the manifest lists no session. */
export function clineManifestCwd(metadataContent: string): string | null {
const metadata = parseJsonRecord(metadataContent)
return metadata ? clineMetadataCwd(metadata) : null
}
function clineMetadataCwd(metadata: Record<string, unknown>): string | null {
return extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
}
export function parseClineSessionContent(
file: FileWithMtime,
metadataContent: string,
@@ -88,7 +98,7 @@ export function parseClineSessionContent(
sessionId,
messages: messageSink
})
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
accumulator.cwd = clineMetadataCwd(metadata)
accumulator.model = extractString(metadata.model)
updateTimeline(accumulator, metadata.started_at)
@@ -82,6 +82,9 @@ import {
} from './session-scanner-parse-cache'
import {
getSessionParseCacheEntry,
lookupSessionScopeCwds,
resolveSessionScopeCwd,
scopeCwdStampOf,
peekSessionParseCacheEntry,
type PersistedSessionParseCacheEntry,
setSessionParseCacheColdTier
@@ -615,6 +618,16 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript
const read = vi.fn((key: string) => rows.get(key))
const tier = {
read,
readScopeCwds: (paths: readonly string[]) => ({
found: new Map(
paths.flatMap((key) => {
const row = rows.get(key)
return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const]
})
),
parsed: true,
read: true
}),
stored: (key: string, row: PersistedSessionParseCacheEntry) => rows.set(key, row),
deleted: (key: string) => rows.delete(key)
}
@@ -645,3 +658,30 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
expect(await stat(path)).toMatchObject({ mtimeMs: file.mtimeMs, size: file.sizeBytes })
})
it('lists a Cursor session once meta.json names the scope after a scan stored it as cwd-less', async () => {
const cursorHome = await createCursorHome()
const path = await writeTranscript(cursorHome, 'slug', 'chat-1', [
JSON.stringify({ role: 'user', message: { content: 'Find the bug' } })
])
await utimes(path, 1, 1)
for (const chatId of ['busy-1', 'busy-2']) {
await writeTranscript(cursorHome, 'slug', chatId, [
JSON.stringify({ role: 'user', message: { content: chatId } })
])
}
const scan = () =>
scanAiVaultSessions({
...isolatedScanRoots(join(cursorHome, '..')),
cursorProjectsDir: join(cursorHome, 'projects'),
platform: 'darwin',
limit: 1,
scopePaths: ['/repo']
})
expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false)
lookupSessionScopeCwds([path])
expect(resolveSessionScopeCwd(path, () => true)).toEqual({ cwd: null })
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-1', { cwd: '/repo' })
const found = (await scan()).sessions.filter((row) => row.filePath === path)
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
})
@@ -68,6 +68,15 @@ export function wasCursorChatMetaRefused(transcriptPath: string): boolean {
return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false
}
/** Whether this scan could not look for the transcript's meta.json, so its absence proves nothing. */
export function cursorChatMetaUnavailable(transcriptPath: string): boolean {
const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath)
return (
wasCursorChatMetaRefused(transcriptPath) ||
(chatsRoot !== null && scanScopedIndex.getStore()?.refusals.has(chatsRoot) === true)
)
}
/** Chats roots the current scan was refused, for the caller to report as scan issues. */
export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] {
const scan = scanScopedIndex.getStore()
@@ -42,6 +42,20 @@ type ParserSessionOptions = {
executionHostPlatform?: NodeJS.Platform | null
}
/** The session's cwd; a scope walk reads it from metadata.json without the context file. */
export function rovoSessionCwd(metadata: Record<string, unknown>): string | null {
return firstString(metadata, [
'workspace_path',
'workspacePath',
'workspace',
'cwd',
'working_directory',
'workingDirectory',
'project_path',
'projectPath'
])
}
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
@@ -60,16 +74,7 @@ export async function parseRovoSessionFile(
messages
})
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
accumulator.cwd = firstString(metadata, [
'workspace_path',
'workspacePath',
'workspace',
'cwd',
'working_directory',
'workingDirectory',
'project_path',
'projectPath'
])
accumulator.cwd = rovoSessionCwd(metadata)
updateTimeline(
accumulator,
extractString(metadata.created_at) ?? extractString(metadata.createdAt)
@@ -45,7 +45,7 @@ export async function parseGrokSessionFile(
const info = asRecord(record.info)
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages })
accumulator.cwd = extractString(info?.cwd)
accumulator.cwd = grokSessionCwd(record)
accumulator.title =
normalizeTitleText(extractString(record.generated_title) ?? '') ??
normalizeTitleText(extractString(record.session_summary) ?? '')
@@ -60,6 +60,11 @@ export async function parseGrokSessionFile(
return finalizeSession(accumulator, platform)
}
/** The session's cwd; a scope walk reads it from summary.json without the history. */
export function grokSessionCwd(summary: Record<string, unknown>): string | null {
return extractString(asRecord(summary.info)?.cwd)
}
async function consumeGrokChatHistory(
accumulator: SessionAccumulator,
sessionDir: string
@@ -99,13 +99,18 @@ function consumeKiroTranscriptLine(accumulator: SessionAccumulator, line: string
}
/** null for a subagent's child session: it is reached from its parent, not listed. */
export function listedKiroMetadata(metadataContent: string): Record<string, unknown> | null {
const metadata = parseJsonObject(metadataContent)
return metadata && !extractString(metadata.parent_session_id) ? metadata : null
}
function createKiroAccumulator(
file: FileWithMtime,
metadataContent: string,
messages?: TranscriptMessageSink
): SessionAccumulator | null {
const metadata = parseJsonObject(metadataContent)
if (!metadata || extractString(metadata.parent_session_id)) {
const metadata = listedKiroMetadata(metadataContent)
if (!metadata) {
return null
}
const accumulator = createAccumulator({
@@ -24,6 +24,11 @@ import {
tokenTotal
} from './session-scanner-values'
/** The session's cwd; a scope walk reads it from the session file without its messages. */
export function openCodeSessionCwd(record: Record<string, unknown>): string | null {
return extractString(record.directory)
}
export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
@@ -36,7 +41,7 @@ export async function parseOpenCodeSessionFile(
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages })
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.directory)
accumulator.cwd = openCodeSessionCwd(record)
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
updateTimeline(accumulator, timeObjectValue(record.time, 'updated'))
await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId)
@@ -0,0 +1,200 @@
import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
import { grokEncodedCwdDirName } from '../../shared/grok-session-paths'
import {
flushSessionParseCachePersist,
initSessionParseCachePersistence,
resetSessionParseCachePersistenceForTests
} from './session-parse-cache-persistence'
import { resetSessionParseCacheForTests } from './session-parse-cache-store'
import { scanAiVaultSessions } from './session-scanner'
import SyncDatabase from '../sqlite/sync-database'
import { SessionParseCacheDatabase } from './session-parse-cache-database'
import { parseAgentSessionFileCached } from './session-scanner-parse-cache'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
const reads = vi.hoisted((): string[] => [])
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => {
const actual = await importOriginal<typeof WslTranscriptFsAccess>()
return {
...actual,
wslGatedReadFile: (...args: Parameters<typeof actual.wslGatedReadFile>) => {
reads.push(String(args[0]))
return actual.wslGatedReadFile(...args)
},
readTranscriptSlice: (...args: Parameters<typeof actual.readTranscriptSlice>) => {
reads.push(String(args[0]))
return actual.readTranscriptSlice(...args)
},
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
reads.push(String(args[0]))
return actual.openTranscriptReadStream(...args)
}
}
})
let tempRoots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
resetSessionParseCachePersistenceForTests()
resetSessionParseCacheForTests()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
reads.length = 0
})
it.each([false, true])('keeps cold authority with read failure=%s', async (failReads) => {
const root = await mkdtemp(join(tmpdir(), 'orca-scope-invalid-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const persistence = { filePath: join(root, 'parse-cache.json'), appVersion: 'test' }
initSessionParseCachePersistence(persistence)
const paths: string[] = []
for (let index = 0; index < 8; index++) {
const path = await writeRollout(
roots.codexSessionsDir,
`old-${index}`,
index === 0 ? '/elsewhere' : '/repo',
index + 1
)
if (index === 0) {
const context = jsonLines([{ type: 'turn_context', payload: { cwd: '/repo' } }])
await appendFile(path, `\n${context}\n`)
}
const { mtimeMs, size: sizeBytes, mtime } = await stat(path)
const parsed = await parseAgentSessionFileCached(
{
agent: 'codex',
file: { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() },
codexHome: null
},
'linux'
)
expect(parsed?.cwd).toBe('/repo')
paths.push(path)
}
await flushSessionParseCachePersist()
resetSessionParseCachePersistenceForTests()
resetSessionParseCacheForTests()
const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite'))
for (const [index, session] of ['{', '1', '[]', 'true', '"scalar"', 'null', 'null'].entries()) {
db.prepare('UPDATE rows SET session = ?, sidecar = ? WHERE path = ?').run(
session,
index === 5 ? '{' : null,
paths[index + 1]
)
}
db.prepare(
'INSERT INTO scope_cwds SELECT path, mtime_ms, size_bytes, platform, ?, ?, seen_at FROM rows WHERE path = ?'
).run('{', '/elsewhere', paths[0])
db.close()
for (let index = 0; index < 9; index++) {
await writeRollout(roots.codexSessionsDir, `busy-${index}`, '/other', 20)
}
initSessionParseCachePersistence(persistence)
const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds')
const ordinary = vi.spyOn(SessionParseCacheDatabase.prototype, 'read')
const prepare = SyncDatabase.prototype.prepare
const queries = vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function (
this: SyncDatabase,
sql
) {
if (failReads && sql.startsWith('SELECT') && sql.includes('FROM rows WHERE path')) {
throw new Error('unavailable parsed channel')
}
return prepare.call(this, sql)
})
const result = await scanAiVaultSessions({
...roots,
platform: 'linux',
limit: 8,
scopePaths: ['/repo']
})
expect(
result.sessions
.filter((row) => row.cwd === '/repo')
.map((row) => row.filePath)
.sort()
).toEqual(paths.slice(0, failReads ? 8 : 7).sort())
expect(
metadata.mock.calls.filter(([batch]) => batch.some((path) => paths.includes(path)))
).toHaveLength(1)
expect(queries.mock.calls.filter(([sql]) => sql.includes('WHERE path IN'))).toHaveLength(2)
if (failReads) {
expect(ordinary.mock.calls.filter(([path]) => paths.includes(path))).toEqual([])
}
})
async function writeGrokSession(sessionsDir: string, id: string, cwd: string, day: number) {
const dir = join(sessionsDir, grokEncodedCwdDirName(cwd) ?? 'unknown', id)
await mkdir(dir, { recursive: true })
const timestamp = new Date(Date.UTC(2026, 9, day, 10))
const summary = join(dir, 'summary.json')
await writeFile(
summary,
JSON.stringify({
info: { id, cwd },
created_at: timestamp.toISOString(),
updated_at: timestamp.toISOString()
})
)
await writeFile(join(dir, 'chat_history.jsonl'), jsonLines([{ type: 'user', content: id }]))
await utimes(summary, timestamp, timestamp)
return dir
}
async function writeRollout(sessionsDir: string, id: string, cwd: string, day: number) {
const timestamp = new Date(Date.UTC(2026, 9, day, 10))
const dir = join(sessionsDir, '2026', '10', String(day).padStart(2, '0'))
const path = join(dir, `rollout-2026-10-${String(day).padStart(2, '0')}T10-00-00-${id}.jsonl`)
await mkdir(dir, { recursive: true })
await writeFile(
path,
jsonLines([
{ timestamp, type: 'session_meta', payload: { id, cwd } },
{
timestamp,
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'text', text: id }] }
}
])
)
await utimes(path, timestamp, timestamp)
return path
}
it('settles out-of-scope transcripts once and reads none of them after a restart', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-scope-cwd-facts-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const persistence = { filePath: join(root, 'user-data', 'parse-cache.json'), appVersion: 'test' }
initSessionParseCachePersistence(persistence)
const others: string[] = []
for (const day of [15, 14, 13]) {
others.push(await writeGrokSession(roots.grokSessionsDir, `other-${day}`, '/other', day))
}
const quiet = await writeGrokSession(roots.grokSessionsDir, 'repo-1', '/repo', 1)
const elsewhere = await writeRollout(roots.codexSessionsDir, 'other-2', '/other', 2)
const scan = () =>
scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] })
const listed = (await scan()).sessions.map((session) => session.cwd)
expect(listed.filter((cwd) => cwd === '/repo')).toHaveLength(1)
// The walked Grok session was ruled out from summary.json, never its history.
expect(reads).not.toContain(join(others[2], 'chat_history.jsonl'))
await flushSessionParseCachePersist()
resetSessionParseCachePersistenceForTests()
resetSessionParseCacheForTests()
initSessionParseCachePersistence(persistence)
reads.length = 0
const relisted = (await scan()).sessions.filter((session) => session.cwd === '/repo')
expect(relisted.map((session) => session.filePath)).toEqual([join(quiet, 'summary.json')])
expect(reads.filter((path) => path.startsWith(root))).toEqual([])
expect(reads).not.toContain(elsewhere)
})
@@ -6,15 +6,25 @@ import { truncateAiVaultListResult } from '../../shared/ai-vault-session-depth'
import { parseSessionCandidates } from './session-scanner-candidate-parse'
import {
MAX_UNLISTED_ENTRIES,
scopeCwdStampOf,
peekSessionParseCacheEntry,
setSessionParseCacheColdTier,
type PersistedSessionParseCacheEntry,
storeSessionParseCacheEntry,
storeSessionScopeCwd,
resetSessionParseCacheForTests
} from './session-parse-cache-store'
import { createSessionParseStats, parseAgentSessionFileCached } from './session-scanner-parse-cache'
import { scanAiVaultSessions } from './session-scanner'
import { resetTranscriptHeadCwdCacheForTests } from './session-scope-cwd'
import * as eventLoop from '../../shared/event-loop-yield'
import { AI_VAULT_SCOPE_CWD_SOURCES } from './session-scope-cwd'
import { SessionParseCacheDatabase } from './session-parse-cache-database'
import SyncDatabase from '../sqlite/sync-database'
import {
initSessionParseCachePersistence,
flushSessionParseCachePersist,
resetSessionParseCachePersistenceForTests
} from './session-parse-cache-persistence'
import {
isolatedScanRoots,
jsonLines,
@@ -26,12 +36,85 @@ import {
let tempRoots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
resetSessionParseCachePersistenceForTests()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
resetTranscriptHeadCwdCacheForTests()
resetSessionParseCacheForTests()
})
it.each(['prefetch', 'read'] as const)('keeps an evicted parse from %s', async (boundary) => {
const root = await mkdtemp(join(tmpdir(), 'orca-scope-publication-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const path = join(roots.codexSessionsDir, 'rollout-published.jsonl')
await writeJsonlFile(path, [
{ type: 'session_meta', payload: { id: 'published', cwd: '/elsewhere' } },
{ type: 'turn_context', payload: { cwd: '/repo' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Find me' } }
])
await utimes(path, 1, 1)
await writeRollout(roots.codexSessionsDir, 'newer', '/other', '15')
const { mtimeMs, size: sizeBytes, mtime } = await stat(path)
const file = { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() }
initSessionParseCachePersistence({ filePath: join(root, 'cache.json'), appVersion: 'test' })
const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds')
const publish = async () => {
const parsed = await parseAgentSessionFileCached(
{ agent: 'codex', file, codexHome: null },
'linux'
)
expect(parsed?.cwd).toBe('/repo')
for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) {
storeSessionParseCacheEntry(`index-${index}`, {
mtimeMs: mtimeMs + 1,
sizeBytes: null,
platform: 'linux',
session: null,
resume: null
})
}
if (boundary === 'read') {
await flushSessionParseCachePersist()
}
const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite'))
expect(db.prepare('SELECT path FROM rows WHERE path = ?').get(path) !== undefined).toBe(
boundary === 'read'
)
db.close()
metadata.mockClear()
}
if (boundary === 'prefetch') {
storeSessionScopeCwd(path, { mtimeMs, sizeBytes, platform: 'linux', cwd: '/elsewhere' })
vi.spyOn(eventLoop, 'yieldToEventLoop').mockImplementationOnce(publish)
} else {
const source = AI_VAULT_SCOPE_CWD_SOURCES.codex
if (source.kind !== 'read') {
throw new Error('Codex must have a cheap reader')
}
const original = source.readCwd
vi.spyOn(source, 'readCwd').mockImplementation(async (candidate, signal) => {
const read = await original(candidate, signal)
if (candidate.file.path === path) {
await publish()
}
return read
})
}
const result = await scanAiVaultSessions({
...roots,
platform: 'linux',
limit: 1,
scopePaths: ['/repo']
})
const found = result.sessions.filter((row) => row.filePath === path)
expect(found.map((row) => row.cwd)).toEqual(['/repo'])
expect(
metadata.mock.calls.filter(([paths]) => paths.length === 1 && paths[0] === path)
).toHaveLength(1)
expect(await stat(path)).toMatchObject({ mtimeMs, size: sizeBytes })
})
async function writeRollout(sessionsDir: string, id: string, cwd: string, day: string) {
const dir = join(sessionsDir, '2026', '10', day)
await mkdir(dir, { recursive: true })
@@ -214,6 +297,16 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn
const read = vi.fn((key: string) => rows.get(key))
setSessionParseCacheColdTier({
read,
readScopeCwds: (paths) => ({
found: new Map(
paths.flatMap((key) => {
const row = rows.get(key)
return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const]
})
),
parsed: true,
read: true
}),
stored: (key, row) => rows.set(key, row),
deleted: (key) => rows.delete(key)
})
+72 -28
View File
@@ -2,7 +2,16 @@ import { createAiVaultScopeCwdMatcher } from '../../shared/ai-vault-scope-cwd-ma
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { noteAiVaultScanReading } from './ai-vault-scan-progress'
import { peekSessionParseCacheEntry } from './session-parse-cache-store'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import {
lookupSessionScopeCwds,
clearSessionScopeCwdKnowledge,
pruneSessionScopeCwds,
resolveSessionScopeCwd,
storeSessionScopeCwd,
type SessionScopeCwdStamp
} from './session-parse-cache-store'
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
import { parseSessionCandidates } from './session-scanner-candidate-parse'
import { sessionSortTime } from './session-scanner-accumulator'
@@ -15,12 +24,7 @@ import type {
SessionFileCandidate,
SessionFileDiscovery
} from './session-scanner-types'
import {
AI_VAULT_SCOPE_CWD_SOURCES,
pruneTranscriptHeadCwdCache,
readTranscriptHeadCwd,
scopeWalksAgentFiles
} from './session-scope-cwd'
import { AI_VAULT_SCOPE_CWD_SOURCES, scopeWalksAgentFiles } from './session-scope-cwd'
import { sidecarUnchanged } from './session-sidecar-stat'
import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates'
@@ -63,7 +67,7 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise<AiVaultS
})
candidates.push(...files.map((file) => ({ agent: layout.agent, file, codexHome: null })))
}
const walked = await walkInScopeSessions(args, scopePaths)
const walked = await walkInScopeSessions(args, scopePaths).finally(clearSessionScopeCwdKnowledge)
if (candidates.length === 0) {
return walked
}
@@ -93,6 +97,7 @@ async function walkInScopeSessions(
const signal = args.options.signal
const inScope = createAiVaultScopeCwdMatcher(scopePaths)
const candidates = walkCandidates(args, scopePaths)
await lookupScopeCwds(candidates, signal)
const sessions = await parseSessionCandidates({
candidates,
limit: args.limit,
@@ -130,7 +135,7 @@ function walkCandidates(
}
all.push(...discoveryFileCandidates(discovery, files, args.options))
}
pruneTranscriptHeadCwdCache(seenPaths)
pruneSessionScopeCwds(seenPaths)
// Aliases collapse with the listed files still present, so a listed rollout's
// hardlink in another Codex home is not parsed again.
return prioritizeAntigravityTranscriptCandidates(
@@ -141,7 +146,29 @@ function walkCandidates(
)
}
/** False only when a current parse or the transcript's opening records put it out of scope. */
// Per lookup batch: one database round trip, then a turn for the event loop.
const SCOPE_LOOKUP_BATCH = 500
async function lookupScopeCwds(
candidates: readonly SessionFileCandidate[],
signal: AbortSignal | undefined
): Promise<void> {
// The Antigravity fold has no cwd until the per-scan history join runs.
const paths = candidates
.filter((candidate) => !candidate.antigravityHistoryPath)
.map((candidate) => candidate.file.path)
for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_BATCH) {
throwIfAiVaultScanCancelled(signal)
lookupSessionScopeCwds(paths.slice(start, start + SCOPE_LOOKUP_BATCH))
await yieldToEventLoop()
}
}
/**
* False only when this transcript version's cwd is known to be out of scope: a
* parse row's cwd first, then one read without a parse. A read the agent can
* settle is stored, so the next scan of an unchanged file reads nothing.
*/
async function mayBeInScope(
candidate: SessionFileCandidate,
inScope: ReturnType<typeof createAiVaultScopeCwdMatcher>,
@@ -149,24 +176,41 @@ async function mayBeInScope(
signal: AbortSignal | undefined
): Promise<boolean> {
const { file } = candidate
const entry = peekSessionParseCacheEntry(file.path)
if (
// The Antigravity fold has no cwd until the per-scan history join runs.
!candidate.antigravityHistoryPath &&
entry !== undefined &&
entry.platform === platform &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null ||
file.sizeBytes === undefined ||
entry.sizeBytes === file.sizeBytes) &&
sidecarUnchanged(entry.sidecar, file.sidecar)
) {
return entry.session !== null && inScope(entry.session.cwd, file.path)
}
const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent]
if (source.kind !== 'head') {
if (candidate.antigravityHistoryPath) {
return true
}
const cwd = await readTranscriptHeadCwd(file, source, signal)
return cwd === null || inScope(cwd, file.path)
const resolve = () =>
resolveSessionScopeCwd(file.path, (stamp) => stampMatchesFile(stamp, file, platform))
let known = resolve()
const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent]
if (known === 'read' && source.kind === 'read') {
const read = await source.readCwd(candidate, signal)
throwIfAiVaultScanCancelled(signal)
if (read !== null) {
storeSessionScopeCwd(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
cwd: read.cwd,
...(file.sidecar === undefined ? {} : { sidecar: file.sidecar })
})
}
known = resolve()
}
return typeof known === 'string' || (known.cwd !== null && inScope(known.cwd, file.path))
}
function stampMatchesFile(
stamp: SessionScopeCwdStamp,
file: FileWithMtime,
platform: NodeJS.Platform
): boolean {
return (
stamp.platform === platform &&
stamp.mtimeMs === file.mtimeMs &&
(stamp.sizeBytes === null ||
file.sizeBytes === undefined ||
stamp.sizeBytes === file.sizeBytes) &&
sidecarUnchanged(stamp.sidecar, file.sidecar)
)
}
+120 -71
View File
@@ -1,36 +1,101 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import { readTranscriptSlice, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import {
CLAUDE_CWD_BUCKET_LAYOUT,
CODEBUDDY_CWD_BUCKET_LAYOUT,
PI_CWD_BUCKET_LAYOUT,
type CwdBucketLayout
} from './session-cwd-bucket-layouts'
import type { FileWithMtime } from './session-scanner-types'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
import { asRecord } from './session-scanner-record-value'
import { extractString, parseJsonObject } from './session-scanner-values'
import { clineManifestCwd } from './session-scanner-cline-parser'
import { cursorChatMetaUnavailable, readCursorChatMeta } from './session-scanner-cursor-chat-meta'
import { rovoSessionCwd } from './session-scanner-graph-parsers'
import { grokSessionCwd } from './session-scanner-grok-parser'
import { listedKiroMetadata } from './session-scanner-kiro-parser'
import { openCodeSessionCwd } from './session-scanner-opencode-parser'
type HeadRecord = Record<string, unknown>
/** A definitive cwd (null: the session has none), or null when the read could not settle it. */
export type ScopeCwdRead = { cwd: string | null } | null
/**
* How Workspace and Project views find a transcript's cwd without a full parse,
* so an agent's older in-scope sessions are selected before the recency cap.
* - bucket: the agent keeps one directory per cwd.
* - head: the cwd sits in the transcript's opening records.
* - read: a small read settles it. A settled cwd is stored per transcript
* version, so later scans read nothing; a parse row always outranks it.
* - parse: only the (cached) full parse knows it.
* - none: the agent never records a cwd, so no session can belong to a scope.
*/
export type SessionScopeCwdSource =
| { kind: 'bucket'; layout: CwdBucketLayout }
| { kind: 'head'; cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number }
| {
kind: 'read'
readCwd: (candidate: SessionFileCandidate, signal?: AbortSignal) => Promise<ScopeCwdRead>
}
| { kind: 'parse' }
| { kind: 'none' }
const PARSE: SessionScopeCwdSource = { kind: 'parse' }
/** The cwd in the transcript's opening records; a later record can still move it, which a parse row then corrects. */
function head(
cwdOf: (record: HeadRecord) => unknown,
firstReadBytes?: number
): SessionScopeCwdSource {
return {
kind: 'read',
readCwd: async ({ file }, signal) => {
const cwd = await readTranscriptHeadCwd(file, { cwdOf, firstReadBytes }, signal)
return cwd === null ? null : { cwd }
}
}
}
function document(cwdOf: (content: string) => string | null): SessionScopeCwdSource {
return { kind: 'read', readCwd: ({ file }, signal) => readDocumentCwd(file, cwdOf, signal) }
}
/** The parser's own cwd from the small file it reads first; `cwdOf` throwing is the parser's failure too. */
async function readDocumentCwd(
file: FileWithMtime,
cwdOf: (content: string) => string | null,
signal?: AbortSignal
): Promise<ScopeCwdRead> {
const content = await settledRead(
() => wslGatedReadFile(file.path, 'utf-8', 'scan', signal),
signal
)
if (content === null) {
return null
}
try {
return { cwd: cwdOf(content) }
} catch {
return null
}
}
function jsonRecord(content: string): Record<string, unknown> | null {
return asRecord(JSON.parse(content) as unknown)
}
// Pi-family header: `{"type":"session","cwd":...}`.
const SESSION_HEADER: SessionScopeCwdSource = {
kind: 'head',
cwdOf: (record) => (record.type === 'session' ? record.cwd : undefined)
const SESSION_HEADER = head((record) => (record.type === 'session' ? record.cwd : undefined))
// Cursor transcripts never record a cwd; the session's comes from its meta.json.
const CURSOR: SessionScopeCwdSource = {
kind: 'read',
readCwd: async ({ file }, signal) => {
const meta = await settledRead(() => readCursorChatMeta(file.path, signal), signal)
if (meta) {
return { cwd: meta.cwd }
}
return file.sidecar === 'none' && !cursorChatMetaUnavailable(file.path) ? { cwd: null } : null
}
}
// Keyed by every agent, so a new agent cannot list without saying how its scope is found.
@@ -38,38 +103,58 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record<AiVaultAgent, SessionScopeCwdSou
claude: { kind: 'bucket', layout: CLAUDE_CWD_BUCKET_LAYOUT },
pi: { kind: 'bucket', layout: PI_CWD_BUCKET_LAYOUT },
codebuddy: { kind: 'bucket', layout: CODEBUDDY_CWD_BUCKET_LAYOUT },
codex: {
kind: 'head',
cwdOf: (record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined),
// Its first record carries the base instructions (about 20 KB): one read, not two.
firstReadBytes: 64 * 1024
},
qoder: { kind: 'head', cwdOf: (record) => record.cwd },
// Its first record carries the base instructions (about 20 KB): one read, not two.
codex: head(
(record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined),
64 * 1024
),
qoder: head((record) => record.cwd),
omp: SESSION_HEADER,
'prime-agent': SESSION_HEADER,
openclaw: SESSION_HEADER,
droid: {
kind: 'head',
cwdOf: (record) => (record.type === 'session_start' ? record.cwd : undefined)
},
droid: head((record) => (record.type === 'session_start' ? record.cwd : undefined)),
// Gemini transcripts carry no cwd at all.
gemini: { kind: 'none' },
// Small per-session documents, a cwd recorded mid-file, a sidecar, or a database row.
// Their parsers take the cwd from the small file discovery found, before any history.
grok: document((content) => {
const summary = jsonRecord(content)
return summary ? grokSessionCwd(summary) : null
}),
rovo: document((content) => {
const metadata = jsonRecord(content)
return metadata ? rovoSessionCwd(metadata) : null
}),
cline: document(clineManifestCwd),
kiro: document((content) => {
const metadata = listedKiroMetadata(content)
return metadata ? extractString(metadata.cwd) : null
}),
cursor: CURSOR,
// Legacy session files; database rows (`<db>#<id>`) are already filtered by directory in SQL.
opencode: {
kind: 'read',
readCwd: async ({ file }, signal) =>
file.path.includes('#')
? null
: readDocumentCwd(
file,
(content) => {
const record = jsonRecord(content)
return record ? openCodeSessionCwd(record) : null
},
signal
)
},
// A cwd recorded mid-file, a sidecar join, a shared index, or a database row.
hermes: PARSE,
cursor: PARSE,
antigravity: PARSE,
rovo: PARSE,
copilot: PARSE,
opencode: PARSE,
opencode2: PARSE,
zcode: PARSE,
grok: PARSE,
devin: PARSE,
cline: PARSE,
kimi: PARSE,
muse: PARSE,
jcode: PARSE,
kiro: PARSE
jcode: PARSE
}
/** Whether a scan with these scope paths walks this agent's files past the recency cap. */
@@ -78,7 +163,7 @@ export function scopeWalksAgentFiles(
scopePaths: readonly string[] | undefined
): boolean {
const kind = AI_VAULT_SCOPE_CWD_SOURCES[agent].kind
return (scopePaths?.length ?? 0) > 0 && (kind === 'head' || kind === 'parse')
return (scopePaths?.length ?? 0) > 0 && (kind === 'read' || kind === 'parse')
}
const HEAD_READ_BYTES = 8 * 1024
@@ -94,10 +179,6 @@ export async function readTranscriptHeadCwd(
source: { cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number },
signal?: AbortSignal
): Promise<string | null> {
const cached = headCwdCache.get(file.path)
if (cached && cached.mtimeMs === file.mtimeMs && cached.sizeBytes === file.sizeBytes) {
return cached.cwd
}
const { cwdOf } = source
let cwd: string | null = null
const first = source.firstReadBytes ?? HEAD_READ_BYTES
@@ -121,13 +202,6 @@ export async function readTranscriptHeadCwd(
break
}
}
if (cwd !== null) {
headCwdCache.set(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes,
cwd: internCwd(cwd)
})
}
return cwd
}
@@ -145,39 +219,14 @@ function firstHeadCwd(
return null
}
// In memory only: a restart re-reads heads, and parsed files answer from the parse cache.
const headCwdCache = new Map<
string,
{ mtimeMs: number; sizeBytes: number | undefined; cwd: string }
>()
// Thousands of transcripts share a handful of cwds.
const cwdStrings = new Map<string, string>()
function internCwd(cwd: string): string {
const existing = cwdStrings.get(cwd)
if (existing !== undefined) {
return existing
}
cwdStrings.set(cwd, cwd)
return cwd
}
/** Drops cached heads for transcripts the latest scoped scan no longer discovered. */
export function pruneTranscriptHeadCwdCache(discoveredPaths: ReadonlySet<string>): void {
for (const path of headCwdCache.keys()) {
if (!discoveredPaths.has(path)) {
headCwdCache.delete(path)
}
}
const liveCwds = new Set([...headCwdCache.values()].map((entry) => entry.cwd))
for (const cwd of cwdStrings.keys()) {
if (!liveCwds.has(cwd)) {
cwdStrings.delete(cwd)
/** A read's value, or null when it failed; an abort still propagates. */
async function settledRead<T>(read: () => Promise<T>, signal?: AbortSignal): Promise<T | null> {
try {
return await read()
} catch (error) {
if (signal?.aborted) {
throw error
}
return null
}
}
export function resetTranscriptHeadCwdCacheForTests(): void {
headCwdCache.clear()
cwdStrings.clear()
}
@@ -0,0 +1,126 @@
// The scope_cwds table of the session parse cache database: a transcript
// version's cwd learned without a parse, read beside each parse row's own cwd.
import type SyncDatabase from '../sqlite/sync-database'
import { parsePersistedSidecar } from './session-sidecar-stat'
import type { SessionScopeCwdLookup, SessionScopeCwdStamp } from './session-parse-cache-store'
// A transcript's cwd learned without a parse (its opening records, or a small
// file its parser reads it from). Versioned apart from rows, so a reader fix
// resets only these.
const SCOPE_CWDS_VERSION = '1'
const SCOPE_CWDS_SQL = `
CREATE TABLE IF NOT EXISTS scope_cwds(
path TEXT PRIMARY KEY,
mtime_ms REAL NOT NULL,
size_bytes INTEGER,
platform TEXT NOT NULL,
sidecar TEXT,
cwd TEXT,
seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS scope_cwds_seen_at ON scope_cwds(seen_at);
`
// Keeps each IN list well under SQLite's bound-parameter limit.
const SCOPE_LOOKUP_CHUNK = 500
const VALID_SESSION =
"CASE WHEN json_valid(session) THEN json_type(session) IN ('object','null') ELSE 0 END"
export function readScopeCwdRows(
db: SyncDatabase,
paths: readonly string[],
channel: 'parsed' | 'read'
): Map<string, SessionScopeCwdLookup> {
const found = new Map<string, SessionScopeCwdLookup>()
for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_CHUNK) {
const chunk = paths.slice(start, start + SCOPE_LOOKUP_CHUNK)
const marks = chunk.map(() => '?').join(',')
for (const [table, key, cwdColumn] of [
['rows', 'parsed', `CASE WHEN ${VALID_SESSION} THEN json_extract(session, '$.cwd') END`],
['scope_cwds', 'read', 'cwd']
] as const) {
if (key !== channel) {
continue
}
const rows = db
.prepare(
`SELECT path, mtime_ms, size_bytes, platform, sidecar, ${cwdColumn} AS cwd FROM ${table} WHERE path IN (${marks}) ${key === 'parsed' ? `AND (${VALID_SESSION})` : ''}`
)
.all(...chunk)
for (const row of rows) {
const stamp = parseScopeCwdStamp(row)
if (stamp && typeof row.path === 'string') {
found.set(row.path, { ...found.get(row.path), [key]: stamp })
}
}
}
}
return found
}
export function writeScopeCwdRows(
db: SyncDatabase,
rows: Iterable<[string, SessionScopeCwdStamp]>,
seenAt: number
): void {
const insert = db.prepare(
'INSERT OR REPLACE INTO scope_cwds(path, mtime_ms, size_bytes, platform, sidecar, cwd, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
)
db.exec('BEGIN IMMEDIATE')
try {
for (const [path, stamp] of rows) {
insert.run(
path,
stamp.mtimeMs,
stamp.sizeBytes,
stamp.platform,
stamp.sidecar === undefined ? null : JSON.stringify(stamp.sidecar),
stamp.cwd,
seenAt
)
}
db.exec('COMMIT')
} catch (error) {
if (db.isTransaction) {
db.exec('ROLLBACK')
}
throw error
}
}
export function ensureScopeCwdsTable(db: SyncDatabase): void {
const current = db.prepare("SELECT value FROM meta WHERE key = 'scope_cwds_version'").get()
if (current?.value !== SCOPE_CWDS_VERSION) {
db.exec('DROP TABLE IF EXISTS scope_cwds')
}
db.exec(SCOPE_CWDS_SQL)
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('scope_cwds_version', ?)").run(
SCOPE_CWDS_VERSION
)
}
function parseScopeCwdStamp(row: Record<string, unknown>): SessionScopeCwdStamp | null {
const { mtime_ms: mtimeMs, size_bytes: sizeBytes, platform, cwd } = row
if (
typeof mtimeMs !== 'number' ||
(sizeBytes !== null && typeof sizeBytes !== 'number') ||
typeof platform !== 'string' ||
(cwd !== null && typeof cwd !== 'string')
) {
return null
}
let sidecar: ReturnType<typeof parsePersistedSidecar>
try {
sidecar =
typeof row.sidecar === 'string' ? parsePersistedSidecar(JSON.parse(row.sidecar)) : undefined
} catch {
return null
}
return {
mtimeMs,
sizeBytes,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only ever compared for equality with process.platform, so an unknown value is a miss, not a misuse.
platform: platform as NodeJS.Platform,
cwd,
...(sidecar === undefined ? {} : { sidecar })
}
}
+20
View File
@@ -47,3 +47,23 @@ export function sidecarUnchanged(
entry.sizeBytes === observed.sizeBytes
)
}
// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
// of the rows that have a sibling and nothing at all for the rest.
export function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
if (value === 'none' || value === 'unknown') {
return value
}
if (!isRecord(value)) {
return undefined
}
const { path, mtimeMs, sizeBytes } = value
return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number'
? { path, mtimeMs, sizeBytes }
: undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -98,6 +98,7 @@ export const SQLITE_SCHEMA_FILE_DATABASES: Record<string, string> = {
'src/main/ai-vault-search/session-search-query-schema.ts': 'ai-vault-search',
'src/main/ai-vault-search/session-search-schema.ts': 'ai-vault-search',
'src/main/ai-vault/session-parse-cache-database.ts': 'ai-vault-parse-cache',
'src/main/ai-vault/session-scope-cwds-table.ts': 'ai-vault-parse-cache',
'src/main/browser/browser-cookie-staged-import.ts': 'browser-cookie-import',
'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts':
'agent-session-journal',
@@ -219,7 +220,7 @@ export const SQLITE_TABLE_DISPOSITIONS: Record<string, WslMoveFieldDisposition>
),
...tables(
'ai-vault-parse-cache',
['meta', 'rows'],
['meta', 'rows', 'scope_cwds'],
notApplicableDisposition(57, 'Session History parse cache; rebuilt from transcripts.')
),
...tables(