mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
556 lines
19 KiB
TypeScript
556 lines
19 KiB
TypeScript
import type { LocalIndexDatabase } from './database.js'
|
|
import { createActivityIndex, type ActivityIndex } from './activityIndex.js'
|
|
import type {
|
|
ClaudeCodeStats,
|
|
StatsDateRange,
|
|
} from '../../../utils/stats.js'
|
|
import type {
|
|
LocalIndexMode,
|
|
LocalIndexStatus,
|
|
PersistedRepositorySession,
|
|
PersistedWorktreeSession,
|
|
TranscriptEntryLocator,
|
|
TranscriptProjection,
|
|
} from './types.js'
|
|
|
|
export type IndexedSessionRow = {
|
|
transcriptPath: string
|
|
id: string
|
|
title: string
|
|
createdAt: string
|
|
modifiedAt: string
|
|
messageCount: number
|
|
projectPath: string
|
|
workDir: string | null
|
|
permissionMode?: string
|
|
runtimeProviderId?: string | null
|
|
runtimeModelId?: string
|
|
effortLevel?: string
|
|
repository?: PersistedRepositorySession
|
|
worktreeSession?: PersistedWorktreeSession | null
|
|
}
|
|
|
|
export type SessionIndexPage = {
|
|
sessions: IndexedSessionRow[]
|
|
total: number
|
|
}
|
|
|
|
export type SessionFileMatch = {
|
|
filePath: string
|
|
projectDir: string
|
|
}
|
|
|
|
export type SessionSearchCandidateFilters = {
|
|
project?: string
|
|
modifiedAfterMs?: number
|
|
modifiedBeforeMs?: number
|
|
}
|
|
|
|
export type IndexedSessionSearchCandidate = Pick<
|
|
IndexedSessionRow,
|
|
'transcriptPath' | 'id' | 'title' | 'modifiedAt' | 'projectPath' | 'workDir'
|
|
>
|
|
|
|
export type SessionEntryLocatorPage = {
|
|
source: SessionSourceRecord
|
|
entries: TranscriptEntryLocator[]
|
|
}
|
|
|
|
export interface SessionIndexReader {
|
|
getSessionSuggestionMetadata?(sessionIds: string[]): IndexedSessionRow[] | null
|
|
searchSessionMetadata?(query: string, options?: { limit?: number; offset?: number }): SessionIndexPage | null
|
|
listSessions(options?: {
|
|
project?: string
|
|
limit?: number
|
|
offset?: number
|
|
}): SessionIndexPage
|
|
findSessionFiles(sessionId: string): SessionFileMatch[]
|
|
getSession?(sessionId: string): IndexedSessionRow | null
|
|
findSearchCandidates?(
|
|
filters: SessionSearchCandidateFilters,
|
|
): IndexedSessionSearchCandidate[] | null
|
|
}
|
|
|
|
export interface LocalIndexGateway extends SessionIndexReader {
|
|
start(): Promise<void>
|
|
stop(): Promise<void>
|
|
getMode(): LocalIndexMode
|
|
getPublicStatus(): LocalIndexStatus
|
|
isSessionScopeReady(): boolean
|
|
isActivityScopeReady?(): boolean
|
|
getActivityStats?(range: StatsDateRange, now?: Date): ClaudeCodeStats | null
|
|
rebuild(): Promise<LocalIndexStatus>
|
|
getSessionEntryLocators?(
|
|
transcriptPath: string,
|
|
entryTypes?: string[],
|
|
): SessionEntryLocatorPage | null
|
|
}
|
|
|
|
export type SessionSourceRecord = {
|
|
path: string
|
|
size: number
|
|
mtimeMs: number
|
|
fileIdentity: string | null
|
|
fingerprint: string
|
|
indexedBytes: number
|
|
parserVersion: number
|
|
state: 'ready' | 'pending' | 'degraded'
|
|
lastErrorCode: string | null
|
|
updatedAtMs: number
|
|
}
|
|
|
|
export type PersistedBackfillState = {
|
|
scope: string
|
|
state: string
|
|
watermark: string | null
|
|
discovered: number
|
|
indexed: number
|
|
degraded: number
|
|
lastErrorCode: string | null
|
|
updatedAtMs: number
|
|
}
|
|
|
|
export interface SessionIndex extends SessionIndexReader, ActivityIndex {
|
|
getSession(sessionId: string): IndexedSessionRow | null
|
|
getSource(path: string): SessionSourceRecord | null
|
|
listSources(): SessionSourceRecord[]
|
|
countSources(): number
|
|
getProjectionSeed(path: string): TranscriptProjection | null
|
|
getBackfillState(scope: string): PersistedBackfillState | null
|
|
getSessionEntryLocators(
|
|
transcriptPath: string,
|
|
entryTypes?: string[],
|
|
): SessionEntryLocatorPage | null
|
|
}
|
|
|
|
type SessionRow = {
|
|
transcript_path: string
|
|
session_id: string
|
|
project_path: string
|
|
title: string
|
|
created_at: string
|
|
modified_at: string
|
|
modified_at_ms: number
|
|
message_count: number
|
|
work_dir: string | null
|
|
permission_mode: string | null
|
|
runtime_provider_id: string | null
|
|
runtime_provider_present: number
|
|
runtime_model_id: string | null
|
|
effort_level: string | null
|
|
repository_json: string | null
|
|
worktree_session_json: string | null
|
|
}
|
|
|
|
type SourceRow = {
|
|
path: string
|
|
size_bytes: number
|
|
mtime_ms: number
|
|
file_identity: string | null
|
|
prefix_hash: string
|
|
indexed_bytes: number
|
|
parser_version: number
|
|
state: SessionSourceRecord['state']
|
|
last_error_code: string | null
|
|
updated_at_ms: number
|
|
}
|
|
|
|
type BackfillRow = {
|
|
scope: string
|
|
state: string
|
|
watermark: string | null
|
|
discovered: number
|
|
indexed: number
|
|
degraded: number
|
|
last_error_code: string | null
|
|
updated_at_ms: number
|
|
}
|
|
|
|
type SessionEntryRow = {
|
|
ordinal: number
|
|
jsonl_line: number
|
|
byte_start: number
|
|
byte_length: number
|
|
entry_type: string
|
|
message_id: string | null
|
|
role: string | null
|
|
timestamp: string | null
|
|
parent_tool_use_id: string | null
|
|
}
|
|
|
|
function boundedInteger(value: number | undefined, fallback: number): number {
|
|
if (!Number.isFinite(value)) return fallback
|
|
return Math.max(0, Math.trunc(value!))
|
|
}
|
|
|
|
function sessionFromRow(row: SessionRow): IndexedSessionRow {
|
|
const repository = parseStoredJson<PersistedRepositorySession>(row.repository_json)
|
|
const worktreeSession = row.worktree_session_json === 'null'
|
|
? null
|
|
: parseStoredJson<PersistedWorktreeSession>(row.worktree_session_json)
|
|
return {
|
|
transcriptPath: row.transcript_path,
|
|
id: row.session_id,
|
|
title: row.title,
|
|
createdAt: row.created_at,
|
|
modifiedAt: row.modified_at,
|
|
messageCount: row.message_count,
|
|
projectPath: row.project_path,
|
|
workDir: row.work_dir,
|
|
...(row.permission_mode ? { permissionMode: row.permission_mode } : {}),
|
|
...(row.runtime_provider_present === 1
|
|
? { runtimeProviderId: row.runtime_provider_id }
|
|
: {}),
|
|
...(row.runtime_model_id ? { runtimeModelId: row.runtime_model_id } : {}),
|
|
...(row.effort_level ? { effortLevel: row.effort_level } : {}),
|
|
...(repository ? { repository } : {}),
|
|
...(row.worktree_session_json !== null ? { worktreeSession } : {}),
|
|
}
|
|
}
|
|
|
|
function sourceFromRow(row: SourceRow): SessionSourceRecord {
|
|
return {
|
|
path: row.path,
|
|
size: row.size_bytes,
|
|
mtimeMs: row.mtime_ms,
|
|
fileIdentity: row.file_identity,
|
|
fingerprint: row.prefix_hash,
|
|
indexedBytes: row.indexed_bytes,
|
|
parserVersion: row.parser_version,
|
|
state: row.state,
|
|
lastErrorCode: row.last_error_code,
|
|
updatedAtMs: row.updated_at_ms,
|
|
}
|
|
}
|
|
|
|
function parseStoredJson<T>(value: string | null | undefined): T | undefined {
|
|
if (value === null || value === undefined) return undefined
|
|
try {
|
|
return JSON.parse(value) as T
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function backfillFromRow(row: BackfillRow): PersistedBackfillState {
|
|
return {
|
|
scope: row.scope,
|
|
state: row.state,
|
|
watermark: row.watermark,
|
|
discovered: row.discovered,
|
|
indexed: row.indexed,
|
|
degraded: row.degraded,
|
|
lastErrorCode: row.last_error_code,
|
|
updatedAtMs: row.updated_at_ms,
|
|
}
|
|
}
|
|
|
|
function locatorFromRow(row: SessionEntryRow): TranscriptEntryLocator {
|
|
return {
|
|
ordinal: row.ordinal,
|
|
jsonlLine: row.jsonl_line,
|
|
byteStart: row.byte_start,
|
|
byteLength: row.byte_length,
|
|
entryType: row.entry_type,
|
|
messageId: row.message_id,
|
|
role: row.role,
|
|
timestamp: row.timestamp,
|
|
parentToolUseId: row.parent_tool_use_id,
|
|
}
|
|
}
|
|
|
|
export function createSessionIndex(database: LocalIndexDatabase): SessionIndex {
|
|
const activityIndex = createActivityIndex(database)
|
|
return {
|
|
...activityIndex,
|
|
listSessions(options): SessionIndexPage {
|
|
const limit = boundedInteger(options?.limit, 50)
|
|
const offset = boundedInteger(options?.offset, 0)
|
|
const project = options?.project
|
|
|
|
return database.read(operation => {
|
|
const total = project === undefined
|
|
? operation.get<{ total: number }>('SELECT COUNT(*) AS total FROM sessions')?.total ?? 0
|
|
: operation.get<{ total: number }>(
|
|
'SELECT COUNT(*) AS total FROM sessions WHERE project_path = ?',
|
|
project,
|
|
)?.total ?? 0
|
|
const rows = project === undefined
|
|
? operation.all<SessionRow>(`
|
|
SELECT transcript_path, session_id, project_path, title, created_at,
|
|
modified_at, message_count, work_dir, permission_mode,
|
|
runtime_provider_id, runtime_provider_present,
|
|
runtime_model_id, effort_level,
|
|
repository_json, worktree_session_json
|
|
FROM sessions
|
|
ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC
|
|
LIMIT ? OFFSET ?
|
|
`, limit, offset)
|
|
: operation.all<SessionRow>(`
|
|
SELECT transcript_path, session_id, project_path, title, created_at,
|
|
modified_at, message_count, work_dir, permission_mode,
|
|
runtime_provider_id, runtime_provider_present,
|
|
runtime_model_id, effort_level,
|
|
repository_json, worktree_session_json
|
|
FROM sessions
|
|
WHERE project_path = ?
|
|
ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC
|
|
LIMIT ? OFFSET ?
|
|
`, project, limit, offset)
|
|
|
|
return { sessions: rows.map(sessionFromRow), total }
|
|
})
|
|
},
|
|
|
|
getSessionSuggestionMetadata(sessionIds): IndexedSessionRow[] {
|
|
const ids = [...new Set(sessionIds)].slice(0, 100)
|
|
if (!ids.length) return []
|
|
return database.read(operation => operation.all<SessionRow>(`
|
|
SELECT transcript_path, session_id, project_path, title, created_at,
|
|
modified_at, message_count, work_dir, permission_mode,
|
|
runtime_provider_id, runtime_provider_present, runtime_model_id,
|
|
effort_level, repository_json, worktree_session_json
|
|
FROM sessions WHERE session_id IN (${ids.map(() => '?').join(',')})
|
|
ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC
|
|
LIMIT 100
|
|
`, ...ids).map(sessionFromRow))
|
|
},
|
|
|
|
searchSessionMetadata(query, options): SessionIndexPage {
|
|
const needle = query.trim().toLowerCase()
|
|
const limit = Math.min(100, Math.max(1, boundedInteger(options?.limit, 30)))
|
|
const offset = boundedInteger(options?.offset, 0)
|
|
return database.read(operation => {
|
|
const where = `instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 OR instr(lower(coalesce(work_dir, '')), ?) > 0 OR instr(lower(project_path), ?) > 0`
|
|
const total = operation.get<{ total: number }>(`SELECT COUNT(*) AS total FROM sessions WHERE ${where}`, needle, needle, needle, needle)?.total ?? 0
|
|
const rows = operation.all<SessionRow>(`
|
|
SELECT transcript_path, session_id, project_path, title, created_at,
|
|
modified_at, message_count, work_dir, permission_mode,
|
|
runtime_provider_id, runtime_provider_present, runtime_model_id,
|
|
effort_level, repository_json, worktree_session_json
|
|
FROM sessions WHERE ${where}
|
|
ORDER BY CASE
|
|
WHEN ? = '' THEN 0
|
|
WHEN lower(title) = ? OR lower(session_id) = ? THEN 3
|
|
WHEN instr(lower(title), ?) = 1 OR instr(lower(session_id), ?) = 1 THEN 2
|
|
WHEN instr(lower(title), ?) > 0 OR instr(lower(session_id), ?) > 0 THEN 1
|
|
ELSE 0 END DESC,
|
|
modified_at_ms DESC, session_id ASC, transcript_path ASC
|
|
LIMIT ? OFFSET ?
|
|
`, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, needle, limit, offset)
|
|
return { sessions: rows.map(sessionFromRow), total }
|
|
})
|
|
},
|
|
|
|
findSessionFiles(sessionId): SessionFileMatch[] {
|
|
return database.read(operation => operation.all<{
|
|
transcript_path: string
|
|
project_path: string
|
|
}>(`
|
|
SELECT sessions.transcript_path, sessions.project_path
|
|
FROM sessions
|
|
JOIN source_files ON source_files.path = sessions.transcript_path
|
|
WHERE sessions.session_id = ?
|
|
ORDER BY source_files.mtime_ms DESC, sessions.transcript_path ASC
|
|
`, sessionId).map(row => ({
|
|
filePath: row.transcript_path,
|
|
projectDir: row.project_path,
|
|
})))
|
|
},
|
|
|
|
getSession(sessionId): IndexedSessionRow | null {
|
|
return database.read(operation => {
|
|
const row = operation.get<SessionRow>(`
|
|
SELECT transcript_path, session_id, project_path, title, created_at,
|
|
modified_at, message_count, work_dir, permission_mode,
|
|
runtime_provider_id, runtime_provider_present,
|
|
runtime_model_id, effort_level,
|
|
repository_json, worktree_session_json
|
|
FROM sessions
|
|
WHERE session_id = ?
|
|
ORDER BY modified_at_ms DESC, transcript_path ASC
|
|
LIMIT 1
|
|
`, sessionId)
|
|
return row ? sessionFromRow(row) : null
|
|
})
|
|
},
|
|
|
|
findSearchCandidates(filters): IndexedSessionSearchCandidate[] {
|
|
const clauses: string[] = []
|
|
const values: Array<string | number> = []
|
|
if (filters.project !== undefined) {
|
|
clauses.push('project_path = ?')
|
|
values.push(filters.project)
|
|
}
|
|
if (filters.modifiedAfterMs !== undefined) {
|
|
clauses.push('modified_at_ms >= ?')
|
|
values.push(filters.modifiedAfterMs)
|
|
}
|
|
if (filters.modifiedBeforeMs !== undefined) {
|
|
clauses.push('modified_at_ms <= ?')
|
|
values.push(filters.modifiedBeforeMs)
|
|
}
|
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
|
return database.read(operation => operation.all<Pick<
|
|
SessionRow,
|
|
| 'transcript_path'
|
|
| 'session_id'
|
|
| 'project_path'
|
|
| 'title'
|
|
| 'modified_at'
|
|
| 'work_dir'
|
|
>>(`
|
|
SELECT transcript_path, session_id, project_path, title,
|
|
modified_at, work_dir
|
|
FROM sessions
|
|
${where}
|
|
ORDER BY modified_at_ms DESC, session_id ASC, transcript_path ASC
|
|
`, ...values).map(row => ({
|
|
transcriptPath: row.transcript_path,
|
|
id: row.session_id,
|
|
title: row.title,
|
|
modifiedAt: row.modified_at,
|
|
projectPath: row.project_path,
|
|
workDir: row.work_dir,
|
|
})))
|
|
},
|
|
|
|
getSource(path): SessionSourceRecord | null {
|
|
return database.read(operation => {
|
|
const row = operation.get<SourceRow>(`
|
|
SELECT path, size_bytes, mtime_ms, file_identity, prefix_hash,
|
|
indexed_bytes, parser_version, state, last_error_code, updated_at_ms
|
|
FROM source_files
|
|
WHERE path = ?
|
|
`, path)
|
|
return row ? sourceFromRow(row) : null
|
|
})
|
|
},
|
|
|
|
listSources(): SessionSourceRecord[] {
|
|
return database.read(operation => operation.all<SourceRow>(`
|
|
SELECT path, size_bytes, mtime_ms, file_identity, prefix_hash,
|
|
indexed_bytes, parser_version, state, last_error_code, updated_at_ms
|
|
FROM source_files
|
|
ORDER BY path ASC
|
|
`).map(sourceFromRow))
|
|
},
|
|
|
|
countSources(): number {
|
|
return database.read(operation => operation.get<{ count: number }>(`
|
|
SELECT COUNT(*) AS count
|
|
FROM source_files
|
|
`)?.count ?? 0)
|
|
},
|
|
|
|
getProjectionSeed(path): TranscriptProjection | null {
|
|
return database.read(operation => {
|
|
const row = operation.get<SessionRow & {
|
|
indexed_bytes: number
|
|
size_bytes: number
|
|
}>(`
|
|
SELECT sessions.transcript_path, sessions.session_id,
|
|
sessions.project_path, sessions.title, sessions.created_at,
|
|
sessions.modified_at, sessions.modified_at_ms,
|
|
sessions.message_count, sessions.work_dir,
|
|
sessions.permission_mode, sessions.runtime_provider_id,
|
|
sessions.runtime_provider_present,
|
|
sessions.runtime_model_id, sessions.effort_level,
|
|
sessions.repository_json, sessions.worktree_session_json,
|
|
source_files.indexed_bytes, source_files.size_bytes
|
|
FROM sessions
|
|
JOIN source_files ON source_files.path = sessions.transcript_path
|
|
WHERE sessions.transcript_path = ?
|
|
`, path)
|
|
if (!row) return null
|
|
const repository = parseStoredJson<NonNullable<TranscriptProjection['summary']['repository']>>(
|
|
row.repository_json,
|
|
)
|
|
const worktreeSession = row.worktree_session_json === 'null'
|
|
? null
|
|
: parseStoredJson<NonNullable<TranscriptProjection['summary']['worktreeSession']>>(
|
|
row.worktree_session_json,
|
|
)
|
|
return {
|
|
summary: {
|
|
title: row.title,
|
|
createdAt: row.created_at,
|
|
modifiedAt: row.modified_at,
|
|
messageCount: row.message_count,
|
|
workDir: row.work_dir,
|
|
...(row.permission_mode ? { permissionMode: row.permission_mode } : {}),
|
|
...(row.runtime_provider_present === 1
|
|
? { runtimeProviderId: row.runtime_provider_id }
|
|
: {}),
|
|
...(row.runtime_model_id ? { runtimeModelId: row.runtime_model_id } : {}),
|
|
...(row.effort_level ? { effortLevel: row.effort_level } : {}),
|
|
...(repository ? { repository } : {}),
|
|
...(row.worktree_session_json !== null
|
|
? { worktreeSession }
|
|
: {}),
|
|
},
|
|
indexedBytes: row.indexed_bytes,
|
|
pendingTailBytes: row.size_bytes - row.indexed_bytes,
|
|
// v1 intentionally does not persist reducer internals. A process restart
|
|
// therefore rebuilds this source before accepting a later append.
|
|
malformedLineCount: 0,
|
|
}
|
|
})
|
|
},
|
|
|
|
getBackfillState(scope): PersistedBackfillState | null {
|
|
return database.read(operation => {
|
|
const row = operation.get<BackfillRow>(`
|
|
SELECT scope, state, watermark, discovered, indexed, degraded,
|
|
last_error_code, updated_at_ms
|
|
FROM backfill_state
|
|
WHERE scope = ?
|
|
`, scope)
|
|
return row ? backfillFromRow(row) : null
|
|
})
|
|
},
|
|
|
|
getSessionEntryLocators(transcriptPath, entryTypes): SessionEntryLocatorPage | null {
|
|
const normalizedTypes = entryTypes === undefined
|
|
? undefined
|
|
: [...new Set(entryTypes.filter(type => type.length > 0))].slice(0, 32)
|
|
return database.read(operation => {
|
|
const source = operation.get<SourceRow>(`
|
|
SELECT path, size_bytes, mtime_ms, file_identity, prefix_hash,
|
|
indexed_bytes, parser_version, state, last_error_code, updated_at_ms
|
|
FROM source_files
|
|
WHERE path = ?
|
|
`, transcriptPath)
|
|
if (!source) return null
|
|
|
|
let rows: SessionEntryRow[]
|
|
if (normalizedTypes === undefined) {
|
|
rows = operation.all<SessionEntryRow>(`
|
|
SELECT ordinal, jsonl_line, byte_start, byte_length, entry_type,
|
|
message_id, role, timestamp, parent_tool_use_id
|
|
FROM session_entries
|
|
WHERE transcript_path = ?
|
|
ORDER BY ordinal ASC
|
|
`, transcriptPath)
|
|
} else if (normalizedTypes.length === 0) {
|
|
rows = []
|
|
} else {
|
|
const placeholders = normalizedTypes.map(() => '?').join(', ')
|
|
rows = operation.all<SessionEntryRow>(`
|
|
SELECT ordinal, jsonl_line, byte_start, byte_length, entry_type,
|
|
message_id, role, timestamp, parent_tool_use_id
|
|
FROM session_entries
|
|
WHERE transcript_path = ? AND entry_type IN (${placeholders})
|
|
ORDER BY ordinal ASC
|
|
`, transcriptPath, ...normalizedTypes)
|
|
}
|
|
|
|
return {
|
|
source: sourceFromRow(source),
|
|
entries: rows.map(locatorFromRow),
|
|
}
|
|
})
|
|
},
|
|
}
|
|
}
|