/** * SearchTextExtractor - Lightweight text extraction for search. * * Mirrors ChunkBuilder's classification loop (classifyMessages → buffer flush) * but only extracts searchable text + metadata, skipping all expensive operations: * - No tool execution building * - No semantic step extraction * - No subagent linking * - No timeline gap filling * - No metrics calculation */import { classifyMessages } from '@main/services/parsing/MessageClassifier';import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';import type { ParsedMessage } from '@main/types';/** * A lightweight entry containing only the data needed for search matching. */export interface SearchableEntry { text: string; groupId: string; messageType: 'user' | 'assistant'; itemType: 'user' | 'ai'; timestamp: number; messageUuid: string;}/** * Result of extracting searchable text from a session's messages. */export interface SearchTextResult { entries: SearchableEntry[]; sessionTitle: string | undefined;}/** * Extract searchable text entries from parsed messages. * * Algorithm mirrors ChunkBuilder.buildChunks() lines 78-151: * - Filter to main thread (!m.isSidechain) * - classifyMessages() — cheap type guard checks * - Walk classified messages with an aiBuffer: * - hardNoise → skip * - compact / system / user → flush AI buffer, then handle * - ai → push to buffer * - Flush remaining buffer at end */export function extractSearchableEntries(messages: ParsedMessage[]): SearchTextResult { const entries: SearchableEntry[] = []; let sessionTitle: string | undefined; // Filter to main thread messages (non-sidechain) — same as ChunkBuilder line 82 const mainMessages = messages.filter((m) => !m.isSidechain); const classified = classifyMessages(mainMessages); let aiBuffer: ParsedMessage[] = []; for (const { message, category } of classified) { switch (category) { case 'hardNoise': // Skip — filtered out break; case 'compact': case 'system': // Flush AI buffer, but compact/system messages have no searchable text if (aiBuffer.length > 0) { const aiEntry = extractAIEntry(aiBuffer); if (aiEntry) entries.push(aiEntry); aiBuffer = []; } break; case 'user': { // Flush AI buffer if (aiBuffer.length > 0) { const aiEntry = extractAIEntry(aiBuffer); if (aiEntry) entries.push(aiEntry); aiBuffer = []; } // Extract user text const userText = extractUserText(message); if (userText) { if (!sessionTitle) { sessionTitle = userText.slice(0, 100); } entries.push({ text: userText, groupId: `user-${message.uuid}`, messageType: 'user', itemType: 'user', timestamp: message.timestamp.getTime(), messageUuid: message.uuid, }); } break; } case 'ai': aiBuffer.push(message); break; } } // Flush remaining AI buffer if (aiBuffer.length > 0) { const aiEntry = extractAIEntry(aiBuffer); if (aiEntry) entries.push(aiEntry); } return { entries, sessionTitle };}/** * Extract the last text output from an AI message buffer. * Scans backward for the last assistant message with a text content block. */function extractAIEntry(buffer: ParsedMessage[]): SearchableEntry | null { // Scan backward for last assistant message with text content for (let i = buffer.length - 1; i >= 0; i--) { const msg = buffer[i]; if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue; // Find the last text block in this message for (let j = msg.content.length - 1; j >= 0; j--) { const block = msg.content[j]; if (block.type === 'text' && block.text) { return { text: block.text, groupId: `ai-${buffer[0].uuid}`, messageType: 'assistant', itemType: 'ai', timestamp: msg.timestamp.getTime(), messageUuid: msg.uuid, }; } } } return null;}/** * Extract searchable text from a user message. * Shared logic previously in SessionSearcher.extractUserSearchableText(). */export function extractUserText(message: ParsedMessage): string { let rawText = ''; if (typeof message.content === 'string') { rawText = message.content; } else if (Array.isArray(message.content)) { rawText = message.content .filter((block) => block.type === 'text') .map((block) => block.text) .join(''); } return sanitizeDisplayContent(rawText);}
/**
* SearchTextExtractor - Lightweight text extraction for search.
*
* Mirrors ChunkBuilder's classification loop (classifyMessages → buffer flush)
* but only extracts searchable text + metadata, skipping all expensive operations:
* - No tool execution building
* - No semantic step extraction
* - No subagent linking
* - No timeline gap filling
* - No metrics calculation
*/
import { classifyMessages } from '@main/services/parsing/MessageClassifier';
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import type { ParsedMessage } from '@main/types';
/**
* A lightweight entry containing only the data needed for search matching.
*/
export interface SearchableEntry {
text: string;
groupId: string;
messageType: 'user' | 'assistant';
itemType: 'user' | 'ai';
timestamp: number;
messageUuid: string;
}
/**
* Result of extracting searchable text from a session's messages.
*/
export interface SearchTextResult {
entries: SearchableEntry[];
sessionTitle: string | undefined;
}
/**
* Extract searchable text entries from parsed messages.
*
* Algorithm mirrors ChunkBuilder.buildChunks() lines 78-151:
* - Filter to main thread (!m.isSidechain)
* - classifyMessages() — cheap type guard checks
* - Walk classified messages with an aiBuffer:
* - hardNoise → skip
* - compact / system / user → flush AI buffer, then handle
* - ai → push to buffer
* - Flush remaining buffer at end
*/
export function extractSearchableEntries(messages: ParsedMessage[]): SearchTextResult {
const entries: SearchableEntry[] = [];
let sessionTitle: string | undefined;
// Filter to main thread messages (non-sidechain) — same as ChunkBuilder line 82
const mainMessages = messages.filter((m) => !m.isSidechain);
const classified = classifyMessages(mainMessages);
let aiBuffer: ParsedMessage[] = [];
for (const { message, category } of classified) {
switch (category) {
case 'hardNoise':
// Skip — filtered out
break;
case 'compact':
case 'system':
// Flush AI buffer, but compact/system messages have no searchable text
if (aiBuffer.length > 0) {
const aiEntry = extractAIEntry(aiBuffer);
if (aiEntry) entries.push(aiEntry);
aiBuffer = [];
}
break;
case 'user': {
// Flush AI buffer
if (aiBuffer.length > 0) {
const aiEntry = extractAIEntry(aiBuffer);
if (aiEntry) entries.push(aiEntry);
aiBuffer = [];
}
// Extract user text
const userText = extractUserText(message);
if (userText) {
if (!sessionTitle) {
sessionTitle = userText.slice(0, 100);
}
entries.push({
text: userText,
groupId: `user-${message.uuid}`,
messageType: 'user',
itemType: 'user',
timestamp: message.timestamp.getTime(),
messageUuid: message.uuid,
});
}
break;
}
case 'ai':
aiBuffer.push(message);
break;
}
}
// Flush remaining AI buffer
if (aiBuffer.length > 0) {
const aiEntry = extractAIEntry(aiBuffer);
if (aiEntry) entries.push(aiEntry);
}
return { entries, sessionTitle };
}
/**
* Extract the last text output from an AI message buffer.
* Scans backward for the last assistant message with a text content block.
*/
function extractAIEntry(buffer: ParsedMessage[]): SearchableEntry | null {
// Scan backward for last assistant message with text content
for (let i = buffer.length - 1; i >= 0; i--) {
const msg = buffer[i];
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
// Find the last text block in this message
for (let j = msg.content.length - 1; j >= 0; j--) {
const block = msg.content[j];
if (block.type === 'text' && block.text) {
return {
text: block.text,
groupId: `ai-${buffer[0].uuid}`,
messageType: 'assistant',
itemType: 'ai',
timestamp: msg.timestamp.getTime(),
messageUuid: msg.uuid,
};
}
}
}
return null;
}
/**
* Extract searchable text from a user message.
* Shared logic previously in SessionSearcher.extractUserSearchableText().
*/
export function extractUserText(message: ParsedMessage): string {
let rawText = '';
if (typeof message.content === 'string') {
rawText = message.content;
} else if (Array.isArray(message.content)) {
rawText = message.content
.filter((block) => block.type === 'text')
.map((block) => block.text)
.join('');
}
return sanitizeDisplayContent(rawText);
}