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:
parent
8e04bc094f
commit
f6a7d09294
7 changed files with 1324 additions and 2 deletions
|
|
@ -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"]}
|
||||
|
|
|
|||
94
src/cli.ts
94
src/cli.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
566
src/sessionDigest.test.ts
Normal 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
500
src/sessionDigest.ts
Normal 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}`;
|
||||
}
|
||||
161
src/types.ts
161
src/types.ts
|
|
@ -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
Loading…
Add table
Reference in a new issue