import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { extractCwd } from '@main/utils/jsonl'; import { encodePath, extractBaseDir, getProjectsBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { createReadStream, type Dirent } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; import type { TeamConfig } from '@shared/types'; const logger = createLogger('Service:TeamTranscriptProjectResolver'); const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; const ROOT_DISCOVERY_CONCURRENCY = 12; type ProjectEvidenceSource = | 'projectPath' | 'projectPathHistory' | 'leadCwd' | 'memberCwd' | 'projectsScan'; interface ProjectPathCandidate { projectPath: string; source: Exclude; } interface ProjectDirCandidate { projectPath: string; projectDir: string; projectId: string; source: ProjectEvidenceSource; } interface SessionProjectMatch extends ProjectDirCandidate { matchedSessionId: string; } interface TeamTranscriptProjectConfigReader { getConfig(teamName: string): Promise; getConfigSnapshot?: (teamName: string) => Promise; } type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { const ch = value.charCodeAt(end - 1); if (ch === 47 || ch === 92) { end -= 1; continue; } break; } return end === value.length ? value : value.slice(0, end); } function isSessionDirectoryName(name: string): boolean { return name !== 'memory' && !name.startsWith('.'); } function normalizeProjectPathCandidate(value: unknown): string | null { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return trimTrailingSlashes(trimmed); } function extractTextContent(entry: Record): string | null { if (typeof entry.content === 'string') { return entry.content; } if (Array.isArray(entry.content)) { const textParts = (entry.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(' '); } } if (entry.message && typeof entry.message === 'object') { return extractTextContent(entry.message as Record); } return null; } function extractDirectTeamName(entry: Record): string | null { if (typeof entry.teamName === 'string') { return entry.teamName.trim().toLowerCase(); } const process = entry.process as Record | undefined; const processTeam = process?.team as Record | undefined; if (typeof processTeam?.teamName === 'string') { return processTeam.teamName.trim().toLowerCase(); } return null; } function lineMentionsTeam(text: string, teamName: string): boolean { const normalizedText = text.trim().toLowerCase(); const normalizedTeam = teamName.trim().toLowerCase(); if (!normalizedText.includes(normalizedTeam)) { return false; } return ( normalizedText.includes(`team name: ${normalizedTeam}`) || normalizedText.includes(`team name "${normalizedTeam}"`) || normalizedText.includes(`team name '${normalizedTeam}'`) || normalizedText.includes(`on team "${normalizedTeam}"`) || normalizedText.includes(`on team '${normalizedTeam}'`) || normalizedText.includes(`team "${normalizedTeam}"`) || normalizedText.includes(`team '${normalizedTeam}'`) || normalizedText.includes(`(${normalizedTeam})`) ); } function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean { if (!value || depth > 8 || typeof value !== 'object') { return false; } if (Array.isArray(value)) { return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1)); } const entry = value as Record; if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) { return true; } return Object.entries(entry).some(([key, nested]) => { if (key === 'teamName') { return false; } return entryContainsNestedTeamName(nested, teamName, depth + 1); }); } function collectKnownSessionIds(config: TeamConfig): string[] { const knownSessionIds = new Set(); const push = (value: unknown): void => { if (typeof value !== 'string') { return; } const trimmed = value.trim(); if (trimmed.length > 0) { knownSessionIds.add(trimmed); } }; push(config.leadSessionId); if (Array.isArray(config.sessionHistory)) { for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) { const sessionId = config.sessionHistory[index]; push(sessionId); } } return [...knownSessionIds]; } export interface TeamTranscriptProjectContext { projectDir: string; projectId: string; config: TeamConfig; sessionIds: string[]; } export interface TeamTranscriptProjectLiveBaseContext { projectDir: string; projectId: string; config: TeamConfig; } export class TeamTranscriptProjectResolver { private readonly contextCache = new Map< string, { value: TeamTranscriptProjectContext; expiresAt: number } >(); constructor( private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() ) {} private readConfigForObservation(teamName: string): Promise { return typeof this.configReader.getConfigSnapshot === 'function' ? this.configReader.getConfigSnapshot(teamName) : this.configReader.getConfig(teamName); } async getLiveBaseContext( teamName: string, options?: { forceRefresh?: boolean; extraProjectPathCandidates?: readonly unknown[] } ): Promise { if (options?.forceRefresh) { this.contextCache.delete(teamName); } const config = await this.readConfigForObservation(teamName); if (!config) { return null; } const projectPathCandidates = this.collectLiveProjectPathCandidates( config, options?.extraProjectPathCandidates ?? [] ); const resolution = await this.resolveLiveProjectDirectoryFromCandidates(projectPathCandidates); if (!resolution) { return null; } const resolvedConfig = trimTrailingSlashes(config.projectPath ?? '') !== trimTrailingSlashes(resolution.effectiveProjectPath) ? { ...config, projectPath: resolution.effectiveProjectPath } : config; return { projectDir: resolution.projectDir, projectId: resolution.projectId, config: resolvedConfig, }; } async getContext( teamName: string, options?: { forceRefresh?: boolean } ): Promise { if (options?.forceRefresh) { this.contextCache.delete(teamName); } const cached = this.contextCache.get(teamName); if (cached && cached.expiresAt > Date.now()) { return cached.value; } const config = await this.readConfigForObservation(teamName); if (!config) { return null; } const resolution = await this.resolveProjectDirectory(teamName, config); if (!resolution) { return null; } const resolvedConfig = resolution.effectiveProjectPath && trimTrailingSlashes(resolution.effectiveProjectPath) !== trimTrailingSlashes(config.projectPath ?? '') ? { ...config, projectPath: resolution.effectiveProjectPath, projectPathHistory: this.buildRepairedProjectPathHistory( config.projectPath, config.projectPathHistory, resolution.effectiveProjectPath ), } : config; const sessionIds = await this.discoverSessionIds( teamName, resolution.projectDir, resolvedConfig ); const value = { projectDir: resolution.projectDir, projectId: resolution.projectId, config: resolvedConfig, sessionIds, }; this.contextCache.set(teamName, { value, expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, }); return value; } private async resolveProjectDirectory( teamName: string, config: TeamConfig ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { const sessionIds = collectKnownSessionIds(config); const pathCandidates = this.collectProjectPathCandidates(config); const currentCandidate = pathCandidates[0] ?? null; if (sessionIds.length === 0) { return this.buildFallbackResolution(teamName, pathCandidates); } const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); const getMatchRank = (match: { matchedSessionId: string } | null): number => match ? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; const toResolution = ( match: Pick & { projectPath?: string } ): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({ projectDir: match.projectDir, projectId: match.projectId, ...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}), }); let currentMatch: SessionProjectMatch | null = null; if (currentCandidate) { const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate( currentCandidate, sessionIds ); if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) { return toResolution(resolvedCurrentMatch); } if (resolvedCurrentMatch) { currentMatch = resolvedCurrentMatch; } } const configuredMatches = pathCandidates.length > 1 ? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds) : []; const scannedMatches = await this.findMatchesByScanningProjects(sessionIds); const candidateMatchesByProjectDir = new Map< string, SessionProjectMatch | ScannedSessionProjectMatch >(); for (const match of configuredMatches) { if (match.projectDir === currentMatch?.projectDir) { continue; } candidateMatchesByProjectDir.set(match.projectDir, match); } for (const match of scannedMatches) { if (match.projectDir === currentMatch?.projectDir) { continue; } if (!candidateMatchesByProjectDir.has(match.projectDir)) { candidateMatchesByProjectDir.set(match.projectDir, match); } } const alternateMatches = [...candidateMatchesByProjectDir.values()]; const bestAlternateRank = alternateMatches.reduce( (best, match) => Math.min(best, getMatchRank(match)), Number.POSITIVE_INFINITY ); const currentRank = getMatchRank(currentMatch); if (currentMatch && currentRank <= bestAlternateRank) { return toResolution(currentMatch); } if (bestAlternateRank !== Number.POSITIVE_INFINITY) { const bestAlternates = alternateMatches.filter( (match) => getMatchRank(match) === bestAlternateRank ); if (bestAlternates.length === 1) { const winner = bestAlternates[0]; if (winner.projectPath) { await this.persistResolvedProjectPath(teamName, config, winner.projectPath); } return toResolution(winner); } logger.warn( `[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path` ); return currentMatch ? toResolution(currentMatch) : this.buildFallbackResolution(teamName, pathCandidates); } if (currentMatch) { return toResolution(currentMatch); } return this.buildFallbackResolution(teamName, pathCandidates); } private async buildFallbackResolution( teamName: string, candidates: readonly ProjectPathCandidate[] ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { let firstResolution: { projectDir: string; projectId: string; effectiveProjectPath?: string; } | null = null; let firstExistingResolution: { projectDir: string; projectId: string; effectiveProjectPath?: string; } | null = null; for (const candidate of candidates) { for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { const resolution = { projectDir: dirCandidate.projectDir, projectId: dirCandidate.projectId, effectiveProjectPath: candidate.projectPath, }; if (!firstResolution) { firstResolution = resolution; } if (!(await this.projectDirExists(dirCandidate.projectDir))) { continue; } if (!firstExistingResolution) { firstExistingResolution = resolution; } const teamRootSessionIds = await this.listTeamRootSessionIds( dirCandidate.projectDir, teamName ); if (teamRootSessionIds.length > 0) { return resolution; } } } return firstExistingResolution ?? firstResolution; } private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] { const candidates: ProjectPathCandidate[] = []; const seen = new Set(); const push = (value: unknown, source: Exclude): void => { const normalized = normalizeProjectPathCandidate(value); if (!normalized || seen.has(normalized)) { return; } seen.add(normalized); candidates.push({ projectPath: normalized, source }); }; push(config.projectPath, 'projectPath'); if (Array.isArray(config.projectPathHistory)) { for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) { push(config.projectPathHistory[index], 'projectPathHistory'); } } const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; push(leadCwd, 'leadCwd'); const distinctMemberCwds = Array.from( new Set( (config.members ?? []) .map((member) => normalizeProjectPathCandidate(member.cwd)) .filter((cwd): cwd is string => Boolean(cwd)) ) ); if (distinctMemberCwds.length === 1) { push(distinctMemberCwds[0], 'memberCwd'); } return candidates; } private collectLiveProjectPathCandidates( config: TeamConfig, extraCandidates: readonly unknown[] ): string[] { const candidates: string[] = []; const seen = new Set(); const push = (value: unknown): void => { const normalized = normalizeProjectPathCandidate(value); if (!normalized || seen.has(normalized)) { return; } seen.add(normalized); candidates.push(normalized); }; push(config.projectPath); const history = Array.isArray(config.projectPathHistory) ? config.projectPathHistory : []; for (let index = history.length - 1; index >= Math.max(0, history.length - 5); index -= 1) { push(history[index]); } push((config.members ?? []).find((member) => isLeadMember(member))?.cwd); const distinctMemberCwds = new Set( (config.members ?? []) .map((member) => normalizeProjectPathCandidate(member.cwd)) .filter((cwd): cwd is string => Boolean(cwd)) ); if (distinctMemberCwds.size === 1) { push([...distinctMemberCwds][0]); } for (const candidate of extraCandidates.slice(0, 64)) { push(candidate); } return candidates; } private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] { const normalizedProjectPath = trimTrailingSlashes(projectPath); const projectId = extractBaseDir(encodePath(normalizedProjectPath)); const baseCandidates = [ { projectDir: path.join(getProjectsBasePath(), projectId), projectId }, ...(projectId.includes('_') ? [ { projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')), projectId: projectId.replace(/_/g, '-'), }, ] : []), ]; const seen = new Set(); return baseCandidates .filter((candidate) => { if (seen.has(candidate.projectDir)) { return false; } seen.add(candidate.projectDir); return true; }) .map((candidate) => ({ projectPath: normalizedProjectPath, projectDir: candidate.projectDir, projectId: candidate.projectId, source: 'projectPath' as const, })); } private async resolveLiveProjectDirectoryFromCandidates( candidates: readonly string[] ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath: string } | null> { let firstResolution: { projectDir: string; projectId: string; effectiveProjectPath: string; } | null = null; for (const projectPath of candidates) { for (const dirCandidate of this.buildProjectDirCandidates(projectPath)) { const resolution = { projectDir: dirCandidate.projectDir, projectId: dirCandidate.projectId, effectiveProjectPath: projectPath, }; firstResolution ??= resolution; if (await this.projectDirExists(dirCandidate.projectDir)) { return resolution; } } } return null; } private async findMatchInProjectPathCandidate( candidate: ProjectPathCandidate, sessionIds: string[] ): Promise { const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); let bestMatch: SessionProjectMatch | null = null; for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { const matchedSessionId = await this.findMatchingSessionId( projectCandidate.projectDir, sessionIds ); if (!matchedSessionId) { continue; } const match = { ...projectCandidate, source: candidate.source, matchedSessionId, }; const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY; const bestRank = bestMatch ? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; if (!bestMatch || matchRank < bestRank) { bestMatch = match; } if (matchRank === 0) { break; } } return bestMatch; } private async findMatchesInProjectPathCandidates( candidates: ProjectPathCandidate[], sessionIds: string[] ): Promise { const matches: SessionProjectMatch[] = []; const seenProjectDirs = new Set(); for (const candidate of candidates) { const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds); if (!match || seenProjectDirs.has(match.projectDir)) { continue; } seenProjectDirs.add(match.projectDir); matches.push(match); } return matches; } private async findMatchingSessionId( projectDir: string, sessionIds: string[] ): Promise { for (const sessionId of sessionIds) { try { const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`)); if (stat.isFile()) { return sessionId; } } catch { // continue } } return null; } private async findMatchesByScanningProjects( sessionIds: string[] ): Promise { let projectEntries: Dirent[]; try { projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); } catch { return []; } const directories = projectEntries.filter((entry) => entry.isDirectory()); const matches: ScannedSessionProjectMatch[] = []; let nextIndex = 0; const worker = async (): Promise => { while (nextIndex < directories.length) { const index = nextIndex++; const entry = directories[index]; const projectDir = path.join(getProjectsBasePath(), entry.name); const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds); if (!matchedSessionId) { continue; } const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`); const cwd = await extractCwd(jsonlPath); matches.push({ projectPath: cwd ?? undefined, projectDir, projectId: entry.name, source: 'projectsScan', matchedSessionId, }); } }; await Promise.all( Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () => worker() ) ); const deduped = new Map(); for (const match of matches) { if (!deduped.has(match.projectDir)) { deduped.set(match.projectDir, match); } } return [...deduped.values()]; } private async persistResolvedProjectPath( teamName: string, config: TeamConfig, nextProjectPath: string ): Promise { const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); if (!normalizedNextPath) { return; } const currentProjectPath = normalizeProjectPathCandidate(config.projectPath); if (currentProjectPath === normalizedNextPath) { return; } const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { const raw = await fs.readFile(configPath, 'utf8'); const parsed = JSON.parse(raw) as Record; const rawProjectPath = normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null; parsed.projectPath = normalizedNextPath; parsed.projectPathHistory = this.buildRepairedProjectPathHistory( rawProjectPath, parsed.projectPathHistory, normalizedNextPath ); await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); TeamConfigReader.invalidateTeam(teamName); logger.info( `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` ); } catch (error) { logger.warn( `[${teamName}] Failed to persist repaired transcript projectPath: ${ error instanceof Error ? error.message : String(error) }` ); } } private async discoverSessionIds( teamName: string, projectDir: string, config: TeamConfig ): Promise { const knownSessionIds = collectKnownSessionIds(config); const [teamRootSessionIds, sessionDirIds] = await Promise.all([ this.listTeamRootSessionIds(projectDir, teamName), this.listSessionDirIds(projectDir), ]); const orderedSessionIds: string[] = []; const seen = new Set(); const push = (sessionId: string): void => { if (seen.has(sessionId)) { return; } seen.add(sessionId); orderedSessionIds.push(sessionId); }; for (const sessionId of knownSessionIds) { push(sessionId); } for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) => left.localeCompare(right) )) { push(sessionId); } return orderedSessionIds; } private buildRepairedProjectPathHistory( currentProjectPath: unknown, rawProjectPathHistory: unknown, nextProjectPath: string ): string[] { const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); const history: string[] = []; const seen = new Set(); const pushHistory = (value: unknown): void => { const normalized = normalizeProjectPathCandidate(value); if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { return; } seen.add(normalized); history.push(normalized); }; if (Array.isArray(rawProjectPathHistory)) { for (const value of rawProjectPathHistory) { pushHistory(value); } } pushHistory(currentProjectPath); return history.slice(-500); } private async projectDirExists(projectDir: string): Promise { try { const stat = await fs.stat(projectDir); return stat.isDirectory(); } catch { return false; } } private async readProjectDirEntries(projectDir: string): Promise { try { return await fs.readdir(projectDir, { withFileTypes: true }); } catch { logger.debug(`Cannot read transcript project dir: ${projectDir}`); return null; } } private async listSessionDirIds(projectDir: string): Promise { const dirEntries = await this.readProjectDirEntries(projectDir); if (!dirEntries) { return []; } return dirEntries .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) .map((entry) => entry.name); } private async collectRootJsonlSessionIds( rootJsonlEntries: Dirent[], projectDir: string, teamName: string ): Promise { const discovered = new Set(); let nextIndex = 0; const scanNextRootEntry = async (): Promise => { while (nextIndex < rootJsonlEntries.length) { const entry = rootJsonlEntries[nextIndex++]; const filePath = path.join(projectDir, entry.name); if (!(await this.fileBelongsToTeam(filePath, teamName))) { continue; } discovered.add(entry.name.slice(0, -'.jsonl'.length)); } }; await Promise.all( Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () => scanNextRootEntry() ) ); return [...discovered]; } private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { const dirEntries = await this.readProjectDirEntries(projectDir); if (!dirEntries) { return []; } const rootJsonlEntries = dirEntries.filter( (entry) => entry.isFile() && entry.name.endsWith('.jsonl') ); return this.collectRootJsonlSessionIds(rootJsonlEntries, projectDir, teamName); } private async fileBelongsToTeam(filePath: string, teamName: string): Promise { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const normalizedTeam = teamName.trim().toLowerCase(); try { let inspected = 0; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) { continue; } inspected += 1; try { const entry = JSON.parse(trimmed) as Record; const directTeamName = extractDirectTeamName(entry); if (directTeamName === normalizedTeam) { return true; } if (entryContainsNestedTeamName(entry, normalizedTeam)) { return true; } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { return true; } } catch { // ignore malformed head lines } if (inspected >= TEAM_AFFINITY_SCAN_LINES) { break; } } } catch { return false; } finally { rl.close(); stream.destroy(); } return false; } }