agent-ecosystem/src/main/services/team/TeamTranscriptProjectResolver.ts

309 lines
9.2 KiB
TypeScript

import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
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;
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 extractTextContent(entry: Record<string, unknown>): string | null {
if (typeof entry.content === 'string') {
return entry.content;
}
if (Array.isArray(entry.content)) {
const textParts = (entry.content as Record<string, unknown>[])
.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<string, unknown>);
}
return null;
}
function extractDirectTeamName(entry: Record<string, unknown>): string | null {
if (typeof entry.teamName === 'string') {
return entry.teamName.trim().toLowerCase();
}
const process = entry.process as Record<string, unknown> | undefined;
const processTeam = process?.team as Record<string, unknown> | 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(`on team "${normalizedTeam}"`) ||
normalizedText.includes(`on team '${normalizedTeam}'`) ||
normalizedText.includes(`team "${normalizedTeam}"`) ||
normalizedText.includes(`team '${normalizedTeam}'`) ||
normalizedText.includes(`(${normalizedTeam})`)
);
}
function collectKnownSessionIds(config: TeamConfig): string[] {
const knownSessionIds = new Set<string>();
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 (const sessionId of config.sessionHistory) {
push(sessionId);
}
}
return [...knownSessionIds];
}
export interface TeamTranscriptProjectContext {
projectDir: string;
projectId: string;
config: TeamConfig;
sessionIds: string[];
}
export class TeamTranscriptProjectResolver {
private readonly contextCache = new Map<
string,
{ value: TeamTranscriptProjectContext; expiresAt: number }
>();
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
async getContext(
teamName: string,
options?: { forceRefresh?: boolean }
): Promise<TeamTranscriptProjectContext | null> {
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.configReader.getConfig(teamName);
if (!config?.projectPath) {
return null;
}
const { projectDir, projectId } = await this.resolveProjectDirectory(config);
const sessionIds = await this.discoverSessionIds(teamName, projectDir, config);
const value = { projectDir, projectId, config, sessionIds };
this.contextCache.set(teamName, {
value,
expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL,
});
return value;
}
private async resolveProjectDirectory(
config: TeamConfig
): Promise<{ projectDir: string; projectId: string }> {
const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? '');
let projectId = encodePath(normalizedProjectPath);
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
return { projectDir, projectId };
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (!leadSessionId) {
return { projectDir, projectId };
}
try {
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(getProjectsBasePath(), entry.name);
try {
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
projectDir = candidateDir;
projectId = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// best-effort fallback
}
}
return { projectDir, projectId };
}
private async discoverSessionIds(
teamName: string,
projectDir: string,
config: TeamConfig
): Promise<string[]> {
const knownSessionIds = collectKnownSessionIds(config);
const [teamRootSessionIds, sessionDirIds] = await Promise.all([
this.listTeamRootSessionIds(projectDir, teamName),
this.listSessionDirIds(projectDir),
]);
return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort(
(left, right) => left.localeCompare(right)
);
}
private async readProjectDirEntries(projectDir: string): Promise<Dirent[] | null> {
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<string[]> {
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<string[]> {
const discovered = new Set<string>();
let nextIndex = 0;
const scanNextRootEntry = async (): Promise<void> => {
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<string[]> {
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<boolean> {
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<string, unknown>;
const directTeamName = extractDirectTeamName(entry);
if (directTeamName === 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;
}
}