fix: on file update, no directory rescan is needed

This commit is contained in:
Haileyesus
2026-04-06 22:18:08 +03:00
parent 28aa5a3902
commit f576b8e6d2
17 changed files with 372 additions and 126 deletions

View File

@@ -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.
*/

View File

@@ -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 {

View File

@@ -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.
*/

View 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.
*/

View 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.
*/

View File

@@ -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.
*/

View File

@@ -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>;
}

View File

@@ -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);

View File

@@ -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();
}
});