mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
perf: reuse AI-vault path matchers and query and sort keys (#19469)
* perf: reuse AI-vault path matchers and query and sort keys * test(ai-vault): add cross-platform path and sort-key differentials --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from './ai-vault-types'
|
||||
import { isPathInsideOrEqual } from './cross-platform-path'
|
||||
import {
|
||||
aiVaultScanLimit,
|
||||
aiVaultSessionDepthCovers,
|
||||
@@ -83,3 +84,43 @@ describe('Agent Session History depth', () => {
|
||||
expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent Session History scope truncation', () => {
|
||||
it('normalizes scope roots and candidate cwd once per truncation pass', () => {
|
||||
const loaded = result(
|
||||
Array.from({ length: 1000 }, (_, i) => session(`id-${i}`, `/other/${i}`, i))
|
||||
)
|
||||
const scopes = Array.from({ length: 100 }, (_, i) => `/repo/${i}`)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
let selected: AiVaultListResult
|
||||
try {
|
||||
selected = truncateAiVaultListResult(loaded, 10, scopes)
|
||||
expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
expect(selected.sessions).toEqual(loaded.sessions.slice(0, 10))
|
||||
})
|
||||
|
||||
it('selects scoped sessions identically to the pre-hoist path predicate', () => {
|
||||
const cases = [
|
||||
['C:\\Users\\Ada\\repo', 'c:/users/ada/repo/app'],
|
||||
['C:/Users/Ada/repo/', 'C:\\Users\\Ada\\repo'],
|
||||
['C:\\Users\\Ada\\repo', 'C:\\Users\\Ada\\repo-other'],
|
||||
['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/Ada/Repo/App'],
|
||||
['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl.localhost/Debian/home/ada/repo'],
|
||||
['/Users/ada/repo', '/Users/ada/repo//app/'],
|
||||
['/Users/ada/repo', '/Users/ada/repository'],
|
||||
['/', '/anywhere']
|
||||
]
|
||||
for (const [scope, cwd] of cases) {
|
||||
const loaded = result([session('scoped', cwd!, 0)])
|
||||
const selected = truncateAiVaultListResult(loaded, 0, [scope!])
|
||||
expect({ scope, cwd, kept: selected.sessions.length === 1 }).toEqual({
|
||||
scope,
|
||||
cwd,
|
||||
kept: isPathInsideOrEqual(scope!, cwd!)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { isPathInsideOrEqual } from './cross-platform-path'
|
||||
import {
|
||||
createNormalizedPathInsideOrEqualMatcher,
|
||||
normalizeRuntimePathForComparison
|
||||
} from './cross-platform-path'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types'
|
||||
|
||||
export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000
|
||||
@@ -40,10 +43,12 @@ export function truncateAiVaultListResult(
|
||||
}
|
||||
const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id))
|
||||
if (scopePaths.length > 0) {
|
||||
const scopeMatchers = scopePaths.map(createNormalizedPathInsideOrEqualMatcher)
|
||||
let scopedCount = 0
|
||||
for (const session of result.sessions) {
|
||||
const cwd = session.cwd
|
||||
if (cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) {
|
||||
const normalizedCwd = cwd ? normalizeRuntimePathForComparison(cwd) : null
|
||||
if (normalizedCwd !== null && scopeMatchers.some((matches) => matches(normalizedCwd))) {
|
||||
selectedIds.add(session.id)
|
||||
if (++scopedCount >= depth) {
|
||||
break
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from './ai-vault-types'
|
||||
import {
|
||||
agentLabel,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
parseVaultQuery
|
||||
} from './ai-vault-session-filters'
|
||||
import { sessionPreviewSearchText } from './ai-vault-session-display'
|
||||
import { isPathInsideOrEqual } from './cross-platform-path'
|
||||
import { parseWslUncPath } from './wsl-paths'
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'claude:1',
|
||||
@@ -198,3 +200,162 @@ describe('/shared ai-vault-session-filters (lifted core)', () => {
|
||||
expect(sessionPreviewSearchText(baseSession)).toContain('scope tabs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', () => {
|
||||
it('prepares workspace path matching once for a large session filter pass', () => {
|
||||
const sessions = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
cwd: `/other/${i}`
|
||||
}))
|
||||
const activeWorktreePaths = Array.from({ length: 100 }, (_, i) => `/repo/${i}`)
|
||||
const normalize = vi.spyOn(String.prototype, 'normalize')
|
||||
try {
|
||||
expect(
|
||||
filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'workspace',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths,
|
||||
hideEmptySessions: false
|
||||
})
|
||||
).toEqual([])
|
||||
expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not read transcript previews for empty or field-only queries', () => {
|
||||
let reads = 0
|
||||
const sessions = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
get previewMessages() {
|
||||
reads++
|
||||
return baseSession.previewMessages
|
||||
}
|
||||
}))
|
||||
for (const query of ['', 'repo:repo', 'path:app']) {
|
||||
expect(
|
||||
filterAiVaultSessions(sessions, {
|
||||
query,
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
).toHaveLength(1000)
|
||||
}
|
||||
expect(reads).toBe(0)
|
||||
expect(
|
||||
filterAiVaultSessions(sessions, {
|
||||
query: 'scope',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
).toHaveLength(1000)
|
||||
expect(reads).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('parses each sort timestamp once with original ordering, including invalid dates', () => {
|
||||
const sessions = Array.from({ length: 2000 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
updatedAt:
|
||||
i % 131 === 0
|
||||
? 'invalid'
|
||||
: new Date(1700000000000 + ((i * 173) % 1999) * 1000).toISOString()
|
||||
}))
|
||||
const parse = vi.spyOn(Date, 'parse')
|
||||
let actual: AiVaultSession[]
|
||||
let expected: AiVaultSession[]
|
||||
try {
|
||||
expected = [...sessions].sort(
|
||||
(a, b) => Date.parse(b.updatedAt ?? b.modifiedAt) - Date.parse(a.updatedAt ?? a.modifiedAt)
|
||||
)
|
||||
expect(parse.mock.calls.length).toBeGreaterThan(10_000)
|
||||
parse.mockClear()
|
||||
actual = filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
expect(parse).toHaveBeenCalledTimes(2000)
|
||||
} finally {
|
||||
parse.mockRestore()
|
||||
}
|
||||
actual.forEach((session, i) => expect(session).toBe(expected[i]))
|
||||
})
|
||||
|
||||
it('matches workspace scope identically to the pre-hoist path predicate', () => {
|
||||
// The hoisted matcher must agree with isPathInsideOrEqual on every spelling the
|
||||
// supported hosts produce: Windows case/separator folding, trailing separators,
|
||||
// both WSL UNC aliases, /mnt drive mounts and near-miss sibling directories.
|
||||
const cases: [workspace: string, cwd: string][] = [
|
||||
['C:\\Users\\Ada\\repo', 'c:/users/ada/repo/app'],
|
||||
['C:/Users/Ada/repo/', 'C:\\Users\\Ada\\repo'],
|
||||
['C:\\Users\\Ada\\repo', 'C:\\Users\\Ada\\repo-other'],
|
||||
['C:\\', 'C:\\anything'],
|
||||
['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl$/Ubuntu/home/ada/repo/app'],
|
||||
['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/Ada/Repo/App'],
|
||||
['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/ada/repo/App'],
|
||||
['//wsl$/Ubuntu/home/ada/repo', '//WSL.LOCALHOST/ubuntu/home/ada/repo'],
|
||||
['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl.localhost/Debian/home/ada/repo'],
|
||||
['//wsl.localhost/Ubuntu/mnt/c/work', '/mnt/c/work/app'],
|
||||
['/Users/ada/repo', '/Users/ada/repo//app/'],
|
||||
['/Users/ada/repo', '/Users/Ada/repo/app'],
|
||||
['/Users/ada/repo', '/Users/ada/repository'],
|
||||
['/', '/anywhere']
|
||||
]
|
||||
for (const [workspace, cwd] of cases) {
|
||||
const expected =
|
||||
isPathInsideOrEqual(workspace, cwd) ||
|
||||
(parseWslUncPath(workspace)
|
||||
? isPathInsideOrEqual(parseWslUncPath(workspace)!.linuxPath, cwd)
|
||||
: false)
|
||||
const actual = filterAiVaultSessions([{ ...baseSession, cwd }], {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'workspace',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [workspace],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
expect({ workspace, cwd, matched: actual.length === 1 }).toEqual({
|
||||
workspace,
|
||||
cwd,
|
||||
matched: expected
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('orders by creation time identically to the per-comparison parse', () => {
|
||||
const sessions = Array.from({ length: 500 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: String(i),
|
||||
createdAt:
|
||||
i % 37 === 0 ? 'invalid' : new Date(1700000000000 + ((i * 91) % 499) * 1000).toISOString()
|
||||
}))
|
||||
const expected = [...sessions].sort(
|
||||
(a, b) => Date.parse(b.createdAt ?? b.modifiedAt) - Date.parse(a.createdAt ?? a.modifiedAt)
|
||||
)
|
||||
const actual = filterAiVaultSessions(sessions, {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'created',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: false
|
||||
})
|
||||
actual.forEach((session, i) => expect(session).toBe(expected[i]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Metro only watches mobile/ + repo-root src/shared, never src/renderer.
|
||||
// INVARIANT: /shared is a leaf — this module must NOT import from src/renderer.
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
createNormalizedPathInsideOrEqualMatcher,
|
||||
normalizeRuntimePathForComparison,
|
||||
normalizeRuntimePathSeparators
|
||||
} from './cross-platform-path'
|
||||
@@ -79,45 +79,50 @@ export function filterAiVaultSessions(
|
||||
|
||||
const agentSet = new Set(filters.agents)
|
||||
const parsedQuery = parseVaultQuery(filters.query)
|
||||
const workspaceMatchers =
|
||||
filters.scope === 'workspace'
|
||||
? filters.activeWorktreePaths.map(createAiVaultWorkspaceMatcher)
|
||||
: []
|
||||
|
||||
return sessions
|
||||
.filter((session) => {
|
||||
if (!agentSet.has(session.agent)) {
|
||||
const filtered = sessions.filter((session) => {
|
||||
if (!agentSet.has(session.agent)) {
|
||||
return false
|
||||
}
|
||||
// Hide plain empty sessions, but keep sessions with resumable content
|
||||
// (some parsers only learn turns from previews, e.g. Grok) and zero-turn
|
||||
// sessions that still carry recoverable content (queued prompts /
|
||||
// subagent transcripts) so a lost conversation is surfaced distinctly.
|
||||
if (
|
||||
filters.hideEmptySessions &&
|
||||
!isAiVaultSessionResumableContent(session) &&
|
||||
!isAiVaultSessionRecoverableEmpty(session)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.scope === 'workspace') {
|
||||
const cwd = session.cwd
|
||||
const normalizedCwd = cwd ? normalizeRuntimePathForComparison(cwd) : null
|
||||
if (normalizedCwd === null || !workspaceMatchers.some((matches) => matches(normalizedCwd))) {
|
||||
return false
|
||||
}
|
||||
// Hide plain empty sessions, but keep sessions with resumable content
|
||||
// (some parsers only learn turns from previews, e.g. Grok) and zero-turn
|
||||
// sessions that still carry recoverable content (queued prompts /
|
||||
// subagent transcripts) so a lost conversation is surfaced distinctly.
|
||||
if (
|
||||
filters.hideEmptySessions &&
|
||||
!isAiVaultSessionResumableContent(session) &&
|
||||
!isAiVaultSessionRecoverableEmpty(session)
|
||||
) {
|
||||
}
|
||||
if (filters.scope === 'project') {
|
||||
if (!filters.activeProjectKey) {
|
||||
return false
|
||||
}
|
||||
if (filters.scope === 'workspace') {
|
||||
const cwd = session.cwd
|
||||
if (
|
||||
!cwd ||
|
||||
!filters.activeWorktreePaths.some((pathValue) =>
|
||||
isAiVaultSessionInWorkspacePath(pathValue, cwd)
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) {
|
||||
return false
|
||||
}
|
||||
if (filters.scope === 'project') {
|
||||
if (!filters.activeProjectKey) {
|
||||
return false
|
||||
}
|
||||
if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return matchesQuery(session, parsedQuery, filters)
|
||||
})
|
||||
.sort((left, right) => compareSessions(left, right, filters.sort))
|
||||
}
|
||||
return matchesQuery(session, parsedQuery, filters)
|
||||
})
|
||||
if (filtered.length < 2) {
|
||||
return filtered
|
||||
}
|
||||
return filtered
|
||||
.map((session) => ({ session, time: sessionSortTime(session, filters.sort) }))
|
||||
.sort((left, right) => right.time - left.time)
|
||||
.map(({ session }) => session)
|
||||
}
|
||||
|
||||
export function groupAiVaultSessions(
|
||||
@@ -206,48 +211,48 @@ function matchesQuery(
|
||||
parsed: ParsedQuery,
|
||||
filters: Pick<AiVaultSessionFilterState, 'sessionProjectById' | 'projectLabelByKey'>
|
||||
): boolean {
|
||||
const searchable = [
|
||||
session.title,
|
||||
session.sessionId,
|
||||
session.agent,
|
||||
session.branch,
|
||||
session.model,
|
||||
session.cwd,
|
||||
session.filePath,
|
||||
sessionPreviewSearchText(session)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
if (parsed.terms.some((term) => !searchable.includes(term))) {
|
||||
return false
|
||||
if (parsed.terms.length > 0) {
|
||||
const searchable = [
|
||||
session.title,
|
||||
session.sessionId,
|
||||
session.agent,
|
||||
session.branch,
|
||||
session.model,
|
||||
session.cwd,
|
||||
session.filePath,
|
||||
sessionPreviewSearchText(session)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (parsed.terms.some((term) => !searchable.includes(term))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sessionProject = filters.sessionProjectById?.get(session.id)
|
||||
const repoLabel = (
|
||||
sessionProject?.kind === 'repo'
|
||||
? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label)
|
||||
: folderLabel(session.cwd)
|
||||
).toLowerCase()
|
||||
if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) {
|
||||
return false
|
||||
if (parsed.repoTerms.length > 0) {
|
||||
const sessionProject = filters.sessionProjectById?.get(session.id)
|
||||
const repoLabel = (
|
||||
sessionProject?.kind === 'repo'
|
||||
? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label)
|
||||
: folderLabel(session.cwd)
|
||||
).toLowerCase()
|
||||
if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase()
|
||||
if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) {
|
||||
return false
|
||||
if (parsed.pathTerms.length > 0) {
|
||||
const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase()
|
||||
if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number {
|
||||
const leftValue = sort === 'created' ? left.createdAt : left.updatedAt
|
||||
const rightValue = sort === 'created' ? right.createdAt : right.updatedAt
|
||||
const leftTime = Date.parse(leftValue ?? left.modifiedAt)
|
||||
const rightTime = Date.parse(rightValue ?? right.modifiedAt)
|
||||
return rightTime - leftTime
|
||||
function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number {
|
||||
const value = sort === 'created' ? session.createdAt : session.updatedAt
|
||||
return Date.parse(value ?? session.modifiedAt)
|
||||
}
|
||||
|
||||
function getGroupIdentity(
|
||||
@@ -276,19 +281,15 @@ function getGroupIdentity(
|
||||
return { key: folderGroupKey(session.cwd), label: folderLabel(session.cwd) }
|
||||
}
|
||||
|
||||
function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean {
|
||||
if (isPathInsideOrEqual(workspacePath, sessionCwd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
function createAiVaultWorkspaceMatcher(workspacePath: string): (normalizedCwd: string) => boolean {
|
||||
const matches = createNormalizedPathInsideOrEqualMatcher(workspacePath)
|
||||
const workspaceWslPath = parseWslUncPath(workspacePath)
|
||||
if (!workspaceWslPath) {
|
||||
return false
|
||||
return matches
|
||||
}
|
||||
|
||||
// WSL agent transcripts record Linux cwd values even when Orca stores the
|
||||
// active worktree as a Windows UNC path.
|
||||
return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd)
|
||||
// WSL transcripts record Linux cwd even when the workspace uses a UNC path.
|
||||
const matchesLinux = createNormalizedPathInsideOrEqualMatcher(workspaceWslPath.linuxPath)
|
||||
return (cwd) => matches(cwd) || matchesLinux(cwd)
|
||||
}
|
||||
|
||||
function tokenizeQuery(query: string): string[] {
|
||||
|
||||
Reference in New Issue
Block a user