mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import { BaseSessionScanner, SessionFileScanEntry, SessionFileScanResult, SessionFileScanStats } from '@/modules/common/session/BaseSessionScanner';
|
|
import { logger } from '@/ui/logger';
|
|
import type { CodexSessionEvent } from './codexEventConverter';
|
|
|
|
interface CodexSessionScannerOptions {
|
|
transcriptPath: string | null;
|
|
onEvent: (event: CodexSessionEvent) => void;
|
|
onSessionId?: (sessionId: string) => void;
|
|
}
|
|
|
|
export interface CodexSessionScanner {
|
|
cleanup: () => Promise<void>;
|
|
setTranscriptPath: (transcriptPath: string) => Promise<void>;
|
|
}
|
|
|
|
export async function createCodexSessionScanner(opts: CodexSessionScannerOptions): Promise<CodexSessionScanner> {
|
|
const scanner = new CodexSessionScannerImpl(opts);
|
|
await scanner.start();
|
|
|
|
return {
|
|
cleanup: async () => {
|
|
await scanner.cleanup();
|
|
},
|
|
setTranscriptPath: async (transcriptPath: string) => {
|
|
await scanner.setTranscriptPath(transcriptPath);
|
|
}
|
|
};
|
|
}
|
|
|
|
class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
|
private transcriptPath: string | null;
|
|
private readonly onEvent: (event: CodexSessionEvent) => void;
|
|
private readonly onSessionId?: (sessionId: string) => void;
|
|
private readonly fileEpochByPath = new Map<string, number>();
|
|
private readonly fileSizeByPath = new Map<string, number>();
|
|
private observedSessionId: string | null = null;
|
|
|
|
constructor(opts: CodexSessionScannerOptions) {
|
|
super({ intervalMs: 2000 });
|
|
this.transcriptPath = opts.transcriptPath;
|
|
this.onEvent = opts.onEvent;
|
|
this.onSessionId = opts.onSessionId;
|
|
}
|
|
|
|
async setTranscriptPath(transcriptPath: string): Promise<void> {
|
|
if (this.transcriptPath === transcriptPath) {
|
|
return;
|
|
}
|
|
this.transcriptPath = transcriptPath;
|
|
await this.primeTranscript(transcriptPath);
|
|
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
|
|
this.invalidate();
|
|
}
|
|
|
|
protected async initialize(): Promise<void> {
|
|
if (this.transcriptPath) {
|
|
await this.primeTranscript(this.transcriptPath);
|
|
}
|
|
}
|
|
|
|
protected async findSessionFiles(): Promise<string[]> {
|
|
if (!this.transcriptPath) {
|
|
return [];
|
|
}
|
|
return [this.transcriptPath];
|
|
}
|
|
|
|
protected shouldWatchFile(filePath: string): boolean {
|
|
return Boolean(this.transcriptPath && filePath === this.transcriptPath);
|
|
}
|
|
|
|
protected async parseSessionFile(filePath: string, cursor: number): Promise<SessionFileScanResult<CodexSessionEvent>> {
|
|
return this.readSessionFile(filePath, cursor);
|
|
}
|
|
|
|
protected generateEventKey(_event: CodexSessionEvent, context: { filePath: string; lineIndex?: number }): string {
|
|
const epoch = this.fileEpochByPath.get(context.filePath) ?? 0;
|
|
return `${context.filePath}:${epoch}:${context.lineIndex ?? -1}`;
|
|
}
|
|
|
|
protected async handleFileScan(stats: SessionFileScanStats<CodexSessionEvent>): Promise<void> {
|
|
for (const event of stats.events) {
|
|
this.onEvent(event);
|
|
}
|
|
if (stats.newCount > 0) {
|
|
logger.debug(`[codex-session-scanner] ${stats.newCount} new events from ${stats.filePath}`);
|
|
}
|
|
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
|
|
}
|
|
|
|
private async primeTranscript(filePath: string): Promise<void> {
|
|
const { events, nextCursor } = await this.readSessionFile(filePath, 0);
|
|
const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex }));
|
|
this.seedProcessedKeys(keys);
|
|
this.setCursor(filePath, nextCursor);
|
|
}
|
|
|
|
private async readSessionFile(filePath: string, startLine: number): Promise<SessionFileScanResult<CodexSessionEvent>> {
|
|
let content: string;
|
|
try {
|
|
content = await readFile(filePath, 'utf-8');
|
|
} catch (error) {
|
|
logger.debug(`[codex-session-scanner] Failed to read transcript ${filePath}: ${error}`);
|
|
return { events: [], nextCursor: startLine };
|
|
}
|
|
|
|
const lines = content.split('\n');
|
|
const hasTrailingEmpty = lines.length > 0 && lines[lines.length - 1] === '';
|
|
const totalLines = hasTrailingEmpty ? lines.length - 1 : lines.length;
|
|
const currentSize = Buffer.byteLength(content);
|
|
const previousSize = this.fileSizeByPath.get(filePath);
|
|
let effectiveStartLine = startLine;
|
|
|
|
if ((previousSize !== undefined && currentSize < previousSize) || effectiveStartLine > totalLines) {
|
|
effectiveStartLine = 0;
|
|
const nextEpoch = (this.fileEpochByPath.get(filePath) ?? 0) + 1;
|
|
this.fileEpochByPath.set(filePath, nextEpoch);
|
|
}
|
|
this.fileSizeByPath.set(filePath, currentSize);
|
|
|
|
const events: SessionFileScanEntry<CodexSessionEvent>[] = [];
|
|
for (let lineIndex = 0; lineIndex < totalLines; lineIndex += 1) {
|
|
const line = lines[lineIndex];
|
|
if (!line || line.trim().length === 0) {
|
|
continue;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch (error) {
|
|
logger.debug(`[codex-session-scanner] Failed to parse transcript line ${filePath}:${lineIndex + 1}: ${error}`);
|
|
continue;
|
|
}
|
|
|
|
const event = parseCodexSessionEvent(parsed);
|
|
if (!event) {
|
|
continue;
|
|
}
|
|
|
|
if (event.type === 'session_meta') {
|
|
const sessionId = extractSessionId(event);
|
|
if (sessionId) {
|
|
this.updateSessionId(sessionId);
|
|
}
|
|
}
|
|
|
|
if (lineIndex < effectiveStartLine) {
|
|
continue;
|
|
}
|
|
|
|
events.push({ event, lineIndex });
|
|
}
|
|
|
|
return {
|
|
events,
|
|
nextCursor: totalLines
|
|
};
|
|
}
|
|
|
|
private updateSessionId(sessionId: string): void {
|
|
if (this.observedSessionId === sessionId) {
|
|
return;
|
|
}
|
|
this.observedSessionId = sessionId;
|
|
this.onSessionId?.(sessionId);
|
|
}
|
|
}
|
|
|
|
function parseCodexSessionEvent(value: unknown): CodexSessionEvent | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.type !== 'string' || record.type.length === 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined,
|
|
type: record.type,
|
|
payload: record.payload
|
|
};
|
|
}
|
|
|
|
function extractSessionId(event: CodexSessionEvent): string | null {
|
|
if (!event.payload || typeof event.payload !== 'object') {
|
|
return null;
|
|
}
|
|
const payload = event.payload as Record<string, unknown>;
|
|
return typeof payload.id === 'string' && payload.id.length > 0 ? payload.id : null;
|
|
}
|