import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { createReadStream } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; const logger = createLogger('Service:TeamMemberLogsFinder'); /** * Phase 1: How many lines to scan for member attribution. * Detection signals (process.team.memberName, "You are {name}", routing.sender) * appear in the first ~10 lines, so 50 is very conservative. */ const ATTRIBUTION_SCAN_LINES = 50; interface StreamedMetadata { firstTimestamp: string | null; lastTimestamp: string | null; messageCount: number; } function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { const ch = value.charCodeAt(end - 1); // '/' or '\' if (ch === 47 || ch === 92) { end--; continue; } break; } return end === value.length ? value : value.slice(0, end); } export class TeamMemberLogsFinder { constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() ) {} async findMemberLogs(teamName: string, memberName: string): Promise { const discovery = await this.discoverMemberFiles(teamName, memberName); if (!discovery) return []; const { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember } = discovery; const results: MemberLogSummary[] = []; const leadMemberName = config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; if (isLeadMember && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); const leadSummary = await this.parseLeadSessionSummary( leadJsonl, projectId, config.leadSessionId, leadMemberName ); if (leadSummary) { results.push(leadSummary); } } for (const sessionId of sessionIds) { const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let files: string[]; try { files = await fs.readdir(subagentsDir); } catch { continue; } for (const file of files) { if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; if (file.startsWith('agent-acompact')) continue; const filePath = path.join(subagentsDir, file); const summary = await this.parseSubagentSummary( filePath, projectId, sessionId, file, memberName, knownMembers ); if (summary) results.push(summary); } } return results.sort( (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() ); } /** * Returns absolute paths to all JSONL files belonging to the specified member. * Uses the same discovery logic as findMemberLogs but collects file paths. */ async findMemberLogPaths(teamName: string, memberName: string): Promise { const discovery = await this.discoverMemberFiles(teamName, memberName); if (!discovery) return []; const { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember } = discovery; const paths: string[] = []; if (isLeadMember && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); paths.push(leadJsonl); } catch { // File doesn't exist } } for (const sessionId of sessionIds) { const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let files: string[]; try { files = await fs.readdir(subagentsDir); } catch { continue; } for (const file of files) { if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; if (file.startsWith('agent-acompact')) continue; const filePath = path.join(subagentsDir, file); // Quick attribution check — only Phase 1 (no full-file streaming) const attribution = await this.attributeSubagent(filePath, knownMembers); if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { paths.push(filePath); } } } return paths; } private async discoverMemberFiles( teamName: string, memberName: string ): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; isLeadMember: boolean; } | null> { const config = await this.configReader.getConfig(teamName); if (!config?.projectPath) { logger.debug(`No projectPath for team "${teamName}"`); return null; } const normalizedProjectPath = trimTrailingSlashes(config.projectPath); const projectId = encodePath(normalizedProjectPath); const baseDir = extractBaseDir(projectId); const projectDir = path.join(getProjectsBasePath(), baseDir); const leadMemberName = config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase(); // Collect all known session IDs: current lead + history const knownSessionIds = new Set(); if (config.leadSessionId) { knownSessionIds.add(config.leadSessionId); } if (Array.isArray(config.sessionHistory)) { for (const sid of config.sessionHistory) { if (typeof sid === 'string' && sid.trim().length > 0) { knownSessionIds.add(sid.trim()); } } } let sessionIds: string[]; if (knownSessionIds.size > 0) { // Verify each known session dir exists, fall back to full scan if none exist const verified: string[] = []; for (const sid of knownSessionIds) { const sidDir = path.join(projectDir, sid); try { const stat = await fs.stat(sidDir); if (stat.isDirectory()) { verified.push(sid); } } catch { // dir doesn't exist, skip } } sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir); } else { sessionIds = await this.listSessionDirs(projectDir); } const knownMembers = new Set( (config.members ?? []) .map((member) => member.name?.trim().toLowerCase()) .filter((name): name is string => Boolean(name && name.length > 0)) ); try { const metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const normalized = member.name.trim().toLowerCase(); if (normalized.length > 0) { knownMembers.add(normalized); } } } catch { // Best-effort enrichment. } try { const inboxMembers = await this.inboxReader.listInboxNames(teamName); for (const memberNameFromInbox of inboxMembers) { const normalized = memberNameFromInbox.trim().toLowerCase(); if (normalized.length > 0) { knownMembers.add(normalized); } } } catch { // Best-effort enrichment. } return { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember }; } private async listSessionDirs(projectDir: string): Promise { try { const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name); } catch { logger.debug(`Cannot read project dir: ${projectDir}`); return []; } } private async parseSubagentSummary( filePath: string, projectId: string, sessionId: string, fileName: string, targetMember: string, knownMembers: Set ): Promise { const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, ''); // ── Phase 1: Attribution (first N lines) ── // Detect which member owns this file + extract description. // All detection signals appear in the first few lines of the JSONL. const attribution = await this.attributeSubagent(filePath, knownMembers); if (!attribution) return null; const targetLower = targetMember.toLowerCase(); if (attribution.detectedMember.toLowerCase() !== targetLower) { return null; } // ── Phase 2: Metadata (stream entire file) ── // Now that we know the file belongs to this member, collect // accurate timestamps and message count from the full file. const metadata = await this.streamFileMetadata(filePath); const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); const endTime = new Date(lastTimestamp); const durationMs = endTime.getTime() - startTime.getTime(); // Check if the file might still be active (modified recently) let isOngoing = false; try { const stat = await fs.stat(filePath); const ageMs = Date.now() - stat.mtimeMs; isOngoing = ageMs < 60_000; // Active within last minute } catch { // ignore } return { kind: 'subagent', subagentId, sessionId, projectId, description: attribution.description || `Subagent ${subagentId}`, memberName: targetMember, startTime: firstTimestamp, durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, }; } /** * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals * and extract a human-readable description from the first user message. * Returns null if the file is a warmup session or empty. */ private async attributeSubagent( filePath: string, knownMembers: Set ): Promise<{ detectedMember: string; description: string } | null> { const lines: string[] = []; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let count = 0; for await (const line of rl) { if (count >= ATTRIBUTION_SCAN_LINES) break; const trimmed = line.trim(); if (trimmed) { lines.push(trimmed); count++; } } rl.close(); stream.destroy(); } catch { return null; } if (lines.length === 0) return null; let description = ''; let detectedMember: string | null = null; let detectionPriority = 0; for (const line of lines) { // Early exit: both objectives met (member detected at max priority + description found) if (detectionPriority >= 3 && description) break; try { const msg = JSON.parse(line) as Record; const role = this.extractRole(msg); const textContent = this.extractTextContent(msg); // Skip warmup messages if (role === 'user' && textContent?.trim() === 'Warmup') { return null; } // Extract description from first user message + teammate_id attribution if (role === 'user' && textContent) { if (textContent.trimStart().startsWith(' 0 && knownMembers.has(tmId)) { detectedMember = parsed[0].teammateId.trim(); detectionPriority = 3; } } } else if (!description) { description = textContent.slice(0, 200); } } // --- Multi-signal member detection --- // Higher priority signals override lower priority ones (skip if already at max) if (detectionPriority < 3) { const detection = this.detectMemberFromMessage(msg, knownMembers); if (detection && detection.priority > detectionPriority) { detectedMember = detection.name; detectionPriority = detection.priority; } } // Check toolUseResult routing (highest priority — directly identifies the agent) if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') { const routing = (msg.toolUseResult as Record).routing as | Record | undefined; if (routing && typeof routing.sender === 'string') { const sender = routing.sender.toLowerCase(); if (knownMembers.has(sender)) { detectedMember = routing.sender; detectionPriority = 3; } } } // Check process.team.memberName from system messages (highest priority) if (detectionPriority < 3) { const init = msg.init as Record | undefined; const process = (msg.process ?? init?.process) as Record | undefined; const team = process?.team as Record | undefined; if (team && typeof team.memberName === 'string') { const memberNameLower = team.memberName.trim().toLowerCase(); if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) { detectedMember = team.memberName.trim(); detectionPriority = 3; } } } } catch { // Skip malformed lines } } if (!detectedMember) return null; return { detectedMember, description }; } /** * Detects the member name from a parsed JSONL message using multiple signals. * Returns a detection result with the name and a priority level: * 3 = routing sender (highest, handled outside this method) * 2 = "You are {name}" spawn prompt * 1 = text-based fallback (single member match or task assignment context) */ private detectMemberFromMessage( msg: Record, knownMembers: Set ): { name: string; priority: number } | null { const text = this.extractTextContent(msg); if (!text) return null; // Signal 1 (priority 2): "You are {name}, a {role}" pattern (spawn prompt) const youAreMatch = /\bYou are (\w[\w-]*),\s+a\s+/i.exec(text); if (youAreMatch) { const name = youAreMatch[1].toLowerCase(); if (knownMembers.has(name)) { return { name: youAreMatch[1], priority: 2 }; } } // Signal 2 (priority 1): Task assignment — look for member name in the task content if (text.includes('New task assigned to you') || text.includes('task assigned')) { for (const name of knownMembers) { const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i'); if (regex.test(text)) { return { name: findOriginalCase(text, name), priority: 1 }; } } } // Signal 3 (priority 1): General fallback — check if exactly one known member // name appears in the first user message content (word-boundary match) if (msg.role === 'user') { const matches: string[] = []; for (const name of knownMembers) { const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i'); if (regex.test(text)) { matches.push(name); } } // Only attribute if exactly one member matches (avoid ambiguity) if (matches.length === 1) { return { name: findOriginalCase(text, matches[0]), priority: 1 }; } } return null; } private extractTextContent(msg: Record): string | null { if (typeof msg.content === 'string') { return msg.content; } if (Array.isArray(msg.content)) { const textParts = (msg.content as Record[]) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) return textParts.join(' '); } // Also check message wrapper if (msg.message && typeof msg.message === 'object') { return this.extractTextContent(msg.message as Record); } return null; } private extractRole(msg: Record): string | null { if (typeof msg.role === 'string') { return msg.role; } if (msg.message && typeof msg.message === 'object') { const inner = msg.message as Record; if (typeof inner.role === 'string') { return inner.role; } } return null; } private async parseLeadSessionSummary( jsonlPath: string, projectId: string, sessionId: string, memberName: string ): Promise { try { await fs.access(jsonlPath); } catch { return null; } const metadata = await this.streamFileMetadata(jsonlPath); const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(jsonlPath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); const endTime = new Date(lastTimestamp); const durationMs = endTime.getTime() - startTime.getTime(); let isOngoing = false; try { const stat = await fs.stat(jsonlPath); const ageMs = Date.now() - stat.mtimeMs; isOngoing = ageMs < 60_000; } catch { // ignore } return { kind: 'lead_session', sessionId, projectId, description: 'Lead session', memberName, startTime: firstTimestamp, durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, }; } /** * Stream entire JSONL file collecting only timestamps and message count. * Lightweight — uses regex to extract timestamp without full JSON parse. */ private async streamFileMetadata(filePath: string): Promise { let firstTimestamp: string | null = null; let lastTimestamp: string | null = null; let messageCount = 0; try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; messageCount++; // Fast timestamp extraction without full JSON parse. // ISO prefix anchor avoids false positives from "timestamp" inside string values. const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(trimmed); if (tsMatch) { if (!firstTimestamp) firstTimestamp = tsMatch[1]; lastTimestamp = tsMatch[1]; } } rl.close(); stream.destroy(); } catch { // ignore — return whatever we collected so far } return { firstTimestamp, lastTimestamp, messageCount }; } private async getFileMtime(filePath: string): Promise { try { const stat = await fs.stat(filePath); return stat.mtime.toISOString(); } catch { return new Date().toISOString(); } } } function findOriginalCase(text: string, lowerName: string): string { const regex = new RegExp(`\\b(${escapeRegex(lowerName)})\\b`, 'i'); const match = regex.exec(text); return match ? match[1] : lowerName; } function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }