feat(bd-1c5): Implement AI session digest generation

Implement comprehensive session digest generation module that aggregates:
- Beads completed with duration tracking
- Files modified with tool and worker attribution
- Errors encountered with categorization
- Time spent per worker
- Token usage and estimated costs

Features:
- SessionDigestGenerator class for digest creation
- Markdown formatting with tables and summaries
- CLI integration via 'fabric digest' command
- Support for time range and worker filtering
- Comprehensive test coverage (14 tests, all passing)

CLI Usage:
  fabric digest -f <logfile> [options]
  Options: --output, --worker, --since, --until, --max-files, --max-errors

All tests passing: 616/616

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-04 03:28:04 +00:00
parent 8e04bc094f
commit f6a7d09294
7 changed files with 1324 additions and 2 deletions

View file

@ -8,7 +8,7 @@
{"id":"bd-195","title":"ALT-007: SQLite direct query fallback","description":"For HUMAN bead bd-3sh. Query beads.db directly using sqlite3 or Node.js better-sqlite3. Bypasses br CLI entirely. Requires sqlite3 CLI or npm package. Fastest access but tight coupling to schema.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T08:39:58.775979286Z","created_by":"coder","updated_at":"2026-03-03T10:33:32.997760049Z","closed_at":"2026-03-03T10:33:31.799597115Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["alternative","br","resilience","worker"],"comments":[{"id":32,"issue_id":"bd-195","author":"Jed Arden","text":"No longer needed - br v0.1.20 fixes the schema bug natively.","created_at":"2026-03-03T10:33:32Z"}]}
{"id":"bd-1a2","title":"P2: Add unit tests for parser.ts","description":"Add comprehensive unit tests for src/parser.ts covering: JSON parsing, formatEvent function, edge cases (malformed JSON, missing fields), and colorization options. Follow vitest patterns from tailer.test.ts.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T07:50:12.670624516Z","created_by":"coder","updated_at":"2026-03-03T10:45:42.655993370Z","closed_at":"2026-03-03T10:45:42.654557737Z","close_reason":"Parser tests already implemented in bd-5eh (36 tests covering parseLogLine, parseLogLines, formatEvent)","source_repo":".","compaction_level":0,"original_size":0,"labels":["parser","testing","unit-test"]}
{"id":"bd-1a6","title":"Add unit tests for errorGrouping module","description":"Create unit tests for src/errorGrouping.ts. Test error clustering logic, similarity detection, group merging, and edge cases with different error patterns.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-04T03:01:57.554791496Z","created_by":"coder","updated_at":"2026-03-04T03:01:57.554791496Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1c5","title":"Implement AI session digest generation","description":"Create module to generate end-of-session summaries. Aggregate: beads completed, files modified, errors encountered, time spent, cost incurred. Output as markdown.","status":"open","priority":4,"issue_type":"task","created_at":"2026-03-04T03:05:28.818772227Z","created_by":"coder","updated_at":"2026-03-04T03:05:28.818772227Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1c5","title":"Implement AI session digest generation","description":"Create module to generate end-of-session summaries. Aggregate: beads completed, files modified, errors encountered, time spent, cost incurred. Output as markdown.","status":"in_progress","priority":4,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:05:28.818772227Z","created_by":"coder","updated_at":"2026-03-04T03:21:56.819464446Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1c6","title":"TEST-002: Add store integration tests","description":"Test Coverage: Add integration tests for EventStore - event indexing, LRU eviction, worker tracking, query performance.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T07:53:40.409846186Z","created_by":"coder","updated_at":"2026-03-03T07:53:40.409846186Z","closed_at":"2026-03-03T07:53:40.409846186Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["store"]}
{"id":"bd-1cc","title":"Port FileHeatmap component to web dashboard","description":"Port the TUI FileHeatmap component (src/tui/components/FileHeatmap.ts) to React for the web dashboard. Add corresponding API endpoint if needed.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T14:27:43.759301428Z","created_by":"coder","updated_at":"2026-03-03T15:08:13.822190937Z","closed_at":"2026-03-03T15:08:13.792085291Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["frontend","phase-4","web"]}
{"id":"bd-1e1","title":"P3-001: Setup Express HTTP server with static file serving","description":"Phase 3 Web Dashboard: Create Express server in src/web/server.ts that serves static files and handles WebSocket upgrade. Foundation for web dashboard.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-03T07:52:09.228852666Z","created_by":"coder","updated_at":"2026-03-03T10:05:21.171663977Z","closed_at":"2026-03-03T10:05:21.171457522Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-3","server","web"]}

View file

@ -18,6 +18,9 @@ import { getStore } from './store.js';
import { createTuiApp } from './tui/index.js';
import { createWebServer } from './web/index.js';
import { SessionReplay } from './tui/components/SessionReplay.js';
import { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';
import { getCostTracker } from './tui/utils/costTracking.js';
import * as fs from 'fs';
import type { LogLevel, EventFilter } from './types.js';
const program = new Command();
@ -302,4 +305,95 @@ program
}
});
program
.command('digest')
.description('Generate session digest from log file')
.option('-f, --file <path>', 'Log file to analyze', '~/.needle/logs/workers.log')
.option('-o, --output <path>', 'Output file (default: stdout)')
.option('-w, --worker <ids>', 'Filter by worker IDs (comma-separated)')
.option('--since <timestamp>', 'Start time (Unix timestamp in ms)')
.option('--until <timestamp>', 'End time (Unix timestamp in ms)')
.option('--max-files <number>', 'Maximum files to list', '50')
.option('--max-errors <number>', 'Maximum errors to list', '20')
.option('--no-cost', 'Exclude cost information')
.option('--no-errors', 'Exclude error information')
.action(async (options) => {
const filePath = options.file.replace('~', process.env.HOME || '');
console.error(`FABRIC Digest - Analyzing: ${filePath}`);
try {
// Load events from file
const store = getStore();
const tailer = new LogTailer({
path: filePath,
parseJson: true,
follow: false,
lines: 0, // Load all lines
});
let eventCount = 0;
tailer.on('event', (event) => {
store.add(event);
eventCount++;
});
tailer.on('error', (err) => {
console.error(`Tailer error: ${err.message}`);
});
// Start tailing and wait for completion
tailer.start();
// Wait for file to be fully read
await new Promise<void>((resolve) => {
setTimeout(() => {
tailer.stop();
resolve();
}, 500);
});
console.error(`Loaded ${eventCount} events`);
// Generate digest
const costTracker = getCostTracker();
const generator = new SessionDigestGenerator(store, costTracker);
const digestOptions: any = {
includeCost: options.cost !== false,
includeErrors: options.errors !== false,
maxFiles: parseInt(options.maxFiles, 10) || 50,
maxErrors: parseInt(options.maxErrors, 10) || 20,
};
if (options.worker) {
digestOptions.workers = options.worker.split(',').map((w: string) => w.trim());
}
if (options.since) {
digestOptions.startTime = parseInt(options.since, 10);
}
if (options.until) {
digestOptions.endTime = parseInt(options.until, 10);
}
const digest = generator.generateDigest(digestOptions);
const markdown = formatDigestAsMarkdown(digest);
// Output
if (options.output) {
const outputPath = options.output.replace('~', process.env.HOME || '');
fs.writeFileSync(outputPath, markdown, 'utf8');
console.error(`Digest written to: ${outputPath}`);
} else {
console.log(markdown);
}
} catch (err) {
console.error(`Failed to generate digest: ${(err as Error).message}`);
process.exit(1);
}
});
program.parse();

View file

@ -23,3 +23,4 @@ export interface WorkerState {
// Re-export submodules
export * from './types.js';
export { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';

566
src/sessionDigest.test.ts Normal file
View file

@ -0,0 +1,566 @@
/**
* Session Digest Tests
*/
import { describe, test, expect, beforeEach } from 'vitest';
import { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';
import { InMemoryEventStore } from './store.js';
import { LogEvent, SessionDigest } from './types.js';
import { CostTracker } from './tui/utils/costTracking.js';
describe('SessionDigestGenerator', () => {
let store: InMemoryEventStore;
let generator: SessionDigestGenerator;
let costTracker: CostTracker;
beforeEach(() => {
store = new InMemoryEventStore();
costTracker = new CostTracker();
generator = new SessionDigestGenerator(store, costTracker);
});
describe('generateDigest', () => {
test('generates empty digest for no events', () => {
const digest = generator.generateDigest();
expect(digest.beadsCompleted).toHaveLength(0);
expect(digest.filesModified).toHaveLength(0);
expect(digest.errors).toHaveLength(0);
expect(digest.workers).toHaveLength(0);
expect(digest.stats.totalEvents).toBe(0);
expect(digest.stats.totalWorkers).toBe(0);
});
test('extracts bead completions from events', () => {
const now = Date.now();
// Add events showing bead work
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Started working on bead',
bead: 'bd-123',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Bead completed successfully',
bead: 'bd-123',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'info',
msg: 'Task finished',
bead: 'bd-456',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.beadsCompleted.length).toBeGreaterThanOrEqual(1);
const bd123 = digest.beadsCompleted.find(b => b.beadId === 'bd-123');
expect(bd123).toBeDefined();
expect(bd123?.workerId).toBe('w-abc');
expect(bd123?.durationMs).toBeGreaterThan(0);
});
test('extracts file modifications', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Editing file',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Editing file again',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'info',
msg: 'Writing file',
tool: 'Write',
path: '/src/file2.ts',
},
{
ts: now + 3000,
worker: 'w-xyz',
level: 'info',
msg: 'Reading file',
tool: 'Read',
path: '/src/file3.ts',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.filesModified).toHaveLength(2);
const file1 = digest.filesModified.find(f => f.path === '/src/file1.ts');
expect(file1).toBeDefined();
expect(file1?.modifications).toBe(2);
expect(file1?.workers).toContain('w-abc');
expect(file1?.tools).toContain('Edit');
const file2 = digest.filesModified.find(f => f.path === '/src/file2.ts');
expect(file2).toBeDefined();
expect(file2?.modifications).toBe(1);
expect(file2?.workers).toContain('w-xyz');
// file3.ts should not be included (Read is not a modification)
const file3 = digest.filesModified.find(f => f.path === '/src/file3.ts');
expect(file3).toBeUndefined();
});
test('extracts errors from events', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'error',
msg: 'ECONNREFUSED connection failed',
error: 'Network error',
},
{
ts: now + 1000,
worker: 'w-xyz',
level: 'error',
msg: 'File not found',
error: 'ENOENT: no such file',
},
{
ts: now + 2000,
worker: 'w-abc',
level: 'warn',
msg: 'Warning message',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({ includeErrors: true });
expect(digest.errors).toHaveLength(2);
const networkError = digest.errors.find(e => e.category === 'network');
expect(networkError).toBeDefined();
expect(networkError?.workerId).toBe('w-abc');
const notFoundError = digest.errors.find(e => e.category === 'not_found');
expect(notFoundError).toBeDefined();
expect(notFoundError?.workerId).toBe('w-xyz');
});
test('generates worker summaries', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Event 1',
bead: 'bd-123',
path: '/src/file1.ts',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Event 2',
bead: 'bd-123',
path: '/src/file2.ts',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'error',
msg: 'Error occurred',
error: 'Test error',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.workers).toHaveLength(2);
const workerAbc = digest.workers.find(w => w.workerId === 'w-abc');
expect(workerAbc).toBeDefined();
expect(workerAbc?.totalEvents).toBe(2);
expect(workerAbc?.beadsCompleted).toBe(1);
expect(workerAbc?.filesModified).toBe(2);
expect(workerAbc?.errorsEncountered).toBe(0);
expect(workerAbc?.activeTimeMs).toBe(1000);
const workerXyz = digest.workers.find(w => w.workerId === 'w-xyz');
expect(workerXyz).toBeDefined();
expect(workerXyz?.totalEvents).toBe(1);
expect(workerXyz?.errorsEncountered).toBe(1);
});
test('calculates statistics correctly', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Working',
bead: 'bd-123',
tool: 'Edit',
path: '/file1.ts',
},
{
ts: now + 1000,
worker: 'w-xyz',
level: 'info',
msg: 'Working',
bead: 'bd-456',
tool: 'Write',
path: '/file2.ts',
},
{
ts: now + 2000,
worker: 'w-abc',
level: 'error',
msg: 'Error',
error: 'Test error',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.stats.totalEvents).toBe(3);
expect(digest.stats.totalWorkers).toBe(2);
expect(digest.stats.totalBeads).toBeGreaterThanOrEqual(2);
expect(digest.stats.totalFiles).toBe(2);
expect(digest.stats.totalErrors).toBe(1);
expect(digest.stats.avgEventsPerWorker).toBe(1.5);
});
test('filters by time range', () => {
const now = Date.now();
const events: LogEvent[] = [
{ ts: now - 2000, worker: 'w-abc', level: 'info', msg: 'Old event' },
{ ts: now, worker: 'w-abc', level: 'info', msg: 'Current event' },
{ ts: now + 2000, worker: 'w-abc', level: 'info', msg: 'Future event' },
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({
startTime: now - 500,
endTime: now + 500,
});
expect(digest.stats.totalEvents).toBe(1);
});
test('filters by workers', () => {
const now = Date.now();
const events: LogEvent[] = [
{ ts: now, worker: 'w-abc', level: 'info', msg: 'Event 1' },
{ ts: now + 1000, worker: 'w-xyz', level: 'info', msg: 'Event 2' },
{ ts: now + 2000, worker: 'w-123', level: 'info', msg: 'Event 3' },
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({
workers: ['w-abc', 'w-xyz'],
});
expect(digest.stats.totalEvents).toBe(2);
expect(digest.stats.totalWorkers).toBe(2);
});
test('limits files and errors', () => {
const now = Date.now();
// Add many files
for (let i = 0; i < 100; i++) {
store.add({
ts: now + i,
worker: 'w-abc',
level: 'info',
msg: `Editing file ${i}`,
tool: 'Edit',
path: `/src/file${i}.ts`,
});
}
// Add many errors
for (let i = 0; i < 100; i++) {
store.add({
ts: now + 1000 + i,
worker: 'w-abc',
level: 'error',
msg: `Error ${i}`,
error: `Error message ${i}`,
});
}
const digest = generator.generateDigest({
maxFiles: 10,
maxErrors: 5,
});
expect(digest.filesModified.length).toBeLessThanOrEqual(10);
expect(digest.errors.length).toBeLessThanOrEqual(5);
});
test('includes cost data when token events are present', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'API call',
input_tokens: 1000,
output_tokens: 500,
} as LogEvent,
{
ts: now + 1000,
worker: 'w-xyz',
level: 'info',
msg: 'API call',
input_tokens: 2000,
output_tokens: 1000,
} as LogEvent,
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({ includeCost: true });
expect(digest.cost.totalTokens).toBe(4500);
expect(digest.cost.inputTokens).toBe(3000);
expect(digest.cost.outputTokens).toBe(1500);
expect(digest.cost.estimatedCostUsd).toBeGreaterThan(0);
});
});
describe('formatDigestAsMarkdown', () => {
test('formats empty digest', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 1000,
durationMs: 1000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 0,
totalWorkers: 0,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 0,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).toContain('# Session Digest');
expect(markdown).toContain('test-session');
expect(markdown).toContain('## Summary');
expect(markdown).toContain('Total Events');
});
test('formats complete digest with all sections', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 60000,
durationMs: 60000,
beadsCompleted: [
{
beadId: 'bd-123',
workerId: 'w-abc',
completedAt: now + 30000,
durationMs: 30000,
},
],
filesModified: [
{
path: '/src/file1.ts',
modifications: 5,
workers: ['w-abc'],
tools: ['Edit'],
},
],
errors: [
{
message: 'Test error',
category: 'network',
workerId: 'w-abc',
timestamp: now + 10000,
},
],
workers: [
{
workerId: 'w-abc',
beadsCompleted: 1,
filesModified: 1,
errorsEncountered: 1,
totalEvents: 10,
activeTimeMs: 60000,
firstActivity: now,
lastActivity: now + 60000,
},
],
cost: {
totalTokens: 5000,
inputTokens: 3000,
outputTokens: 2000,
estimatedCostUsd: 0.05,
},
stats: {
totalEvents: 10,
totalWorkers: 1,
totalBeads: 1,
totalFiles: 1,
totalErrors: 1,
avgEventsPerWorker: 10,
avgBeadsPerWorker: 1,
},
};
const markdown = formatDigestAsMarkdown(digest);
// Check all major sections
expect(markdown).toContain('# Session Digest');
expect(markdown).toContain('## Summary');
expect(markdown).toContain('## Cost Summary');
expect(markdown).toContain('## Worker Activity');
expect(markdown).toContain('## Beads Completed');
expect(markdown).toContain('## Files Modified');
expect(markdown).toContain('## Errors Encountered');
// Check content
expect(markdown).toContain('bd-123');
expect(markdown).toContain('w-abc');
expect(markdown).toContain('/src/file1.ts');
expect(markdown).toContain('Test error');
expect(markdown).toContain('5,000');
expect(markdown).toContain('$0.0500');
});
test('omits cost section when no tokens', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 1000,
durationMs: 1000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 10,
totalWorkers: 1,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 10,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).not.toContain('## Cost Summary');
});
test('formats durations correctly', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 3723000, // 1h 2m 3s
durationMs: 3723000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [
{
workerId: 'w-abc',
beadsCompleted: 0,
filesModified: 0,
errorsEncountered: 0,
totalEvents: 1,
activeTimeMs: 3723000,
firstActivity: now,
lastActivity: now + 3723000,
},
],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 1,
totalWorkers: 1,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 1,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).toContain('1h 2m');
});
});
});

500
src/sessionDigest.ts Normal file
View file

@ -0,0 +1,500 @@
/**
* Session Digest Generator
*
* Generates end-of-session summaries aggregating:
* - Beads completed
* - Files modified
* - Errors encountered
* - Time spent
* - Cost incurred
*
* Outputs as formatted markdown.
*/
import {
LogEvent,
EventStore,
SessionDigest,
SessionDigestOptions,
BeadCompletion,
FileModificationSummary,
ErrorOccurrence,
WorkerSessionSummary,
ErrorCategory,
} from './types.js';
import { CostTracker } from './tui/utils/costTracking.js';
import { ErrorGroupManager } from './errorGrouping.js';
const DEFAULT_OPTIONS: SessionDigestOptions = {
workers: [],
includeErrors: true,
includeCost: true,
maxFiles: 50,
maxErrors: 20,
};
/**
* Session Digest Generator
*/
export class SessionDigestGenerator {
private store: EventStore;
private costTracker: CostTracker;
private errorGroupManager: ErrorGroupManager;
constructor(store: EventStore, costTracker?: CostTracker, errorGroupManager?: ErrorGroupManager) {
this.store = store;
this.costTracker = costTracker || new CostTracker();
this.errorGroupManager = errorGroupManager || new ErrorGroupManager();
}
/**
* Generate session digest from events
*/
generateDigest(options: SessionDigestOptions = {}): SessionDigest {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Build filter for query
const filter: any = {};
if (opts.startTime !== undefined) filter.since = opts.startTime;
if (opts.endTime !== undefined) filter.until = opts.endTime;
// Query events within time range
const events = this.store.query(Object.keys(filter).length > 0 ? filter : undefined);
// Filter by workers if specified
const filteredEvents = opts.workers && opts.workers.length > 0
? events.filter(e => opts.workers!.includes(e.worker))
: events;
// Process events to extract data
const beadsCompleted = this.extractBeadCompletions(filteredEvents);
const filesModified = this.extractFileModifications(filteredEvents, opts.maxFiles ?? 50);
const errors = opts.includeErrors
? this.extractErrors(filteredEvents, opts.maxErrors ?? 20)
: [];
// Generate worker summaries
const workers = this.generateWorkerSummaries(filteredEvents);
// Process cost data
filteredEvents.forEach(event => this.costTracker.processEvent(event));
const costSummary = this.costTracker.getSummary();
// Calculate session time bounds
const now = Date.now();
const startTime = filteredEvents.length > 0
? Math.min(...filteredEvents.map(e => e.ts))
: (opts.startTime ?? now);
const endTime = filteredEvents.length > 0
? Math.max(...filteredEvents.map(e => e.ts))
: (opts.endTime ?? now);
// Generate statistics
const uniqueWorkers = new Set(filteredEvents.map(e => e.worker));
const uniqueBeads = new Set(filteredEvents.filter(e => e.bead).map(e => e.bead!));
const uniqueFiles = new Set(filesModified.map(f => f.path));
return {
sessionId: `session-${startTime}`,
startTime,
endTime,
durationMs: endTime - startTime,
beadsCompleted,
filesModified,
errors,
workers,
cost: {
totalTokens: costSummary.total.total,
inputTokens: costSummary.total.input,
outputTokens: costSummary.total.output,
estimatedCostUsd: costSummary.totalCostUsd,
},
stats: {
totalEvents: filteredEvents.length,
totalWorkers: uniqueWorkers.size,
totalBeads: uniqueBeads.size,
totalFiles: uniqueFiles.size,
totalErrors: errors.length,
avgEventsPerWorker: uniqueWorkers.size > 0
? filteredEvents.length / uniqueWorkers.size
: 0,
avgBeadsPerWorker: uniqueWorkers.size > 0
? uniqueBeads.size / uniqueWorkers.size
: 0,
},
};
}
/**
* Extract bead completions from events
*/
private extractBeadCompletions(events: LogEvent[]): BeadCompletion[] {
const completions: BeadCompletion[] = [];
const beadStartTimes = new Map<string, number>();
for (const event of events) {
const beadId = event.bead;
if (!beadId) continue;
// Track bead start times
if (!beadStartTimes.has(beadId)) {
beadStartTimes.set(beadId, event.ts);
}
// Look for completion indicators
if (this.isBeadCompletion(event)) {
const startTime = beadStartTimes.get(beadId) || event.ts;
completions.push({
beadId,
workerId: event.worker,
completedAt: event.ts,
durationMs: event.ts - startTime,
});
}
}
return completions;
}
/**
* Check if event indicates bead completion
*/
private isBeadCompletion(event: LogEvent): boolean {
const msg = event.msg.toLowerCase();
return (
msg.includes('completed') ||
msg.includes('finished') ||
msg.includes('done') ||
msg.includes('success') ||
(event as any).status === 'completed'
);
}
/**
* Extract file modifications from events
*/
private extractFileModifications(events: LogEvent[], maxFiles: number): FileModificationSummary[] {
const fileMap = new Map<string, {
modifications: number;
workers: Set<string>;
tools: Set<string>;
}>();
for (const event of events) {
const path = event.path;
if (!path) continue;
// Only count file modification tools
const tool = event.tool;
if (!tool || !this.isFileModificationTool(tool)) continue;
let fileData = fileMap.get(path);
if (!fileData) {
fileData = {
modifications: 0,
workers: new Set(),
tools: new Set(),
};
fileMap.set(path, fileData);
}
fileData.modifications++;
fileData.workers.add(event.worker);
fileData.tools.add(tool);
}
// Convert to array and sort by modification count
const files = Array.from(fileMap.entries())
.map(([path, data]) => ({
path,
modifications: data.modifications,
workers: Array.from(data.workers),
tools: Array.from(data.tools),
}))
.sort((a, b) => b.modifications - a.modifications)
.slice(0, maxFiles);
return files;
}
/**
* Check if tool is a file modification tool
*/
private isFileModificationTool(tool: string): boolean {
const modificationTools = ['Edit', 'Write', 'NotebookEdit', 'Delete', 'Move'];
return modificationTools.includes(tool);
}
/**
* Extract errors from events
*/
private extractErrors(events: LogEvent[], maxErrors: number): ErrorOccurrence[] {
const errors: ErrorOccurrence[] = [];
for (const event of events) {
if (event.level !== 'error' && !event.error) continue;
// Process error through error group manager
this.errorGroupManager.addError(event);
const errorMsg = event.error || event.msg;
const category = this.categorizeError(errorMsg);
errors.push({
message: errorMsg,
category,
workerId: event.worker,
timestamp: event.ts,
});
}
// Sort by timestamp (most recent first) and limit
return errors
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, maxErrors);
}
/**
* Categorize error message
*/
private categorizeError(message: string): ErrorCategory {
const msg = message.toLowerCase();
if (msg.includes('econnrefused') || msg.includes('timeout') || msg.includes('network')) {
return 'network';
}
if (msg.includes('permission') || msg.includes('denied') || msg.includes('unauthorized')) {
return 'permission';
}
if (msg.includes('not found') || msg.includes('enoent') || msg.includes('404')) {
return 'not_found';
}
if (msg.includes('invalid') || msg.includes('validation')) {
return 'validation';
}
if (msg.includes('memory') || msg.includes('quota') || msg.includes('disk')) {
return 'resource';
}
if (msg.includes('syntax') || msg.includes('parse')) {
return 'syntax';
}
return 'unknown';
}
/**
* Generate worker summaries
*/
private generateWorkerSummaries(events: LogEvent[]): WorkerSessionSummary[] {
const workerMap = new Map<string, {
events: LogEvent[];
beads: Set<string>;
files: Set<string>;
errors: number;
}>();
// Group events by worker
for (const event of events) {
let workerData = workerMap.get(event.worker);
if (!workerData) {
workerData = {
events: [],
beads: new Set(),
files: new Set(),
errors: 0,
};
workerMap.set(event.worker, workerData);
}
workerData.events.push(event);
if (event.bead) workerData.beads.add(event.bead);
if (event.path) workerData.files.add(event.path);
if (event.level === 'error' || event.error) workerData.errors++;
}
// Generate summaries
return Array.from(workerMap.entries()).map(([workerId, data]) => {
const timestamps = data.events.map(e => e.ts).sort((a, b) => a - b);
const firstActivity = timestamps[0] || 0;
const lastActivity = timestamps[timestamps.length - 1] || 0;
// Calculate active time (rough estimate based on event spread)
const activeTimeMs = lastActivity - firstActivity;
return {
workerId,
beadsCompleted: data.beads.size,
filesModified: data.files.size,
errorsEncountered: data.errors,
totalEvents: data.events.length,
activeTimeMs,
firstActivity,
lastActivity,
};
}).sort((a, b) => b.totalEvents - a.totalEvents);
}
}
/**
* Format session digest as markdown
*/
export function formatDigestAsMarkdown(digest: SessionDigest): string {
const lines: string[] = [];
// Header
lines.push('# Session Digest');
lines.push('');
lines.push(`**Session ID:** ${digest.sessionId}`);
lines.push(`**Duration:** ${formatDuration(digest.durationMs)}`);
lines.push(`**Period:** ${new Date(digest.startTime).toISOString()} - ${new Date(digest.endTime).toISOString()}`);
lines.push('');
// Summary Statistics
lines.push('## Summary');
lines.push('');
lines.push('| Metric | Count |');
lines.push('|--------|-------|');
lines.push(`| Total Events | ${digest.stats.totalEvents.toLocaleString()} |`);
lines.push(`| Active Workers | ${digest.stats.totalWorkers} |`);
lines.push(`| Beads Completed | ${digest.stats.totalBeads} |`);
lines.push(`| Files Modified | ${digest.stats.totalFiles} |`);
lines.push(`| Errors Encountered | ${digest.stats.totalErrors} |`);
lines.push('');
// Cost Summary
if (digest.cost.totalTokens > 0) {
lines.push('## Cost Summary');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Total Tokens | ${digest.cost.totalTokens.toLocaleString()} |`);
lines.push(`| Input Tokens | ${digest.cost.inputTokens.toLocaleString()} |`);
lines.push(`| Output Tokens | ${digest.cost.outputTokens.toLocaleString()} |`);
lines.push(`| Estimated Cost | $${digest.cost.estimatedCostUsd.toFixed(4)} |`);
lines.push('');
}
// Worker Summaries
if (digest.workers.length > 0) {
lines.push('## Worker Activity');
lines.push('');
lines.push('| Worker | Events | Beads | Files | Errors | Active Time |');
lines.push('|--------|--------|-------|-------|--------|-------------|');
for (const worker of digest.workers) {
lines.push(
`| ${worker.workerId} | ${worker.totalEvents} | ${worker.beadsCompleted} | ${worker.filesModified} | ${worker.errorsEncountered} | ${formatDuration(worker.activeTimeMs)} |`
);
}
lines.push('');
}
// Beads Completed
if (digest.beadsCompleted.length > 0) {
lines.push('## Beads Completed');
lines.push('');
lines.push('| Bead ID | Worker | Duration |');
lines.push('|---------|--------|----------|');
for (const bead of digest.beadsCompleted) {
const duration = bead.durationMs ? formatDuration(bead.durationMs) : 'N/A';
lines.push(`| ${bead.beadId} | ${bead.workerId} | ${duration} |`);
}
lines.push('');
}
// Files Modified
if (digest.filesModified.length > 0) {
lines.push('## Files Modified');
lines.push('');
lines.push('| File | Modifications | Workers | Tools |');
lines.push('|------|---------------|---------|-------|');
for (const file of digest.filesModified.slice(0, 20)) {
lines.push(
`| ${truncatePath(file.path)} | ${file.modifications} | ${file.workers.length} | ${file.tools.join(', ')} |`
);
}
if (digest.filesModified.length > 20) {
lines.push(`| ... and ${digest.filesModified.length - 20} more files | | | |`);
}
lines.push('');
}
// Errors Encountered
if (digest.errors.length > 0) {
lines.push('## Errors Encountered');
lines.push('');
for (const error of digest.errors.slice(0, 10)) {
lines.push(`### ${error.category} - ${error.workerId}`);
lines.push('');
lines.push('```');
lines.push(error.message);
lines.push('```');
lines.push('');
lines.push(`*Time:* ${new Date(error.timestamp).toISOString()}`);
lines.push('');
}
if (digest.errors.length > 10) {
lines.push(`*... and ${digest.errors.length - 10} more errors*`);
lines.push('');
}
}
// Footer
lines.push('---');
lines.push('');
lines.push(`*Generated at ${new Date().toISOString()}*`);
return lines.join('\n');
}
/**
* Format duration in human-readable form
*/
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
const seconds = Math.floor(ms / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return remainingSeconds > 0
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0
? `${hours}h ${remainingMinutes}m`
: `${hours}h`;
}
/**
* Truncate file path for display
*/
function truncatePath(path: string, maxLength: number = 60): string {
if (path.length <= maxLength) {
return path;
}
const parts = path.split('/');
if (parts.length <= 2) {
return `...${path.slice(-(maxLength - 3))}`;
}
// Keep first and last parts, truncate middle
const first = parts[0];
const last = parts[parts.length - 1];
return `${first}/.../.../${last}`;
}

View file

@ -972,3 +972,164 @@ export interface RecoveryStats {
/** Most common recovery action types */
topActionTypes: Array<{ type: RecoveryActionType; count: number }>;
}
// ============================================
// Session Digest Types
// ============================================
/**
* Bead completion summary
*/
export interface BeadCompletion {
/** Bead ID */
beadId: string;
/** Worker that completed the bead */
workerId: string;
/** Completion timestamp */
completedAt: number;
/** Duration in milliseconds */
durationMs?: number;
}
/**
* File modification summary
*/
export interface FileModificationSummary {
/** File path */
path: string;
/** Number of modifications */
modifications: number;
/** Workers who modified this file */
workers: string[];
/** Tools used */
tools: string[];
}
/**
* Error occurrence in session
*/
export interface ErrorOccurrence {
/** Error message */
message: string;
/** Error category */
category: ErrorCategory;
/** Worker that encountered the error */
workerId: string;
/** Timestamp */
timestamp: number;
/** Error fingerprint */
fingerprint?: string;
}
/**
* Worker session summary
*/
export interface WorkerSessionSummary {
/** Worker ID */
workerId: string;
/** Beads completed */
beadsCompleted: number;
/** Files modified */
filesModified: number;
/** Errors encountered */
errorsEncountered: number;
/** Total events */
totalEvents: number;
/** Active time in milliseconds */
activeTimeMs: number;
/** First activity timestamp */
firstActivity: number;
/** Last activity timestamp */
lastActivity: number;
}
/**
* Complete session digest
*/
export interface SessionDigest {
/** Session ID or identifier */
sessionId: string;
/** Session start timestamp */
startTime: number;
/** Session end timestamp */
endTime: number;
/** Total duration in milliseconds */
durationMs: number;
/** Beads completed */
beadsCompleted: BeadCompletion[];
/** Files modified */
filesModified: FileModificationSummary[];
/** Errors encountered */
errors: ErrorOccurrence[];
/** Worker summaries */
workers: WorkerSessionSummary[];
/** Token usage and cost */
cost: {
totalTokens: number;
inputTokens: number;
outputTokens: number;
estimatedCostUsd: number;
};
/** Overall statistics */
stats: {
totalEvents: number;
totalWorkers: number;
totalBeads: number;
totalFiles: number;
totalErrors: number;
avgEventsPerWorker: number;
avgBeadsPerWorker: number;
};
}
/**
* Options for session digest generation
*/
export interface SessionDigestOptions {
/** Start time filter */
startTime?: number;
/** End time filter */
endTime?: number;
/** Include only specific workers */
workers?: string[];
/** Include error details */
includeErrors?: boolean;
/** Include cost breakdown */
includeCost?: boolean;
/** Maximum files to list */
maxFiles?: number;
/** Maximum errors to list */
maxErrors?: number;
}

File diff suppressed because one or more lines are too long