mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 09:13:36 +00:00
fix: on file update, no directory rescan is needed
This commit is contained in:
@@ -53,11 +53,15 @@ async function onUpdate(
|
||||
filePath: string,
|
||||
provider: LLMProvider,
|
||||
): Promise<void> {
|
||||
if (!isWatcherTargetFile(provider, filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await llmSessionsService.synchronizeProvider(provider, { fullRescan: true });
|
||||
const result = await llmSessionsService.synchronizeProviderFile(provider, filePath);
|
||||
logger.info(`LLM watcher sync complete for provider "${provider}" after ${eventType}`, {
|
||||
filePath,
|
||||
processed: result.processed,
|
||||
indexed: result.indexed,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -69,6 +73,17 @@ async function onUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters watcher events to provider-specific transcript artifact file types.
|
||||
*/
|
||||
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json');
|
||||
}
|
||||
|
||||
return filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes LLM session watchers and performs an initial index sync.
|
||||
*/
|
||||
|
||||
@@ -145,12 +145,12 @@ export const llmSessionsService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs one provider indexer and updates `scan_state.last_scanned_at`.
|
||||
* Indexes one provider artifact file without running a full provider rescan.
|
||||
*/
|
||||
async synchronizeProvider(
|
||||
async synchronizeProviderFile(
|
||||
provider: LLMProvider,
|
||||
options: { fullRescan?: boolean } = {},
|
||||
): Promise<{ provider: LLMProvider; processed: number }> {
|
||||
filePath: string,
|
||||
): Promise<{ provider: LLMProvider; indexed: boolean }> {
|
||||
const indexer = sessionIndexers.find((entry) => entry.provider === provider);
|
||||
if (!indexer) {
|
||||
throw new AppError(`No session indexer registered for provider "${provider}".`, {
|
||||
@@ -159,11 +159,12 @@ export const llmSessionsService = {
|
||||
});
|
||||
}
|
||||
|
||||
const lastScanAt = options.fullRescan ? null : scanStateDb.getLastScannedAt();
|
||||
const processed = await indexer.synchronize(lastScanAt);
|
||||
scanStateDb.updateLastScannedAt();
|
||||
if (!indexer.synchronizeFile) {
|
||||
return { provider, indexed: false };
|
||||
}
|
||||
|
||||
return { provider, processed };
|
||||
const indexed = await indexer.synchronizeFile(filePath);
|
||||
return { provider, indexed };
|
||||
},
|
||||
|
||||
updateSessionCustomName(sessionId: string, sessionCustomName: string): void {
|
||||
|
||||
@@ -22,15 +22,15 @@ type ParsedSession = {
|
||||
*/
|
||||
export class ClaudeSessionIndexer implements ISessionIndexer {
|
||||
readonly provider = 'claude' as const;
|
||||
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||
|
||||
/**
|
||||
* Scans ~/.claude projects and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(lastScanAt: Date | null): Promise<number> {
|
||||
const claudeHome = path.join(os.homedir(), '.claude');
|
||||
const nameMap = await buildLookupMap(path.join(claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const files = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(claudeHome, 'projects'),
|
||||
path.join(this.claudeHome, 'projects'),
|
||||
'.jsonl',
|
||||
lastScanAt,
|
||||
);
|
||||
@@ -58,6 +58,34 @@ export class ClaudeSessionIndexer implements ISessionIndexer {
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Claude session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<boolean> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.workspacePath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Claude JSONL session file.
|
||||
*/
|
||||
|
||||
@@ -22,15 +22,15 @@ type ParsedSession = {
|
||||
*/
|
||||
export class CodexSessionIndexer implements ISessionIndexer {
|
||||
readonly provider = 'codex' as const;
|
||||
private readonly codexHome = path.join(os.homedir(), '.codex');
|
||||
|
||||
/**
|
||||
* Scans ~/.codex sessions and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(lastScanAt: Date | null): Promise<number> {
|
||||
const codexHome = path.join(os.homedir(), '.codex');
|
||||
const nameMap = await buildLookupMap(path.join(codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||
const files = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(codexHome, 'sessions'),
|
||||
path.join(this.codexHome, 'sessions'),
|
||||
'.jsonl',
|
||||
lastScanAt,
|
||||
);
|
||||
@@ -58,6 +58,34 @@ export class CodexSessionIndexer implements ISessionIndexer {
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Codex session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<boolean> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.workspacePath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Codex JSONL session file.
|
||||
*/
|
||||
|
||||
@@ -25,13 +25,13 @@ type ParsedSession = {
|
||||
*/
|
||||
export class CursorSessionIndexer implements ISessionIndexer {
|
||||
readonly provider = 'cursor' as const;
|
||||
private readonly cursorHome = path.join(os.homedir(), '.cursor');
|
||||
|
||||
/**
|
||||
* Scans Cursor chats and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(lastScanAt: Date | null): Promise<number> {
|
||||
const cursorHome = path.join(os.homedir(), '.cursor');
|
||||
const projectsDir = path.join(cursorHome, 'projects');
|
||||
const projectsDir = path.join(this.cursorHome, 'projects');
|
||||
const projectEntries = await listDirectoryEntriesSafe(projectsDir);
|
||||
const seenWorkspacePaths = new Set<string>();
|
||||
|
||||
@@ -49,7 +49,7 @@ export class CursorSessionIndexer implements ISessionIndexer {
|
||||
|
||||
seenWorkspacePaths.add(workspacePath);
|
||||
const workspaceHash = this.md5(workspacePath);
|
||||
const chatsDir = path.join(cursorHome, 'chats', workspaceHash);
|
||||
const chatsDir = path.join(this.cursorHome, 'chats', workspaceHash);
|
||||
const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', lastScanAt);
|
||||
|
||||
for (const filePath of files) {
|
||||
@@ -75,6 +75,33 @@ export class CursorSessionIndexer implements ISessionIndexer {
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Cursor session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<boolean> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.workspacePath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the same workspace hash Cursor uses in chat directory names.
|
||||
*/
|
||||
|
||||
@@ -21,19 +21,19 @@ type ParsedSession = {
|
||||
*/
|
||||
export class GeminiSessionIndexer implements ISessionIndexer {
|
||||
readonly provider = 'gemini' as const;
|
||||
private readonly geminiHome = path.join(os.homedir(), '.gemini');
|
||||
|
||||
/**
|
||||
* Scans Gemini session JSON files and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(lastScanAt: Date | null): Promise<number> {
|
||||
const geminiHome = path.join(os.homedir(), '.gemini');
|
||||
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(geminiHome, 'sessions'),
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.json',
|
||||
lastScanAt,
|
||||
);
|
||||
const tempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(geminiHome, 'tmp'),
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.json',
|
||||
lastScanAt,
|
||||
);
|
||||
@@ -42,7 +42,7 @@ export class GeminiSessionIndexer implements ISessionIndexer {
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (
|
||||
filePath.startsWith(path.join(geminiHome, 'tmp')) &&
|
||||
filePath.startsWith(path.join(this.geminiHome, 'tmp')) &&
|
||||
!filePath.includes(`${path.sep}chats${path.sep}`)
|
||||
) {
|
||||
continue;
|
||||
@@ -69,6 +69,40 @@ export class GeminiSessionIndexer implements ISessionIndexer {
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Gemini session JSON file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<boolean> {
|
||||
if (!filePath.endsWith('.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
filePath.startsWith(path.join(this.geminiHome, 'tmp')) &&
|
||||
!filePath.includes(`${path.sep}chats${path.sep}`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.workspacePath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini JSON artifact.
|
||||
*/
|
||||
|
||||
@@ -10,4 +10,9 @@ export interface ISessionIndexer {
|
||||
* Scans provider session artifacts and upserts discovered sessions into DB.
|
||||
*/
|
||||
synchronize(lastScanAt: Date | null): Promise<number>;
|
||||
|
||||
/**
|
||||
* Parses and upserts one provider artifact file without running a full directory scan.
|
||||
*/
|
||||
synchronizeFile?(filePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function findFilesRecursivelyCreatedAfter(
|
||||
fileList: string[] = [],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
console.log("HEY THERE!")
|
||||
const entries = await fsp.readdir(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
|
||||
@@ -66,36 +66,32 @@ test('llmSessionsService.synchronizeSessions aggregates processed counts and fai
|
||||
}
|
||||
});
|
||||
|
||||
// This test covers provider-specific sync behavior for both incremental and full-rescan modes.
|
||||
test('llmSessionsService.synchronizeProvider honors fullRescan option', { concurrency: false }, async () => {
|
||||
const observedScanDates: Array<Date | null> = [];
|
||||
const restoreScanDate = patchMethod(scanStateDb, 'getLastScannedAt', () => new Date('2026-04-02T00:00:00.000Z'));
|
||||
const restoreUpdateScanDate = patchMethod(scanStateDb, 'updateLastScannedAt', () => {});
|
||||
// This test covers single-file indexing delegation used by the watcher (no full provider rescan).
|
||||
test('llmSessionsService.synchronizeProviderFile delegates to provider indexer file sync', { concurrency: false }, async () => {
|
||||
let synchronizeCalls = 0;
|
||||
let synchronizeFilePath: string | null = null;
|
||||
const restoreIndexers = patchIndexers([
|
||||
{
|
||||
provider: 'cursor',
|
||||
async synchronize(lastScanAt) {
|
||||
observedScanDates.push(lastScanAt);
|
||||
return 7;
|
||||
provider: 'claude',
|
||||
async synchronize() {
|
||||
synchronizeCalls += 1;
|
||||
return 0;
|
||||
},
|
||||
async synchronizeFile(filePath: string) {
|
||||
synchronizeFilePath = filePath;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const incremental = await llmSessionsService.synchronizeProvider('cursor');
|
||||
const fullRescan = await llmSessionsService.synchronizeProvider('cursor', { fullRescan: true });
|
||||
|
||||
assert.equal(incremental.provider, 'cursor');
|
||||
assert.equal(incremental.processed, 7);
|
||||
assert.equal(fullRescan.provider, 'cursor');
|
||||
assert.equal(fullRescan.processed, 7);
|
||||
assert.equal(observedScanDates.length, 2);
|
||||
assert.ok(observedScanDates[0] instanceof Date);
|
||||
assert.equal(observedScanDates[1], null);
|
||||
const result = await llmSessionsService.synchronizeProviderFile('claude', '/tmp/claude-session.jsonl');
|
||||
assert.equal(result.provider, 'claude');
|
||||
assert.equal(result.indexed, true);
|
||||
assert.equal(synchronizeFilePath, '/tmp/claude-session.jsonl');
|
||||
assert.equal(synchronizeCalls, 0);
|
||||
} finally {
|
||||
restoreIndexers();
|
||||
restoreUpdateScanDate();
|
||||
restoreScanDate();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user