agent-ecosystem/src/main/services/team/MemberStatsComputer.ts
iliya adbacc90fd feat: enhance task management and UI with new filters and line counting
- Implemented line counting for NotebookEdit and Bash commands in MemberStatsComputer to improve task tracking accuracy.
- Updated TeamDataService to include kanban column information for tasks, enhancing task visibility.
- Refactored Sidebar and GlobalTaskList components to support task filtering by status and team, improving user interaction.
- Introduced TaskFiltersPopover for better task filtering options, allowing users to filter tasks by status and unread comments.
- Enhanced UI components for better responsiveness and user experience in task management.

These changes aim to improve task management efficiency and enhance collaboration within teams.
2026-02-22 22:15:48 +02:00

361 lines
12 KiB
TypeScript

import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as readline from 'readline';
import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer');
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface CacheEntry {
stats: MemberFullStats;
timestamp: number;
}
export class MemberStatsComputer {
private cache = new Map<string, CacheEntry>();
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
async getStats(teamName: string, memberName: string): Promise<MemberFullStats> {
const cacheKey = `${teamName}:${memberName}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.stats;
}
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let messageCount = 0;
let totalDurationMs = 0;
for (const filePath of paths) {
const fileStats = await this.parseFile(filePath);
linesAdded += fileStats.linesAdded;
linesRemoved += fileStats.linesRemoved;
for (const f of fileStats.filesTouched) filesTouchedSet.add(f);
for (const [tool, count] of Object.entries(fileStats.toolUsage)) {
toolUsage[tool] = (toolUsage[tool] ?? 0) + count;
}
inputTokens += fileStats.inputTokens;
outputTokens += fileStats.outputTokens;
cacheReadTokens += fileStats.cacheReadTokens;
messageCount += fileStats.messageCount;
totalDurationMs += fileStats.durationMs;
}
const stats: MemberFullStats = {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet].sort((a, b) => a.localeCompare(b)),
toolUsage,
inputTokens,
outputTokens,
cacheReadTokens,
costUsd: 0,
tasksCompleted: 0,
messageCount,
totalDurationMs,
sessionCount: paths.length,
computedAt: new Date().toISOString(),
};
this.cache.set(cacheKey, { stats, timestamp: Date.now() });
return stats;
}
private async parseFile(filePath: string): Promise<{
linesAdded: number;
linesRemoved: number;
filesTouched: string[];
toolUsage: Record<string, number>;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
messageCount: number;
durationMs: number;
}> {
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let messageCount = 0;
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
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;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
// Count messages
const role = this.extractRole(msg);
if (role) messageCount++;
// Extract token usage
const usage = this.extractUsage(msg);
if (usage) {
inputTokens += usage.inputTokens;
outputTokens += usage.outputTokens;
cacheReadTokens += usage.cacheReadTokens;
}
// Extract tool_use blocks from assistant messages
if (role === 'assistant') {
const content = this.extractContent(msg);
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === 'object' &&
(block as Record<string, unknown>).type === 'tool_use'
) {
const toolBlock = block as Record<string, unknown>;
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : 'unknown';
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
toolUsage[toolName] = (toolUsage[toolName] ?? 0) + 1;
const input = toolBlock.input as Record<string, unknown> | undefined;
if (!input) continue;
// Track files
if (typeof input.file_path === 'string') {
filesTouchedSet.add(input.file_path);
}
if (typeof input.path === 'string' && toolName === 'Read') {
filesTouchedSet.add(input.path);
}
// Count lines for Edit
if (toolName === 'Edit') {
const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
const newStr = typeof input.new_string === 'string' ? input.new_string : '';
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
if (newLines > oldLines) linesAdded += newLines - oldLines;
if (oldLines > newLines) linesRemoved += oldLines - newLines;
}
// Count lines for Write
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
linesAdded += writeContent.split('\n').length;
}
}
// Count lines for NotebookEdit
if (toolName === 'NotebookEdit') {
const src = typeof input.new_source === 'string' ? input.new_source : '';
if (src) {
linesAdded += src.split('\n').length;
}
if (typeof input.notebook_path === 'string') {
filesTouchedSet.add(input.notebook_path);
}
}
// Count lines for Bash commands that write to files
if (toolName === 'Bash') {
const cmd = typeof input.command === 'string' ? input.command : '';
if (cmd) {
const bashLines = estimateBashLinesChanged(cmd);
linesAdded += bashLines.added;
linesRemoved += bashLines.removed;
for (const f of bashLines.files) filesTouchedSet.add(f);
}
}
}
}
}
}
} catch {
// Skip malformed lines
}
}
rl.close();
stream.destroy();
} catch (err) {
logger.debug(`Failed to parse file ${filePath}: ${String(err)}`);
}
let durationMs = 0;
if (firstTimestamp && lastTimestamp) {
durationMs = Math.max(
0,
new Date(lastTimestamp).getTime() - new Date(firstTimestamp).getTime()
);
}
return {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet],
toolUsage,
inputTokens,
outputTokens,
cacheReadTokens,
messageCount,
durationMs,
};
}
private extractRole(msg: Record<string, unknown>): string | null {
if (typeof msg.role === 'string') return msg.role;
if (msg.message && typeof msg.message === 'object') {
const inner = msg.message as Record<string, unknown>;
if (typeof inner.role === 'string') return inner.role;
}
return null;
}
private extractContent(msg: Record<string, unknown>): unknown[] | null {
const content = msg.content ?? (msg.message as Record<string, unknown> | undefined)?.content;
if (Array.isArray(content)) return content as unknown[];
return null;
}
private extractUsage(
msg: Record<string, unknown>
): { inputTokens: number; outputTokens: number; cacheReadTokens: number } | null {
const usage = (msg.usage ?? (msg.message as Record<string, unknown> | undefined)?.usage) as
| Record<string, unknown>
| undefined;
if (!usage || typeof usage !== 'object') return null;
return {
inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
cacheReadTokens:
typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0,
};
}
}
// ---------------------------------------------------------------------------
// Bash line-change heuristics
// ---------------------------------------------------------------------------
interface BashLinesResult {
added: number;
removed: number;
files: string[];
}
/**
* Best-effort estimation of lines changed by a Bash command.
* Handles common patterns: heredoc writes, echo/printf redirects,
* sed in-place edits, and tee writes.
*
* TODO: Improve Bash line counting accuracy:
* - Currently only covers ~30-40% of real Bash file-write patterns.
* - Misses: variable expansions (`echo "$var" > file`), piped output
* (`grep ... | sort > file`), `python -c`, `git apply`, `patch`,
* `mv`/`cp`, complex heredocs with `<<-` (tab-stripped).
* - The fundamental limitation is that Bash command output is not stored
* in the JSONL tool_use input — only the command string is available.
* The actual content written to files lives inside the shell runtime
* and is not captured.
* - Potential improvements: parse tool_result blocks for git diff --stat
* patterns (requires two-pass parser), or run a post-hoc `git log --stat`
* against the project repo filtered by session timestamps.
*/
export function estimateBashLinesChanged(command: string): BashLinesResult {
let added = 0;
let removed = 0;
const files: string[] = [];
// 1. Heredoc: cat <<'EOF' > file OR cat <<EOF > file
// Count lines between delimiter markers.
const heredocPattern = /<<-?\s*'?(\w+)'?/g;
let heredocMatch: RegExpExecArray | null;
while ((heredocMatch = heredocPattern.exec(command)) !== null) {
const delimiter = heredocMatch[1];
const afterHeredoc = command.slice(heredocMatch.index + heredocMatch[0].length);
const endIdx = afterHeredoc.indexOf(`\n${delimiter}`);
if (endIdx > 0) {
const startIdx = afterHeredoc.indexOf('\n');
if (startIdx >= 0 && startIdx < endIdx) {
const content = afterHeredoc.slice(startIdx + 1, endIdx);
added += content.split('\n').length;
}
}
}
// 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path
const echoPattern =
/(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g;
let echoMatch: RegExpExecArray | null;
while ((echoMatch = echoPattern.exec(command)) !== null) {
const content = echoMatch[1] ?? echoMatch[2] ?? '';
if (content) {
added += content.split('\\n').length;
}
const filePath = echoMatch[3];
if (filePath && filePath.startsWith('/')) {
files.push(filePath);
}
}
// 3. sed -i: each invocation ~ 1 line changed
const sedPattern = /sed\s+(?:-[a-zA-Z]*i[a-zA-Z]*|-i)\s/g;
let sedMatch: RegExpExecArray | null;
while ((sedMatch = sedPattern.exec(command)) !== null) {
added += 1;
removed += 1;
const afterSed = command.slice(sedMatch.index);
const sedFileMatch = /\s(\/\S+)\s*(?:[;&|]|$)/.exec(afterSed);
if (sedFileMatch) {
files.push(sedFileMatch[1]);
}
}
// 4. Redirect to file (catch-all for remaining redirects not caught above)
if (added === 0 && removed === 0) {
const redirectPattern = />{1,2}\s*(\/\S+)/g;
let redirectMatch: RegExpExecArray | null;
while ((redirectMatch = redirectPattern.exec(command)) !== null) {
const filePath = redirectMatch[1];
if (filePath) {
files.push(filePath);
}
}
}
// 5. tee: ... | tee /path/to/file
const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g;
let teeMatch: RegExpExecArray | null;
while ((teeMatch = teePattern.exec(command)) !== null) {
const filePath = teeMatch[1];
if (filePath) {
files.push(filePath);
}
}
return { added, removed, files };
}