- Added support for a new worker in the team file system to list teams, improving performance and reliability in team management. - Introduced event loop lag monitoring in various IPC handlers to track and log slow operations, enhancing observability. - Implemented caching for CLI installation status to reduce redundant calls and improve responsiveness. - Updated project and team data services to include total session counts, optimizing data handling and performance. - Enhanced error handling and logging across multiple services to provide clearer insights into performance issues and failures. Made-with: Cursor
261 lines
6.2 KiB
TypeScript
261 lines
6.2 KiB
TypeScript
/**
|
|
* Metadata extraction utilities for parsing first messages and session context from JSONL files.
|
|
*/
|
|
|
|
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import * as readline from 'readline';
|
|
|
|
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
|
|
import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types';
|
|
|
|
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
|
|
|
const logger = createLogger('Util:metadataExtraction');
|
|
|
|
const defaultProvider = new LocalFileSystemProvider();
|
|
|
|
const JSONL_HEAD_TIMEOUT_MS = 2000;
|
|
const JSONL_HEAD_MAX_BYTES = 256 * 1024;
|
|
const JSONL_HEAD_MAX_LINES = 400;
|
|
|
|
interface MessagePreview {
|
|
text: string;
|
|
timestamp: string;
|
|
isCommand: boolean;
|
|
}
|
|
|
|
function byteLen(chunk: string): number {
|
|
return Buffer.byteLength(chunk, 'utf8');
|
|
}
|
|
|
|
/**
|
|
* Extract CWD (current working directory) from the first entry.
|
|
* Used to get the actual project path from encoded directory names.
|
|
*/
|
|
export async function extractCwd(
|
|
filePath: string,
|
|
fsProvider: FileSystemProvider = defaultProvider
|
|
): Promise<string | null> {
|
|
if (!(await fsProvider.exists(filePath))) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const stat = await fsProvider.stat(filePath);
|
|
if (!stat.isFile()) {
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
|
let bytes = 0;
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
fileStream.destroy();
|
|
}, JSONL_HEAD_TIMEOUT_MS);
|
|
fileStream.on('data', (chunk: string) => {
|
|
bytes += byteLen(chunk);
|
|
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
|
fileStream.destroy();
|
|
}
|
|
});
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
try {
|
|
let lines = 0;
|
|
for await (const line of rl) {
|
|
if (++lines > JSONL_HEAD_MAX_LINES) {
|
|
break;
|
|
}
|
|
if (!line.trim()) continue;
|
|
|
|
let entry: ChatHistoryEntry;
|
|
try {
|
|
entry = JSON.parse(line) as ChatHistoryEntry;
|
|
} catch {
|
|
continue;
|
|
}
|
|
// Only conversational entries have cwd
|
|
if ('cwd' in entry && entry.cwd) {
|
|
rl.close();
|
|
fileStream.destroy();
|
|
return entry.cwd;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!timedOut) {
|
|
logger.debug(`Error extracting cwd from ${filePath}:`, error);
|
|
}
|
|
} finally {
|
|
clearTimeout(timer);
|
|
rl.close();
|
|
fileStream.destroy();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract a lightweight title preview from the first user message.
|
|
* For command-style sessions, falls back to a slash-command label.
|
|
*/
|
|
export async function extractFirstUserMessagePreview(
|
|
filePath: string,
|
|
fsProvider: FileSystemProvider = defaultProvider,
|
|
maxLines: number = 200
|
|
): Promise<{ text: string; timestamp: string } | null> {
|
|
const safeMaxLines = Math.max(1, maxLines);
|
|
try {
|
|
const stat = await fsProvider.stat(filePath);
|
|
if (!stat.isFile()) {
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
|
let bytes = 0;
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
fileStream.destroy();
|
|
}, JSONL_HEAD_TIMEOUT_MS);
|
|
fileStream.on('data', (chunk: string) => {
|
|
bytes += byteLen(chunk);
|
|
if (bytes > JSONL_HEAD_MAX_BYTES) {
|
|
fileStream.destroy();
|
|
}
|
|
});
|
|
const rl = readline.createInterface({
|
|
input: fileStream,
|
|
crlfDelay: Infinity,
|
|
});
|
|
|
|
let commandFallback: { text: string; timestamp: string } | null = null;
|
|
let linesRead = 0;
|
|
|
|
try {
|
|
for await (const line of rl) {
|
|
if (linesRead++ >= safeMaxLines) {
|
|
break;
|
|
}
|
|
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
let entry: ChatHistoryEntry;
|
|
try {
|
|
entry = JSON.parse(trimmed) as ChatHistoryEntry;
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (entry.type !== 'user') {
|
|
continue;
|
|
}
|
|
|
|
const preview = extractPreviewFromUserEntry(entry);
|
|
if (!preview) {
|
|
continue;
|
|
}
|
|
|
|
if (!preview.isCommand) {
|
|
return { text: preview.text, timestamp: preview.timestamp };
|
|
}
|
|
|
|
if (!commandFallback) {
|
|
commandFallback = { text: preview.text, timestamp: preview.timestamp };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!timedOut) {
|
|
logger.debug(`Error extracting first user preview from ${filePath}:`, error);
|
|
}
|
|
return commandFallback;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
rl.close();
|
|
fileStream.destroy();
|
|
}
|
|
|
|
return commandFallback;
|
|
}
|
|
|
|
function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
|
const timestamp = entry.timestamp ?? new Date().toISOString();
|
|
const message = entry.message;
|
|
if (!message) {
|
|
return null;
|
|
}
|
|
|
|
const content = message.content;
|
|
if (typeof content === 'string') {
|
|
if (isCommandOutputContent(content) || content.startsWith('[Request interrupted by user')) {
|
|
return null;
|
|
}
|
|
|
|
if (content.startsWith('<command-name>')) {
|
|
return {
|
|
text: extractCommandName(content),
|
|
timestamp,
|
|
isCommand: true,
|
|
};
|
|
}
|
|
|
|
const sanitized = sanitizeDisplayContent(content).trim();
|
|
if (!sanitized) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: sanitized.substring(0, 500),
|
|
timestamp,
|
|
isCommand: false,
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(content)) {
|
|
return null;
|
|
}
|
|
|
|
const textContent = content
|
|
.filter(isTextContent)
|
|
.map((block) => block.text)
|
|
.join(' ')
|
|
.trim();
|
|
if (!textContent || textContent.startsWith('[Request interrupted by user')) {
|
|
return null;
|
|
}
|
|
|
|
if (textContent.startsWith('<command-name>')) {
|
|
return {
|
|
text: extractCommandName(textContent),
|
|
timestamp,
|
|
isCommand: true,
|
|
};
|
|
}
|
|
|
|
const sanitized = sanitizeDisplayContent(textContent).trim();
|
|
if (!sanitized) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: sanitized.substring(0, 500),
|
|
timestamp,
|
|
isCommand: false,
|
|
};
|
|
}
|
|
|
|
function extractCommandName(content: string): string {
|
|
const commandMatch = /<command-name>\/([^<]+)<\/command-name>/.exec(content);
|
|
return commandMatch?.[1] ? `/${commandMatch[1]}` : '/command';
|
|
}
|