feat(bd-2st): Implement semantic narrative summarization

- Create SemanticNarrativeGenerator with event sequence analysis
- Implement pattern detection for 12 event types (bead lifecycle, file ops, testing, debugging, git, etc.)
- Add real-time narrative segmentation and updates
- Generate natural language summaries with multiple styles (brief, detailed, timeline, technical)
- Integrate with store for automatic event processing
- Add comprehensive unit tests (35 tests, all passing)
- Export semantic narrative from main index

Features:
- Event pattern detection and grouping
- Real-time narrative updates via callback system
- Multiple narrative styles (brief, detailed, timeline, technical)
- Accomplishment and challenge extraction
- Sentiment analysis (productive, struggling, mixed, idle)
- Timeline generation
- Aggregated narratives for multiple workers
- Filtering by time range and bead ID

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-04 04:31:11 +00:00
parent dc4f33266a
commit 8002f002bf
9 changed files with 3036 additions and 3 deletions

View file

@ -37,7 +37,7 @@
{"id":"bd-1sy","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 28239s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T12:14:09.703537295Z","created_by":"coder","updated_at":"2026-03-03T12:18:02.612564365Z","closed_at":"2026-03-03T12:18:02.605978022Z","close_reason":"FALSE POSITIVE: Work IS available in ready-queue.json (22 beads). Worker discovery logic failed to check ready-queue.json before escalating to HUMAN bead creation. See memory pattern Worker Starvation Resolution - always check ready-queue.json first.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1wo","title":"P3-005: Build Activity Feed component with filtering","description":"Phase 3 Web Dashboard: Create scrollable log activity feed component. Support level filtering (debug/info/warn/error), worker filtering, and search.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T07:52:10.076389946Z","created_by":"coder","updated_at":"2026-03-03T07:52:10.076389946Z","closed_at":"2026-03-03T07:52:10.076389946Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["frontend","phase-3","web"]}
{"id":"bd-1x0","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 30078s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T12:44:48.241496969Z","created_by":"coder","updated_at":"2026-03-03T12:46:35.950360191Z","closed_at":"2026-03-03T12:46:35.940367951Z","close_reason":"FALSE POSITIVE: Worker starvation alert created incorrectly. Ready queue has 22 available beads. Workers should check ready-queue.json before creating HUMAN alerts. Real work available: bd-2kf (FABRIC epic), bd-3k9 (Session Replay). See MEMORY.md for resolution pattern.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-1xi","title":"Create SessionDigest TUI component","description":"Create TUI component to view and export session digests. Show summary stats, list of completed work, notable events. Add export command.","status":"open","priority":4,"issue_type":"task","created_at":"2026-03-04T03:05:49.317943893Z","created_by":"coder","updated_at":"2026-03-04T03:07:01.172189009Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1xi","depends_on_id":"bd-1c5","type":"blocks","created_at":"2026-03-04T03:07:01.172103890Z","created_by":"coder"}]}
{"id":"bd-1xi","title":"Create SessionDigest TUI component","description":"Create TUI component to view and export session digests. Show summary stats, list of completed work, notable events. Add export command.","status":"in_progress","priority":4,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:05:49.317943893Z","created_by":"coder","updated_at":"2026-03-04T04:25:20.541381261Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1xi","depends_on_id":"bd-1c5","type":"blocks","created_at":"2026-03-04T03:07:01.172103890Z","created_by":"coder"}]}
{"id":"bd-1zq","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 20056s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T09:57:48.976176359Z","created_by":"coder","updated_at":"2026-03-03T09:59:18.431284666Z","closed_at":"2026-03-03T09:58:54.181440569Z","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":22,"issue_id":"bd-1zq","author":"Jed Arden","text":"FALSE POSITIVE: ready-queue.json shows 22 beads available. Worker discovery did not check ready-queue.json before escalating. See bd-b02 for root cause fix.","created_at":"2026-03-03T09:59:18Z"}]}
{"id":"bd-20w","title":"Add unit tests for CollisionAlert component","description":"Create unit tests for src/tui/components/CollisionAlert.ts. Test collision detection logic, alert rendering, dismissal behavior, and severity level display (Read/Read, Read/Edit, Edit/Edit).","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:01:50.865036919Z","created_by":"coder","updated_at":"2026-03-04T03:37:18.422728290Z","closed_at":"2026-03-04T03:37:18.412922708Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-22v","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 24915s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T11:18:45.172152756Z","created_by":"coder","updated_at":"2026-03-03T11:19:36.492644692Z","closed_at":"2026-03-03T11:19:36.489000449Z","close_reason":"FALSE POSITIVE: Ready queue has 22 beads available. Worker discovery logic should check ready-queue.json before creating HUMAN beads for starvation. Closed following worker starvation resolution pattern.","source_repo":".","compaction_level":0,"original_size":0}
@ -67,7 +67,7 @@
{"id":"bd-2r0","title":"P3-007: Add web command to CLI","description":"Phase 3 Web Dashboard: Add 'fabric web' command to cli.ts. Start Express server, WebSocket server, and serve frontend. Support --port flag.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-03T07:52:10.506686382Z","created_by":"coder","updated_at":"2026-03-03T10:05:21.035570938Z","closed_at":"2026-03-03T10:05:21.035371785Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["cli","phase-3","web"]}
{"id":"bd-2s2","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 20668s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T10:07:58.350843437Z","created_by":"coder","updated_at":"2026-03-03T10:09:34.334980509Z","closed_at":"2026-03-03T10:09:11.281823730Z","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":24,"issue_id":"bd-2s2","author":"Jed Arden","text":"FALSE POSITIVE: ready-queue.json has 22 available beads. Worker discovery did not check ready-queue.json before escalating. Root cause tracked in bd-b02.","created_at":"2026-03-03T10:09:34Z"}]}
{"id":"bd-2sn","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 23056s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T10:47:46.828248079Z","created_by":"coder","updated_at":"2026-03-03T10:48:54.037724581Z","closed_at":"2026-03-03T10:48:54.036211395Z","close_reason":"FALSE POSITIVE: 22 beads available in ready-queue.json. Worker did not check ready queue before creating HUMAN bead (mandatory Step 0 in starvation resolution pattern). Starvation alert invalid - work exists.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2st","title":"Implement semantic narrative summarization","description":"Create module to generate natural language summaries of worker activity. Transform event sequences into readable narratives. Update in real-time as events arrive.","status":"open","priority":4,"issue_type":"task","created_at":"2026-03-04T03:05:17.134639604Z","created_by":"coder","updated_at":"2026-03-04T03:05:17.134639604Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2st","title":"Implement semantic narrative summarization","description":"Create module to generate natural language summaries of worker activity. Transform event sequences into readable narratives. Update in real-time as events arrive.","status":"in_progress","priority":4,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:05:17.134639604Z","created_by":"coder","updated_at":"2026-03-04T04:24:47.700887180Z","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-2u8","title":"P4-001: Implement Cross-Reference Hyperlinking","description":"Phase 4 Intelligence: Detect references to beads, files, and workers in log messages. Convert them to clickable links that navigate to related context. Pattern: bd-XXXX, file://path, worker://id","status":"closed","priority":3,"issue_type":"task","created_at":"2026-03-03T07:53:39.570693768Z","created_by":"coder","updated_at":"2026-03-03T07:53:39.570693768Z","closed_at":"2026-03-03T07:53:39.570693768Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["hyperlinks","intelligence","phase-4"]}
{"id":"bd-2uo","title":"Add Vitest tests for web server API endpoints","description":"Add tests for src/web/server.ts covering all API endpoints: /api/health, /api/workers, /api/events, /api/collisions, /api/xref/*","status":"closed","priority":2,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T14:27:22.602088181Z","created_by":"coder","updated_at":"2026-03-03T14:37:07.947224963Z","closed_at":"2026-03-03T14:37:07.923725415Z","close_reason":"completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-3","testing","web"]}
{"id":"bd-2uw","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 35773s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T14:19:46.052206230Z","created_by":"coder","updated_at":"2026-03-03T15:35:36.775288280Z","closed_at":"2026-03-03T15:35:27.125572062Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":34,"issue_id":"bd-2uw","author":"Jed Arden","text":"FALSE POSITIVE closed via --no-db mode. ready-queue.json had 17 available beads but worker discovery failed. Root cause: SQLite B-tree corruption prevented normal close operation. Pattern documented in MEMORY.md 'False-Positive HUMAN Beads for Worker Starvation'.","created_at":"2026-03-03T15:35:36Z"}]}

View file

@ -25,3 +25,4 @@ export interface WorkerState {
export * from './types.js';
export { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';
export { WorkerAnalytics, getWorkerAnalytics, resetWorkerAnalytics } from './workerAnalytics.js';
export { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js';

View file

@ -0,0 +1,886 @@
/**
* Tests for Semantic Narrative Summarization
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SemanticNarrativeGenerator } from './semanticNarrative.js';
import { LogEvent, NarrativeUpdate, EventPattern } from './types.js';
describe('SemanticNarrativeGenerator', () => {
let generator: SemanticNarrativeGenerator;
beforeEach(() => {
generator = new SemanticNarrativeGenerator();
});
describe('processEvent', () => {
it('should process events and create narratives', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test-1',
level: 'info',
msg: 'Started working on task',
bead: 'bd-123',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test-1');
expect(narrative).toBeDefined();
expect(narrative.workerId).toBe('w-test-1');
expect(narrative.stats.totalEvents).toBe(1);
});
it('should track multiple workers separately', () => {
const event1: LogEvent = {
ts: Date.now(),
worker: 'w-alpha',
level: 'info',
msg: 'Working',
};
const event2: LogEvent = {
ts: Date.now() + 1000,
worker: 'w-beta',
level: 'info',
msg: 'Also working',
};
generator.processEvent(event1);
generator.processEvent(event2);
const narrativeAlpha = generator.generateNarrative('w-alpha');
const narrativeBeta = generator.generateNarrative('w-beta');
expect(narrativeAlpha.workerId).toBe('w-alpha');
expect(narrativeBeta.workerId).toBe('w-beta');
expect(narrativeAlpha.stats.totalEvents).toBe(1);
expect(narrativeBeta.stats.totalEvents).toBe(1);
});
it('should track files, beads, and tools', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Editing file',
tool: 'Edit',
path: '/src/test.ts',
bead: 'bd-123',
},
{
ts: Date.now() + 1000,
worker: 'w-test',
level: 'info',
msg: 'Writing file',
tool: 'Write',
path: '/src/new.ts',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.stats.filesModified).toBe(2);
expect(narrative.stats.toolsUsed).toBe(2);
expect(narrative.stats.beadsWorked).toBe(1);
});
it('should count errors', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'w-test',
level: 'error',
msg: 'Error occurred',
error: 'ECONNREFUSED',
},
{
ts: Date.now() + 1000,
worker: 'w-test',
level: 'error',
msg: 'Another error',
error: 'File not found',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.stats.errorsEncountered).toBe(2);
});
});
describe('pattern detection', () => {
it('should detect bead_started pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Started working on bead',
bead: 'bd-456',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('bead_started');
});
it('should detect bead_completed pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Completed bead successfully',
bead: 'bd-456',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('bead_completed');
});
it('should detect file_editing pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Editing file',
tool: 'Edit',
path: '/src/app.ts',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('file_editing');
});
it('should detect file_created pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Creating new file',
tool: 'Write',
path: '/src/new.ts',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('file_created');
});
it('should detect testing pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Running vitest',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('testing');
});
it('should detect debugging pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'error',
msg: 'Error in code',
error: 'TypeError',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('debugging');
});
it('should detect git_operations pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Committing changes',
tool: 'git',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('git_operations');
});
it('should detect investigation pattern', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Reading files',
tool: 'Read',
path: '/src/index.ts',
};
generator.processEvent(event);
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThan(0);
expect(narrative.segments[0].pattern).toBe('investigation');
});
});
describe('segment generation', () => {
it('should group similar events into segments', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file2.ts',
},
{
ts: baseTime + 2000,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file3.ts',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBe(1);
expect(narrative.segments[0].events.length).toBe(3);
expect(narrative.segments[0].pattern).toBe('file_editing');
});
it('should split segments on pattern change', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Running tests',
},
{
ts: baseTime + 2000,
worker: 'w-test',
level: 'info',
msg: 'Committing',
tool: 'git',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.segments.length).toBeGreaterThanOrEqual(2);
expect(narrative.segments[0].pattern).toBe('file_editing');
expect(narrative.segments[1].pattern).toBe('testing');
});
it('should split segments on time gaps', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 400000, // 6+ minute gap
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test', { segmentWindowMs: 300000 });
expect(narrative.segments.length).toBeGreaterThanOrEqual(2);
});
it('should track entities in segments', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file1.ts',
bead: 'bd-123',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file2.ts',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
const segment = narrative.segments[0];
expect(segment.entities.files).toContain('/src/file1.ts');
expect(segment.entities.files).toContain('/src/file2.ts');
expect(segment.entities.tools).toContain('Edit');
expect(segment.entities.beads).toContain('bd-123');
});
});
describe('narrative generation', () => {
it('should generate summary', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
bead: 'bd-123',
},
{
ts: baseTime + 60000,
worker: 'w-test',
level: 'info',
msg: 'Completed',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.summary).toBeDefined();
expect(narrative.summary.length).toBeGreaterThan(0);
expect(narrative.summary).toContain('1m');
});
it('should generate full narrative', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Running tests',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.fullNarrative).toBeDefined();
expect(narrative.fullNarrative.length).toBeGreaterThan(0);
});
it('should generate timeline', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Started',
bead: 'bd-123',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 2000,
worker: 'w-test',
level: 'info',
msg: 'Completed',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test', { includeTimeline: true });
expect(narrative.timeline).toBeDefined();
expect(narrative.timeline.length).toBeGreaterThan(0);
});
it('should extract accomplishments', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Completed task',
bead: 'bd-123',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Creating file',
tool: 'Write',
path: '/src/new.ts',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.accomplishments.length).toBeGreaterThan(0);
});
it('should extract challenges', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'error',
msg: 'Error occurred',
error: 'TypeError',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'error',
msg: 'Another error',
error: 'ECONNREFUSED',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.challenges.length).toBeGreaterThan(0);
});
it('should determine sentiment - productive', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Completed',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.sentiment).toBe('productive');
});
it('should determine sentiment - struggling', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'error',
msg: 'Error 1',
error: 'Error',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'error',
msg: 'Error 2',
error: 'Error',
},
{
ts: baseTime + 2000,
worker: 'w-test',
level: 'error',
msg: 'Error 3',
error: 'Error',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test');
expect(narrative.sentiment).toBe('struggling');
});
it('should determine sentiment - idle', () => {
const narrative = generator.generateNarrative('w-nonexistent');
expect(narrative.sentiment).toBe('idle');
});
});
describe('aggregated narratives', () => {
it('should generate aggregated narrative for all workers', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-alpha',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: baseTime + 1000,
worker: 'w-beta',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file2.ts',
},
{
ts: baseTime + 2000,
worker: 'w-alpha',
level: 'info',
msg: 'Completed',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateAggregatedNarrative();
expect(narrative.workerId).toBe('all');
expect(narrative.stats.totalEvents).toBe(3);
expect(narrative.title).toContain('2 workers');
});
it('should aggregate statistics correctly', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-alpha',
level: 'info',
msg: 'Working',
bead: 'bd-123',
path: '/src/file1.ts',
},
{
ts: baseTime + 1000,
worker: 'w-beta',
level: 'info',
msg: 'Working',
bead: 'bd-456',
path: '/src/file2.ts',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateAggregatedNarrative();
expect(narrative.stats.beadsWorked).toBe(2);
expect(narrative.stats.filesModified).toBe(2);
});
});
describe('narrative updates', () => {
it('should emit updates when events are processed', (done) => {
const updates: NarrativeUpdate[] = [];
const unsubscribe = generator.onUpdate((update) => {
updates.push(update);
if (updates.length === 2) {
expect(updates[0].type).toBe('segment_updated');
expect(updates[1].type).toBe('segment_updated');
unsubscribe();
done();
}
});
const baseTime = Date.now();
generator.processEvent({
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
});
generator.processEvent({
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Editing more',
tool: 'Edit',
path: '/src/file.ts',
});
});
it('should allow unsubscribing from updates', () => {
let updateCount = 0;
const unsubscribe = generator.onUpdate(() => {
updateCount++;
});
generator.processEvent({
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Event 1',
});
expect(updateCount).toBe(1);
unsubscribe();
generator.processEvent({
ts: Date.now() + 1000,
worker: 'w-test',
level: 'info',
msg: 'Event 2',
});
expect(updateCount).toBe(1); // Should not have increased
});
});
describe('formatting', () => {
it('should format narrative as markdown - brief style', () => {
const baseTime = Date.now();
generator.processEvent({
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
});
const narrative = generator.generateNarrative('w-test');
const formatted = generator.formatNarrative(narrative, 'brief');
expect(formatted).toContain('# ');
expect(formatted).toContain('## Summary');
expect(formatted).toContain('## Statistics');
});
it('should format narrative as markdown - detailed style', () => {
const baseTime = Date.now();
generator.processEvent({
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
});
const narrative = generator.generateNarrative('w-test');
const formatted = generator.formatNarrative(narrative, 'detailed');
expect(formatted).toContain('## Narrative');
});
it('should format narrative as markdown - timeline style', () => {
const baseTime = Date.now();
generator.processEvent({
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
});
const narrative = generator.generateNarrative('w-test', { includeTimeline: true });
const formatted = generator.formatNarrative(narrative, 'timeline');
expect(formatted).toContain('## Timeline');
});
it('should format narrative as markdown - technical style', () => {
const baseTime = Date.now();
generator.processEvent({
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Editing',
tool: 'Edit',
path: '/src/file.ts',
});
const narrative = generator.generateNarrative('w-test');
const formatted = generator.formatNarrative(narrative, 'technical');
expect(formatted).toContain('## Detailed Segments');
});
});
describe('filtering', () => {
it('should filter by time range', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Event 1',
},
{
ts: baseTime + 60000,
worker: 'w-test',
level: 'info',
msg: 'Event 2',
},
{
ts: baseTime + 120000,
worker: 'w-test',
level: 'info',
msg: 'Event 3',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test', {
startTime: baseTime + 30000,
endTime: baseTime + 90000,
});
expect(narrative.stats.totalEvents).toBe(1);
});
it('should filter by bead', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime,
worker: 'w-test',
level: 'info',
msg: 'Event 1',
bead: 'bd-123',
},
{
ts: baseTime + 1000,
worker: 'w-test',
level: 'info',
msg: 'Event 2',
bead: 'bd-456',
},
{
ts: baseTime + 2000,
worker: 'w-test',
level: 'info',
msg: 'Event 3',
bead: 'bd-123',
},
];
events.forEach(e => generator.processEvent(e));
const narrative = generator.generateNarrative('w-test', {
beadId: 'bd-123',
});
expect(narrative.stats.totalEvents).toBe(2);
expect(narrative.stats.beadsWorked).toBe(1);
});
});
describe('clear', () => {
it('should clear all narratives and contexts', () => {
generator.processEvent({
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Event',
});
let narrative = generator.generateNarrative('w-test');
expect(narrative.stats.totalEvents).toBe(1);
generator.clear();
narrative = generator.generateNarrative('w-test');
expect(narrative.stats.totalEvents).toBe(0);
});
});
});

881
src/semanticNarrative.ts Normal file
View file

@ -0,0 +1,881 @@
/**
* Semantic Narrative Summarization
*
* Generates natural language summaries of worker activity by:
* - Analyzing event sequences to detect patterns
* - Grouping related events into narrative segments
* - Generating human-readable summaries
* - Updating narratives in real-time
*/
import {
LogEvent,
SemanticNarrative,
NarrativeSegment,
NarrativeOptions,
NarrativeUpdate,
SemanticNarrativeManager,
EventPattern,
NarrativeStyle,
} from './types.js';
const DEFAULT_OPTIONS: Required<NarrativeOptions> = {
style: 'detailed',
workerId: '',
beadId: '',
startTime: 0,
endTime: 0,
minConfidence: 0.5,
maxSegments: 100,
includeTechnicalDetails: true,
includeTimeline: true,
segmentWindowMs: 300000, // 5 minutes
minEventsPerSegment: 1,
};
/**
* Internal tracking for narrative generation
*/
interface NarrativeContext {
narrativeId: string;
workerId: string;
events: LogEvent[];
segments: NarrativeSegment[];
activeSegment: NarrativeSegment | null;
lastEventTime: number;
startTime: number;
beadsWorked: Set<string>;
filesModified: Set<string>;
toolsUsed: Set<string>;
errorsEncountered: number;
updateCallbacks: Array<(update: NarrativeUpdate) => void>;
}
/**
* Semantic Narrative Manager
*/
export class SemanticNarrativeGenerator implements SemanticNarrativeManager {
private contexts: Map<string, NarrativeContext> = new Map();
private narratives: Map<string, SemanticNarrative> = new Map();
private globalUpdateCallbacks: Array<(update: NarrativeUpdate) => void> = [];
private segmentCounter = 0;
private narrativeCounter = 0;
/**
* Process an event and update narratives
*/
processEvent(event: LogEvent): void {
// Get or create context for this worker
let context = this.contexts.get(event.worker);
if (!context) {
context = this.createContext(event.worker, event.ts);
this.contexts.set(event.worker, context);
}
// Add event to context
context.events.push(event);
context.lastEventTime = event.ts;
// Track entities
if (event.bead) context.beadsWorked.add(event.bead);
if (event.path) context.filesModified.add(event.path);
if (event.tool) context.toolsUsed.add(event.tool);
if (event.level === 'error' || event.error) context.errorsEncountered++;
// Update or create narrative segment
this.updateNarrativeSegment(context, event);
}
/**
* Generate narrative for a specific worker
*/
generateNarrative(workerId: string, options: NarrativeOptions = {}): SemanticNarrative {
const opts = { ...DEFAULT_OPTIONS, ...options };
const context = this.contexts.get(workerId);
if (!context) {
return this.createEmptyNarrative(workerId);
}
// Filter events by time range if specified
let events = context.events;
if (opts.startTime > 0) {
events = events.filter(e => e.ts >= opts.startTime);
}
if (opts.endTime > 0) {
events = events.filter(e => e.ts <= opts.endTime);
}
// Filter by bead if specified
if (opts.beadId) {
events = events.filter(e => e.bead === opts.beadId);
}
// Regenerate segments from filtered events
const segments = this.generateSegments(events, opts);
// Generate narrative components
const summary = this.generateSummary(segments, events);
const fullNarrative = this.generateFullNarrative(segments, opts.style);
const timeline = opts.includeTimeline ? this.generateTimeline(segments) : [];
const accomplishments = this.extractAccomplishments(segments);
const challenges = this.extractChallenges(segments);
const sentiment = this.determineSentiment(segments, events);
// Calculate statistics
const beadsWorked = new Set(events.filter(e => e.bead).map(e => e.bead!));
const filesModified = new Set(events.filter(e => e.path).map(e => e.path!));
const toolsUsed = new Set(events.filter(e => e.tool).map(e => e.tool!));
const errorsEncountered = events.filter(e => e.level === 'error' || e.error).length;
const startTime = events.length > 0 ? events[0].ts : Date.now();
const endTime = events.length > 0 ? events[events.length - 1].ts : Date.now();
const narrative: SemanticNarrative = {
id: context.narrativeId,
workerId,
title: this.generateTitle(workerId, segments),
summary,
segments,
fullNarrative,
timeline,
startTime,
endTime,
durationMs: endTime - startTime,
accomplishments,
challenges,
sentiment,
stats: {
totalEvents: events.length,
segmentCount: segments.length,
beadsWorked: beadsWorked.size,
filesModified: filesModified.size,
errorsEncountered,
toolsUsed: toolsUsed.size,
},
generatedAt: Date.now(),
isLive: true,
};
this.narratives.set(narrative.id, narrative);
return narrative;
}
/**
* Generate aggregated narrative for all workers
*/
generateAggregatedNarrative(options: NarrativeOptions = {}): SemanticNarrative {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Collect all events from all workers
const allEvents: LogEvent[] = [];
for (const context of this.contexts.values()) {
allEvents.push(...context.events);
}
// Sort by timestamp
allEvents.sort((a, b) => a.ts - b.ts);
// Filter by time range
let events = allEvents;
if (opts.startTime > 0) {
events = events.filter(e => e.ts >= opts.startTime);
}
if (opts.endTime > 0) {
events = events.filter(e => e.ts <= opts.endTime);
}
// Generate segments
const segments = this.generateSegments(events, opts);
// Generate narrative components
const summary = this.generateSummary(segments, events, true);
const fullNarrative = this.generateFullNarrative(segments, opts.style, true);
const timeline = opts.includeTimeline ? this.generateTimeline(segments) : [];
const accomplishments = this.extractAccomplishments(segments);
const challenges = this.extractChallenges(segments);
const sentiment = this.determineSentiment(segments, events);
// Calculate statistics
const workers = new Set(events.map(e => e.worker));
const beadsWorked = new Set(events.filter(e => e.bead).map(e => e.bead!));
const filesModified = new Set(events.filter(e => e.path).map(e => e.path!));
const toolsUsed = new Set(events.filter(e => e.tool).map(e => e.tool!));
const errorsEncountered = events.filter(e => e.level === 'error' || e.error).length;
const startTime = events.length > 0 ? events[0].ts : Date.now();
const endTime = events.length > 0 ? events[events.length - 1].ts : Date.now();
const narrative: SemanticNarrative = {
id: `narrative-agg-${this.narrativeCounter++}`,
workerId: 'all',
title: `Aggregated Activity: ${workers.size} worker${workers.size !== 1 ? 's' : ''}`,
summary,
segments,
fullNarrative,
timeline,
startTime,
endTime,
durationMs: endTime - startTime,
accomplishments,
challenges,
sentiment,
stats: {
totalEvents: events.length,
segmentCount: segments.length,
beadsWorked: beadsWorked.size,
filesModified: filesModified.size,
errorsEncountered,
toolsUsed: toolsUsed.size,
},
generatedAt: Date.now(),
isLive: true,
};
this.narratives.set(narrative.id, narrative);
return narrative;
}
/**
* Get all active narratives
*/
getActiveNarratives(): SemanticNarrative[] {
return Array.from(this.narratives.values()).filter(n => n.isLive);
}
/**
* Get narrative by ID
*/
getNarrative(narrativeId: string): SemanticNarrative | undefined {
return this.narratives.get(narrativeId);
}
/**
* Subscribe to narrative updates
*/
onUpdate(callback: (update: NarrativeUpdate) => void): () => void {
this.globalUpdateCallbacks.push(callback);
return () => {
const index = this.globalUpdateCallbacks.indexOf(callback);
if (index > -1) {
this.globalUpdateCallbacks.splice(index, 1);
}
};
}
/**
* Clear all narratives
*/
clear(): void {
this.contexts.clear();
this.narratives.clear();
this.globalUpdateCallbacks = [];
this.segmentCounter = 0;
this.narrativeCounter = 0;
}
/**
* Format narrative as string
*/
formatNarrative(narrative: SemanticNarrative, style: NarrativeStyle = 'detailed'): string {
const lines: string[] = [];
// Title
lines.push(`# ${narrative.title}`);
lines.push('');
// Summary
lines.push('## Summary');
lines.push('');
lines.push(narrative.summary);
lines.push('');
// Statistics
lines.push('## Statistics');
lines.push('');
lines.push(`- **Duration:** ${this.formatDuration(narrative.durationMs)}`);
lines.push(`- **Events:** ${narrative.stats.totalEvents}`);
lines.push(`- **Beads Worked:** ${narrative.stats.beadsWorked}`);
lines.push(`- **Files Modified:** ${narrative.stats.filesModified}`);
lines.push(`- **Tools Used:** ${narrative.stats.toolsUsed}`);
lines.push(`- **Errors:** ${narrative.stats.errorsEncountered}`);
lines.push(`- **Sentiment:** ${narrative.sentiment}`);
lines.push('');
if (style === 'brief') {
return lines.join('\n');
}
// Accomplishments
if (narrative.accomplishments.length > 0) {
lines.push('## Accomplishments');
lines.push('');
narrative.accomplishments.forEach(acc => {
lines.push(`- ${acc}`);
});
lines.push('');
}
// Challenges
if (narrative.challenges.length > 0) {
lines.push('## Challenges');
lines.push('');
narrative.challenges.forEach(challenge => {
lines.push(`- ${challenge}`);
});
lines.push('');
}
if (style === 'timeline') {
// Timeline view
lines.push('## Timeline');
lines.push('');
narrative.timeline.forEach(item => {
lines.push(item);
});
lines.push('');
} else if (style === 'detailed' || style === 'technical') {
// Full narrative
lines.push('## Narrative');
lines.push('');
lines.push(narrative.fullNarrative);
lines.push('');
// Technical details
if (style === 'technical') {
lines.push('## Detailed Segments');
lines.push('');
narrative.segments.forEach((segment, i) => {
lines.push(`### ${i + 1}. ${segment.pattern} (${this.formatDuration(segment.durationMs)})`);
lines.push('');
lines.push(`**Summary:** ${segment.summary}`);
if (segment.details) {
lines.push('');
lines.push(segment.details);
}
lines.push('');
lines.push(`**Events:** ${segment.events.length}`);
if (segment.entities.files && segment.entities.files.length > 0) {
lines.push(`**Files:** ${segment.entities.files.join(', ')}`);
}
if (segment.entities.tools && segment.entities.tools.length > 0) {
lines.push(`**Tools:** ${segment.entities.tools.join(', ')}`);
}
lines.push('');
});
}
}
lines.push('---');
lines.push('');
lines.push(`*Generated at ${new Date(narrative.generatedAt).toISOString()}*`);
return lines.join('\n');
}
// ==========================================
// Private Helper Methods
// ==========================================
private createContext(workerId: string, startTime: number): NarrativeContext {
return {
narrativeId: `narrative-${this.narrativeCounter++}`,
workerId,
events: [],
segments: [],
activeSegment: null,
lastEventTime: startTime,
startTime,
beadsWorked: new Set(),
filesModified: new Set(),
toolsUsed: new Set(),
errorsEncountered: 0,
updateCallbacks: [],
};
}
private createEmptyNarrative(workerId: string): SemanticNarrative {
return {
id: `narrative-empty-${this.narrativeCounter++}`,
workerId,
title: `No activity for ${workerId}`,
summary: 'No events recorded for this worker.',
segments: [],
fullNarrative: 'No activity to report.',
timeline: [],
startTime: Date.now(),
endTime: Date.now(),
durationMs: 0,
accomplishments: [],
challenges: [],
sentiment: 'idle',
stats: {
totalEvents: 0,
segmentCount: 0,
beadsWorked: 0,
filesModified: 0,
errorsEncountered: 0,
toolsUsed: 0,
},
generatedAt: Date.now(),
isLive: false,
};
}
private updateNarrativeSegment(context: NarrativeContext, event: LogEvent): void {
const timeSinceLastEvent = context.lastEventTime > 0
? event.ts - context.lastEventTime
: 0;
// If too much time has passed, close the active segment
if (timeSinceLastEvent > DEFAULT_OPTIONS.segmentWindowMs && context.activeSegment) {
this.closeSegment(context);
}
// Detect pattern for this event
const pattern = this.detectPattern(event, context);
// If no active segment or pattern changed, create new segment
if (!context.activeSegment || context.activeSegment.pattern !== pattern) {
if (context.activeSegment) {
this.closeSegment(context);
}
context.activeSegment = this.createSegment(pattern, event, context);
} else {
// Add to existing segment
context.activeSegment.events.push(event);
context.activeSegment.endTime = event.ts;
context.activeSegment.durationMs = event.ts - context.activeSegment.startTime;
// Update entities
if (event.path && !context.activeSegment.entities.files?.includes(event.path)) {
context.activeSegment.entities.files = context.activeSegment.entities.files || [];
context.activeSegment.entities.files.push(event.path);
}
if (event.tool && !context.activeSegment.entities.tools?.includes(event.tool)) {
context.activeSegment.entities.tools = context.activeSegment.entities.tools || [];
context.activeSegment.entities.tools.push(event.tool);
}
// Update summary
context.activeSegment.summary = this.generateSegmentSummary(context.activeSegment);
}
// Emit update
this.emitUpdate({
narrativeId: context.narrativeId,
type: 'segment_updated',
segment: context.activeSegment,
timestamp: event.ts,
summary: context.activeSegment.summary,
});
}
private closeSegment(context: NarrativeContext): void {
if (!context.activeSegment) return;
context.activeSegment.isActive = false;
context.segments.push(context.activeSegment);
this.emitUpdate({
narrativeId: context.narrativeId,
type: 'segment_completed',
segment: context.activeSegment,
timestamp: context.activeSegment.endTime,
});
context.activeSegment = null;
}
private createSegment(pattern: EventPattern, event: LogEvent, context: NarrativeContext): NarrativeSegment {
const segment: NarrativeSegment = {
id: `segment-${this.segmentCounter++}`,
pattern,
summary: '',
startTime: event.ts,
endTime: event.ts,
durationMs: 0,
workerId: event.worker,
beadId: event.bead,
events: [event],
entities: {
files: event.path ? [event.path] : [],
tools: event.tool ? [event.tool] : [],
beads: event.bead ? [event.bead] : [],
errors: (event.level === 'error' || event.error) ? [event.error || event.msg] : [],
},
confidence: 0.8,
isActive: true,
};
segment.summary = this.generateSegmentSummary(segment);
return segment;
}
private detectPattern(event: LogEvent, context: NarrativeContext): EventPattern {
const msg = event.msg.toLowerCase();
const tool = event.tool?.toLowerCase() || '';
// Bead lifecycle
if (msg.includes('started') && event.bead) return 'bead_started';
if (msg.includes('completed') || msg.includes('finished')) return 'bead_completed';
// File operations
if (tool === 'write' || msg.includes('creating file')) return 'file_created';
if (tool === 'edit' || tool === 'notebookedit') return 'file_editing';
// Testing
if (msg.includes('test') || msg.includes('vitest') || msg.includes('jest')) return 'testing';
// Debugging
if (event.level === 'error' || event.error || msg.includes('debug')) return 'debugging';
// Git operations
if (tool === 'git' || msg.includes('commit') || msg.includes('push')) return 'git_operations';
// Dependency management
if (msg.includes('npm install') || msg.includes('yarn') || msg.includes('dependency')) return 'dependency_install';
// Investigation
if (tool === 'read' || tool === 'grep' || tool === 'glob') return 'investigation';
// Iteration (multiple edits to same file)
if (context.activeSegment?.pattern === 'file_editing' && context.activeSegment.entities.files?.includes(event.path || '')) {
return 'iteration';
}
// Default
return 'investigation';
}
private generateSegments(events: LogEvent[], options: Required<NarrativeOptions>): NarrativeSegment[] {
const segments: NarrativeSegment[] = [];
let currentSegment: NarrativeSegment | null = null;
let lastEventTime = 0;
const tempContext: Partial<NarrativeContext> = {
segments: [],
activeSegment: null,
};
for (const event of events) {
const timeSinceLastEvent = lastEventTime > 0 ? event.ts - lastEventTime : 0;
// Close segment if time gap is too large
if (timeSinceLastEvent > options.segmentWindowMs && currentSegment) {
currentSegment.isActive = false;
if (currentSegment.events.length >= options.minEventsPerSegment) {
segments.push(currentSegment);
}
currentSegment = null;
}
const pattern = this.detectPattern(event, tempContext as NarrativeContext);
// Create new segment if pattern changed or no active segment
if (!currentSegment || currentSegment.pattern !== pattern) {
if (currentSegment) {
currentSegment.isActive = false;
if (currentSegment.events.length >= options.minEventsPerSegment) {
segments.push(currentSegment);
}
}
currentSegment = this.createSegment(pattern, event, tempContext as NarrativeContext);
} else {
// Add to existing segment
currentSegment.events.push(event);
currentSegment.endTime = event.ts;
currentSegment.durationMs = event.ts - currentSegment.startTime;
if (event.path && !currentSegment.entities.files?.includes(event.path)) {
currentSegment.entities.files = currentSegment.entities.files || [];
currentSegment.entities.files.push(event.path);
}
if (event.tool && !currentSegment.entities.tools?.includes(event.tool)) {
currentSegment.entities.tools = currentSegment.entities.tools || [];
currentSegment.entities.tools.push(event.tool);
}
}
tempContext.activeSegment = currentSegment;
lastEventTime = event.ts;
}
// Add final segment
if (currentSegment && currentSegment.events.length >= options.minEventsPerSegment) {
currentSegment.isActive = false;
segments.push(currentSegment);
}
// Update all segment summaries
segments.forEach(segment => {
segment.summary = this.generateSegmentSummary(segment);
});
return segments.slice(0, options.maxSegments);
}
private generateSegmentSummary(segment: NarrativeSegment): string {
const { pattern, events, entities } = segment;
const fileCount = entities.files?.length || 0;
const toolCount = entities.tools?.length || 0;
switch (pattern) {
case 'bead_started':
return `Started working on ${segment.beadId || 'a task'}`;
case 'bead_completed':
return `Completed ${segment.beadId || 'task'} (${this.formatDuration(segment.durationMs)})`;
case 'file_editing':
if (fileCount === 1) {
return `Editing ${entities.files![0]}`;
}
return `Editing ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
case 'file_created':
if (fileCount === 1) {
return `Created ${entities.files![0]}`;
}
return `Created ${fileCount} new file${fileCount !== 1 ? 's' : ''}`;
case 'testing':
return `Running tests (${events.length} event${events.length !== 1 ? 's' : ''})`;
case 'debugging':
const errorCount = entities.errors?.length || events.length;
return `Debugging ${errorCount} error${errorCount !== 1 ? 's' : ''}`;
case 'git_operations':
return `Git operations (${events.length} action${events.length !== 1 ? 's' : ''})`;
case 'dependency_install':
return 'Installing dependencies';
case 'iteration':
return `Iterative refinement on ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
case 'investigation':
return `Investigating codebase (${toolCount} tool${toolCount !== 1 ? 's' : ''} used)`;
default:
return `Working (${events.length} event${events.length !== 1 ? 's' : ''})`;
}
}
private generateSummary(segments: NarrativeSegment[], events: LogEvent[], isAggregated = false): string {
if (segments.length === 0) {
return 'No activity to report.';
}
const beads = new Set(events.filter(e => e.bead).map(e => e.bead!));
const files = new Set(events.filter(e => e.path).map(e => e.path!));
const errors = events.filter(e => e.level === 'error' || e.error).length;
const totalDuration = this.formatDuration(
events.length > 0 ? events[events.length - 1].ts - events[0].ts : 0
);
const parts: string[] = [];
if (isAggregated) {
const workers = new Set(events.map(e => e.worker));
parts.push(`${workers.size} worker${workers.size !== 1 ? 's' : ''} active over ${totalDuration}`);
} else {
parts.push(`Active for ${totalDuration}`);
}
if (beads.size > 0) {
parts.push(`worked on ${beads.size} bead${beads.size !== 1 ? 's' : ''}`);
}
if (files.size > 0) {
parts.push(`modified ${files.size} file${files.size !== 1 ? 's' : ''}`);
}
if (errors > 0) {
parts.push(`encountered ${errors} error${errors !== 1 ? 's' : ''}`);
}
const mainActivities = this.getTopPatterns(segments, 3);
if (mainActivities.length > 0) {
parts.push(`primarily ${mainActivities.map(p => this.patternToVerb(p)).join(', ')}`);
}
return parts.join(', ') + '.';
}
private generateFullNarrative(segments: NarrativeSegment[], style: NarrativeStyle = 'detailed', isAggregated = false): string {
if (segments.length === 0) {
return 'No activity recorded.';
}
const lines: string[] = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const connector = i === 0 ? 'Started by' : this.getConnector(segment, segments[i - 1]);
lines.push(`${connector} ${segment.summary.toLowerCase()}.`);
if (style === 'detailed' && segment.details) {
lines.push(` ${segment.details}`);
}
}
return lines.join(' ');
}
private generateTimeline(segments: NarrativeSegment[]): string[] {
return segments.map(segment => {
const time = new Date(segment.startTime).toISOString().split('T')[1].split('.')[0];
return `[${time}] ${segment.summary}`;
});
}
private generateTitle(workerId: string, segments: NarrativeSegment[]): string {
if (segments.length === 0) {
return `${workerId}: Idle`;
}
const topPattern = this.getTopPatterns(segments, 1)[0];
const verb = topPattern ? this.patternToVerb(topPattern) : 'working';
return `${workerId}: ${verb.charAt(0).toUpperCase() + verb.slice(1)}`;
}
private extractAccomplishments(segments: NarrativeSegment[]): string[] {
const accomplishments: string[] = [];
for (const segment of segments) {
if (segment.pattern === 'bead_completed') {
accomplishments.push(`Completed ${segment.beadId || 'task'}`);
} else if (segment.pattern === 'file_created' && segment.entities.files) {
accomplishments.push(`Created ${segment.entities.files.length} file${segment.entities.files.length !== 1 ? 's' : ''}`);
} else if (segment.pattern === 'git_operations') {
accomplishments.push('Committed changes to Git');
}
}
return accomplishments.slice(0, 5);
}
private extractChallenges(segments: NarrativeSegment[]): string[] {
const challenges: string[] = [];
for (const segment of segments) {
if (segment.pattern === 'debugging' && segment.entities.errors && segment.entities.errors.length > 0) {
challenges.push(`Debugged ${segment.entities.errors.length} error${segment.entities.errors.length !== 1 ? 's' : ''}`);
}
}
return challenges.slice(0, 5);
}
private determineSentiment(segments: NarrativeSegment[], events: LogEvent[]): 'productive' | 'struggling' | 'mixed' | 'idle' {
if (segments.length === 0) return 'idle';
const completions = segments.filter(s => s.pattern === 'bead_completed').length;
const errors = segments.filter(s => s.pattern === 'debugging').length;
const totalTime = segments.reduce((sum, s) => sum + s.durationMs, 0);
if (completions > 0 && errors === 0) return 'productive';
if (errors > completions * 2) return 'struggling';
if (completions > 0 || totalTime > 300000) return 'productive'; // > 5 minutes active
if (errors > 0) return 'mixed';
return 'mixed';
}
private getTopPatterns(segments: NarrativeSegment[], count: number): EventPattern[] {
const patternCounts = new Map<EventPattern, number>();
for (const segment of segments) {
patternCounts.set(segment.pattern, (patternCounts.get(segment.pattern) || 0) + 1);
}
return Array.from(patternCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, count)
.map(([pattern]) => pattern);
}
private patternToVerb(pattern: EventPattern): string {
const verbs: Record<EventPattern, string> = {
bead_started: 'starting tasks',
bead_completed: 'completing tasks',
file_editing: 'editing files',
file_created: 'creating files',
testing: 'running tests',
debugging: 'debugging',
git_operations: 'using git',
dependency_install: 'installing dependencies',
iteration: 'iterating',
investigation: 'investigating',
collision_detected: 'resolving conflicts',
error_recovery: 'recovering from errors',
};
return verbs[pattern] || 'working';
}
private getConnector(current: NarrativeSegment, previous: NarrativeSegment): string {
const timeDiff = current.startTime - previous.endTime;
if (timeDiff > 300000) { // 5 minutes
return 'After a pause,';
}
if (current.pattern === previous.pattern) {
return 'Continued';
}
if (current.pattern === 'debugging' && previous.pattern === 'testing') {
return 'Tests revealed issues, then';
}
if (current.pattern === 'testing' && previous.pattern === 'file_editing') {
return 'After edits,';
}
if (current.pattern === 'git_operations' && previous.pattern === 'testing') {
return 'Tests passed, then';
}
return 'Then';
}
private emitUpdate(update: NarrativeUpdate): void {
this.globalUpdateCallbacks.forEach(callback => {
try {
callback(update);
} catch (error) {
console.error('Error in narrative update callback:', error);
}
});
}
private 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`;
}
}
// Singleton instance
let instance: SemanticNarrativeGenerator | null = null;
export function getSemanticNarrativeManager(): SemanticNarrativeGenerator {
if (!instance) {
instance = new SemanticNarrativeGenerator();
}
return instance;
}

View file

@ -32,11 +32,15 @@ import {
CrossReferenceQueryOptions,
CrossReferenceStats,
CrossReferencePath,
SemanticNarrative,
NarrativeOptions,
NarrativeUpdate,
} from './types.js';
import { ErrorGroupManager, getErrorGroupManager } from './errorGrouping.js';
import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlaybook.js';
import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js';
import { WorkerAnalytics, getWorkerAnalytics } from './workerAnalytics.js';
import { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js';
/** Time window (in ms) to consider events as concurrent */
const COLLISION_WINDOW_MS = 5000;
@ -81,6 +85,7 @@ export class InMemoryEventStore implements EventStore {
private recoveryManager: RecoveryManager;
private crossReferenceManager: CrossReferenceManager;
private workerAnalytics: WorkerAnalytics;
private semanticNarrativeManager: SemanticNarrativeGenerator;
private maxEvents: number;
private alertCounter = 0;
private batchBuffer: LogEvent[] = [];
@ -92,6 +97,7 @@ export class InMemoryEventStore implements EventStore {
this.recoveryManager = getRecoveryManager();
this.crossReferenceManager = getCrossReferenceManager();
this.workerAnalytics = getWorkerAnalytics();
this.semanticNarrativeManager = getSemanticNarrativeManager();
}
/**
@ -116,6 +122,9 @@ export class InMemoryEventStore implements EventStore {
// Process event for worker analytics
this.workerAnalytics.processEvent(event);
// Process event for semantic narrative (real-time)
this.semanticNarrativeManager.processEvent(event);
// Add to batch buffer for relationship detection
this.batchBuffer.push(event);
this.scheduleBatchProcessing();
@ -1230,6 +1239,59 @@ export class InMemoryEventStore implements EventStore {
clearCrossReferences(): void {
this.crossReferenceManager.clear();
}
// ============================================
// Semantic Narrative Methods
// ============================================
/**
* Generate semantic narrative for a specific worker
*/
generateNarrative(workerId: string, options?: NarrativeOptions): SemanticNarrative {
return this.semanticNarrativeManager.generateNarrative(workerId, options);
}
/**
* Generate aggregated narrative for all workers
*/
generateAggregatedNarrative(options?: NarrativeOptions): SemanticNarrative {
return this.semanticNarrativeManager.generateAggregatedNarrative(options);
}
/**
* Get all active narratives
*/
getActiveNarratives(): SemanticNarrative[] {
return this.semanticNarrativeManager.getActiveNarratives();
}
/**
* Get narrative by ID
*/
getNarrative(narrativeId: string): SemanticNarrative | undefined {
return this.semanticNarrativeManager.getNarrative(narrativeId);
}
/**
* Subscribe to narrative updates
*/
onNarrativeUpdate(callback: (update: NarrativeUpdate) => void): () => void {
return this.semanticNarrativeManager.onUpdate(callback);
}
/**
* Format narrative as markdown
*/
formatNarrative(narrative: SemanticNarrative, style?: 'brief' | 'detailed' | 'timeline' | 'technical'): string {
return this.semanticNarrativeManager.formatNarrative(narrative, style);
}
/**
* Get semantic narrative manager instance
*/
getSemanticNarrativeManager(): SemanticNarrativeGenerator {
return this.semanticNarrativeManager;
}
}
/**

View file

@ -16,7 +16,9 @@ import { FileHeatmap } from './components/FileHeatmap.js';
import { DependencyDag } from './components/DependencyDag.js';
import { SessionReplay } from './components/SessionReplay.js';
import { ErrorGroupPanel } from './components/ErrorGroupPanel.js';
import { SessionDigest, generateSessionDigest } from './components/SessionDigest.js';
import { getErrorGroupManager } from '../errorGrouping.js';
import { WorkerSessionSummary } from '../types.js';
export interface TuiOptions {
/** Log file path to tail */
@ -36,7 +38,7 @@ export class FabricTuiApp {
private isRunning = false;
// View mode
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' = 'default';
private viewMode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' = 'default';
// Focus mode state
private focusModeEnabled = false;
@ -53,6 +55,7 @@ export class FabricTuiApp {
private dependencyDag!: DependencyDag;
private sessionReplay!: SessionReplay;
private errorGroupPanel!: ErrorGroupPanel;
private sessionDigest!: SessionDigest;
private footerBox!: blessed.Widgets.BoxElement;
private helpOverlay?: blessed.Widgets.BoxElement;
@ -183,6 +186,19 @@ export class FabricTuiApp {
});
this.errorGroupPanel.hide();
// Session Digest panel (hidden by default, 'G' key)
this.sessionDigest = new SessionDigest({
parent: this.screen,
top: 1,
left: 0,
width: '100%',
height: '100%-2',
onExport: (format, path) => {
// Log export
},
});
this.sessionDigest.hide();
// Footer with key hints
this.footerBox = blessed.box({
parent: this.screen,

View file

@ -0,0 +1,962 @@
/**
* SessionDigest Component
*
* Displays a summary digest of worker session activity including:
* - Summary statistics
* - List of completed beads/work
* - Notable events (errors, warnings)
* - Export functionality
*/
import * as blessed from 'blessed';
import * as fs from 'fs';
import * as path from 'path';
import {
SessionDigest,
BeadCompletion,
FileModificationSummary,
ErrorOccurrence,
WorkerSessionSummary,
LogEvent,
ErrorCategory,
} from '../../types.js';
import { colors, getLevelColor } from '../utils/colors.js';
export interface SessionDigestOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position options */
top: number | string;
left: number | string;
width: number | string;
height: number | string;
/** Callback when digest is exported */
onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
}
export type DigestViewTab = 'summary' | 'beads' | 'files' | 'errors' | 'workers';
export class SessionDigest {
private container: blessed.Widgets.BoxElement;
private contentBox: blessed.Widgets.BoxElement;
private tabBar: blessed.Widgets.BoxElement;
private headerBox: blessed.Widgets.BoxElement;
private footerBox: blessed.Widgets.BoxElement;
private digest: SessionDigest | null = null;
private currentTab: DigestViewTab = 'summary';
private scrollOffset = 0;
private onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
constructor(options: SessionDigestOptions) {
this.onExport = options.onExport;
// Main container
this.container = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Session Digest ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
hidden: true,
});
// Header with session info
this.headerBox = blessed.box({
parent: this.container,
top: 0,
left: 0,
right: 0,
height: 2,
content: '{gray-fg}No session data loaded{/}',
tags: true,
});
// Tab bar
this.tabBar = blessed.box({
parent: this.container,
top: 2,
left: 0,
right: 0,
height: 1,
content: this.getTabBarContent(),
tags: true,
style: {
fg: colors.muted,
},
});
// Content area
this.contentBox = blessed.box({
parent: this.container,
top: 3,
left: 0,
right: 0,
bottom: 1,
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
tags: true,
style: {
fg: colors.text,
},
});
// Footer with controls
this.footerBox = blessed.box({
parent: this.container,
bottom: 0,
left: 0,
right: 0,
height: 1,
content: ' [1-5] Tabs [e] Export JSON [m] Export Markdown [j/k] Scroll [Esc] Close',
style: {
fg: colors.muted,
},
});
// Bind keyboard events
this.bindKeys();
}
/**
* Bind keyboard shortcuts
*/
private bindKeys(): void {
this.contentBox.key(['1'], () => this.switchTab('summary'));
this.contentBox.key(['2'], () => this.switchTab('beads'));
this.contentBox.key(['3'], () => this.switchTab('files'));
this.contentBox.key(['4'], () => this.switchTab('errors'));
this.contentBox.key(['5'], () => this.switchTab('workers'));
this.contentBox.key(['e'], () => this.exportDigest('json'));
this.contentBox.key(['m'], () => this.exportDigest('markdown'));
this.contentBox.key(['t'], () => this.exportDigest('text'));
}
/**
* Get tab bar content with current tab highlighted
*/
private getTabBarContent(): string {
const tabs: Array<{ key: string; label: string; tab: DigestViewTab }> = [
{ key: '1', label: 'Summary', tab: 'summary' },
{ key: '2', label: 'Beads', tab: 'beads' },
{ key: '3', label: 'Files', tab: 'files' },
{ key: '4', label: 'Errors', tab: 'errors' },
{ key: '5', label: 'Workers', tab: 'workers' },
];
return tabs
.map((t) => {
const isActive = t.tab === this.currentTab;
const color = isActive ? 'cyan' : 'gray';
const prefix = isActive ? '[' : ' ';
const suffix = isActive ? ']' : ' ';
return `{${color}-fg}${prefix}${t.key}:${t.label}${suffix}{/}`;
})
.join(' ');
}
/**
* Switch to a different tab
*/
switchTab(tab: DigestViewTab): void {
this.currentTab = tab;
this.scrollOffset = 0;
this.tabBar.setContent(this.getTabBarContent());
this.render();
this.container.screen.render();
}
/**
* Set the session digest data
*/
setDigest(digest: SessionDigest): void {
this.digest = digest;
this.scrollOffset = 0;
this.updateHeader();
this.render();
}
/**
* Update the header with session info
*/
private updateHeader(): void {
if (!this.digest) {
this.headerBox.setContent('{gray-fg}No session data loaded{/}');
return;
}
const d = this.digest;
const duration = this.formatDuration(d.durationMs);
const startTime = new Date(d.startTime).toLocaleString();
const endTime = new Date(d.endTime).toLocaleString();
const header = `{bold}Session:{/} ${d.sessionId.slice(0, 16)}... ` +
`{bold}Duration:{/} ${duration} ` +
`{bold}Events:{/} ${d.stats.totalEvents} ` +
`{bold}Workers:{/} ${d.stats.totalWorkers}`;
this.headerBox.setContent(header);
}
/**
* Render the current tab content
*/
render(): void {
if (!this.digest) {
this.contentBox.setContent('{gray-fg}No session data loaded{/}');
this.container.screen.render();
return;
}
let content = '';
switch (this.currentTab) {
case 'summary':
content = this.renderSummary();
break;
case 'beads':
content = this.renderBeads();
break;
case 'files':
content = this.renderFiles();
break;
case 'errors':
content = this.renderErrors();
break;
case 'workers':
content = this.renderWorkers();
break;
}
this.contentBox.setContent(content);
this.contentBox.setScrollPerc(0);
this.container.screen.render();
}
/**
* Render summary tab
*/
private renderSummary(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('{bold}{cyan-fg} SESSION SUMMARY{/}');
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('');
// Session info
lines.push('{bold}Session ID:{/} ' + d.sessionId);
lines.push('{bold}Start Time:{/} ' + new Date(d.startTime).toLocaleString());
lines.push('{bold}End Time:{/} ' + new Date(d.endTime).toLocaleString());
lines.push('{bold}Duration:{/} ' + this.formatDuration(d.durationMs));
lines.push('');
// Statistics
lines.push('{bold}{green-fg}─── Statistics ───{/}');
lines.push(` {bold}Total Events:{/} ${d.stats.totalEvents}`);
lines.push(` {bold}Total Workers:{/} ${d.stats.totalWorkers}`);
lines.push(` {bold}Total Beads:{/} ${d.stats.totalBeads}`);
lines.push(` {bold}Total Files:{/} ${d.stats.totalFiles}`);
lines.push(` {bold}Total Errors:{/} {red-fg}${d.stats.totalErrors}{/}`);
lines.push(` {bold}Avg Events/Worker:{/} ${d.stats.avgEventsPerWorker.toFixed(1)}`);
lines.push(` {bold}Avg Beads/Worker:{/} ${d.stats.avgBeadsPerWorker.toFixed(1)}`);
lines.push('');
// Cost breakdown
if (d.cost) {
lines.push('{bold}{yellow-fg}─── Cost Breakdown ───{/}');
lines.push(` {bold}Input Tokens:{/} ${d.cost.inputTokens.toLocaleString()}`);
lines.push(` {bold}Output Tokens:{/} ${d.cost.outputTokens.toLocaleString()}`);
lines.push(` {bold}Total Tokens:{/} ${d.cost.totalTokens.toLocaleString()}`);
lines.push(` {bold}Est. Cost:{/} {green-fg}$${d.cost.estimatedCostUsd.toFixed(4)}{/}`);
lines.push('');
}
// Quick stats
lines.push('{bold}{magenta-fg}─── Completed Work ───{/}');
lines.push(` {bold}Beads Completed:{/} {green-fg}${d.beadsCompleted.length}{/}`);
lines.push(` {bold}Files Modified:{/} {cyan-fg}${d.filesModified.length}{/}`);
lines.push(` {bold}Workers Active:{/} ${d.workers.length}`);
lines.push('');
// Error summary
if (d.errors.length > 0) {
lines.push('{bold}{red-fg}─── Errors ({/}' + d.errors.length + '{bold}{red-fg}) ───{/}');
// Group errors by category
const errorsByCategory: Record<ErrorCategory, number> = {
network: 0,
permission: 0,
validation: 0,
resource: 0,
not_found: 0,
timeout: 0,
syntax: 0,
tool: 0,
unknown: 0,
};
for (const err of d.errors) {
errorsByCategory[err.category]++;
}
for (const [category, count] of Object.entries(errorsByCategory)) {
if (count > 0) {
lines.push(` {red-fg}${category}:{/} ${count}`);
}
}
}
return lines.join('\n');
}
/**
* Render beads tab
*/
private renderBeads(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('{bold}{cyan-fg} COMPLETED BEADS ({/}' + d.beadsCompleted.length + '{bold}{cyan-fg}){/}');
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('');
if (d.beadsCompleted.length === 0) {
lines.push('{gray-fg}No beads completed in this session{/}');
return lines.join('\n');
}
// Sort by completion time (most recent first)
const sorted = [...d.beadsCompleted].sort((a, b) => b.completedAt - a.completedAt);
for (const bead of sorted) {
const time = new Date(bead.completedAt).toLocaleTimeString();
const duration = bead.durationMs ? ` (${this.formatDuration(bead.durationMs)})` : '';
const worker = bead.workerId.slice(0, 8);
lines.push(`{magenta-fg}${bead.beadId}{/} {gray-fg}by{/} {cyan-fg}${worker}{/} {gray-fg}at{/} ${time}${duration}`);
}
return lines.join('\n');
}
/**
* Render files tab
*/
private renderFiles(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('{bold}{cyan-fg} FILES MODIFIED ({/}' + d.filesModified.length + '{bold}{cyan-fg}){/}');
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('');
if (d.filesModified.length === 0) {
lines.push('{gray-fg}No files modified in this session{/}');
return lines.join('\n');
}
// Sort by modification count (most modified first)
const sorted = [...d.filesModified].sort((a, b) => b.modifications - a.modifications);
for (const file of sorted) {
const mods = file.modifications;
const modStr = mods === 1 ? '1 mod' : `${mods} mods`;
const workers = file.workers.length === 1 ? '1 worker' : `${file.workers.length} workers`;
// Color based on modification count
let color = 'green';
if (mods >= 10) color = 'red';
else if (mods >= 5) color = 'yellow';
else if (mods >= 3) color = 'cyan';
lines.push(`{${color}-fg}${modStr}{/} {gray-fg}by{/} ${workers}`);
lines.push(` {white-fg}${file.path}{/}`);
if (file.tools.length > 0) {
lines.push(` {gray-fg}Tools: ${file.tools.join(', ')}{/}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Render errors tab
*/
private renderErrors(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('{bold}{cyan-fg} ERRORS ({/}' + d.errors.length + '{bold}{cyan-fg}){/}');
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('');
if (d.errors.length === 0) {
lines.push('{green-fg}✓ No errors encountered in this session{/}');
return lines.join('\n');
}
// Sort by timestamp (most recent first)
const sorted = [...d.errors].sort((a, b) => b.timestamp - a.timestamp);
for (const err of sorted) {
const time = new Date(err.timestamp).toLocaleTimeString();
const worker = err.workerId.slice(0, 8);
const category = err.category.toUpperCase();
lines.push(`{red-fg}[${category}]{/} {gray-fg}${time}{/} {cyan-fg}${worker}{/}`);
lines.push(` {white-fg}${err.message.slice(0, 100)}${err.message.length > 100 ? '...' : ''}{/}`);
if (err.fingerprint) {
lines.push(` {gray-fg}Fingerprint: ${err.fingerprint}{/}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Render workers tab
*/
private renderWorkers(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('{bold}{cyan-fg} WORKERS ({/}' + d.workers.length + '{bold}{cyan-fg}){/}');
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
lines.push('');
if (d.workers.length === 0) {
lines.push('{gray-fg}No workers in this session{/}');
return lines.join('\n');
}
// Sort by beads completed (most productive first)
const sorted = [...d.workers].sort((a, b) => b.beadsCompleted - a.beadsCompleted);
for (const worker of sorted) {
const activeTime = this.formatDuration(worker.activeTimeMs);
const firstActivity = new Date(worker.firstActivity).toLocaleTimeString();
const lastActivity = new Date(worker.lastActivity).toLocaleTimeString();
lines.push(`{bold}{cyan-fg}${worker.workerId}{/}`);
lines.push(` {bold}Beads Completed:{/} {green-fg}${worker.beadsCompleted}{/}`);
lines.push(` {bold}Files Modified:{/} ${worker.filesModified}`);
lines.push(` {bold}Errors:{/} {red-fg}${worker.errorsEncountered}{/}`);
lines.push(` {bold}Total Events:{/} ${worker.totalEvents}`);
lines.push(` {bold}Active Time:{/} ${activeTime}`);
lines.push(` {bold}First Activity:{/} ${firstActivity}`);
lines.push(` {bold}Last Activity:{/} ${lastActivity}`);
lines.push('');
}
return lines.join('\n');
}
/**
* Format duration for display
*/
private formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
/**
* Export digest to file
*/
exportDigest(format: 'json' | 'markdown' | 'text'): void {
if (!this.digest) {
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `session-digest-${timestamp}`;
let filePath: string;
let content: string;
switch (format) {
case 'json':
filePath = `${baseName}.json`;
content = JSON.stringify(this.digest, null, 2);
break;
case 'markdown':
filePath = `${baseName}.md`;
content = this.formatAsMarkdown();
break;
case 'text':
filePath = `${baseName}.txt`;
content = this.formatAsText();
break;
}
// Write to current directory or temp
const outputPath = path.join(process.cwd(), filePath);
try {
fs.writeFileSync(outputPath, content, 'utf-8');
// Show success message
const successMsg = `{green-fg}✓ Exported to ${outputPath}{/}`;
this.footerBox.setContent(successMsg);
this.container.screen.render();
// Reset footer after 3 seconds
setTimeout(() => {
this.footerBox.setContent(' [1-5] Tabs [e] Export JSON [m] Export Markdown [j/k] Scroll [Esc] Close');
this.container.screen.render();
}, 3000);
if (this.onExport) {
this.onExport(format, outputPath);
}
} catch (error) {
const errorMsg = `{red-fg}✗ Export failed: ${error}{/}`;
this.footerBox.setContent(errorMsg);
this.container.screen.render();
}
}
/**
* Format digest as markdown
*/
private formatAsMarkdown(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('# Session Digest');
lines.push('');
lines.push(`**Session ID:** ${d.sessionId}`);
lines.push(`**Start Time:** ${new Date(d.startTime).toLocaleString()}`);
lines.push(`**End Time:** ${new Date(d.endTime).toLocaleString()}`);
lines.push(`**Duration:** ${this.formatDuration(d.durationMs)}`);
lines.push('');
lines.push('## Statistics');
lines.push('');
lines.push('| Metric | Value |');
lines.push('|--------|-------|');
lines.push(`| Total Events | ${d.stats.totalEvents} |`);
lines.push(`| Total Workers | ${d.stats.totalWorkers} |`);
lines.push(`| Total Beads | ${d.stats.totalBeads} |`);
lines.push(`| Total Files | ${d.stats.totalFiles} |`);
lines.push(`| Total Errors | ${d.stats.totalErrors} |`);
lines.push('');
if (d.cost) {
lines.push('## Cost Breakdown');
lines.push('');
lines.push(`- **Input Tokens:** ${d.cost.inputTokens.toLocaleString()}`);
lines.push(`- **Output Tokens:** ${d.cost.outputTokens.toLocaleString()}`);
lines.push(`- **Total Tokens:** ${d.cost.totalTokens.toLocaleString()}`);
lines.push(`- **Estimated Cost:** $${d.cost.estimatedCostUsd.toFixed(4)}`);
lines.push('');
}
lines.push('## Completed Beads');
lines.push('');
if (d.beadsCompleted.length === 0) {
lines.push('_No beads completed_');
} else {
lines.push('| Bead ID | Worker | Completed At | Duration |');
lines.push('|---------|--------|--------------|----------|');
for (const bead of d.beadsCompleted) {
const time = new Date(bead.completedAt).toLocaleString();
const duration = bead.durationMs ? this.formatDuration(bead.durationMs) : '-';
lines.push(`| ${bead.beadId} | ${bead.workerId.slice(0, 8)} | ${time} | ${duration} |`);
}
}
lines.push('');
lines.push('## Files Modified');
lines.push('');
if (d.filesModified.length === 0) {
lines.push('_No files modified_');
} else {
lines.push('| Path | Modifications | Workers |');
lines.push('|------|---------------|---------|');
for (const file of d.filesModified) {
lines.push(`| \`${file.path}\` | ${file.modifications} | ${file.workers.length} |`);
}
}
lines.push('');
lines.push('## Errors');
lines.push('');
if (d.errors.length === 0) {
lines.push('_No errors encountered_');
} else {
lines.push('| Time | Category | Worker | Message |');
lines.push('|------|----------|--------|---------|');
for (const err of d.errors) {
const time = new Date(err.timestamp).toLocaleTimeString();
const msg = err.message.slice(0, 50).replace(/\n/g, ' ');
lines.push(`| ${time} | ${err.category} | ${err.workerId.slice(0, 8)} | ${msg} |`);
}
}
lines.push('');
lines.push('## Worker Summary');
lines.push('');
lines.push('| Worker ID | Beads | Files | Errors | Active Time |');
lines.push('|-----------|-------|-------|--------|-------------|');
for (const worker of d.workers) {
lines.push(`| ${worker.workerId.slice(0, 8)} | ${worker.beadsCompleted} | ${worker.filesModified} | ${worker.errorsEncountered} | ${this.formatDuration(worker.activeTimeMs)} |`);
}
lines.push('');
lines.push('---');
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
return lines.join('\n');
}
/**
* Format digest as plain text
*/
private formatAsText(): string {
if (!this.digest) return '';
const d = this.digest;
const lines: string[] = [];
lines.push('SESSION DIGEST');
lines.push('='.repeat(50));
lines.push('');
lines.push(`Session ID: ${d.sessionId}`);
lines.push(`Start Time: ${new Date(d.startTime).toLocaleString()}`);
lines.push(`End Time: ${new Date(d.endTime).toLocaleString()}`);
lines.push(`Duration: ${this.formatDuration(d.durationMs)}`);
lines.push('');
lines.push('STATISTICS');
lines.push('-'.repeat(30));
lines.push(`Total Events: ${d.stats.totalEvents}`);
lines.push(`Total Workers: ${d.stats.totalWorkers}`);
lines.push(`Total Beads: ${d.stats.totalBeads}`);
lines.push(`Total Files: ${d.stats.totalFiles}`);
lines.push(`Total Errors: ${d.stats.totalErrors}`);
lines.push('');
if (d.cost) {
lines.push('COST BREAKDOWN');
lines.push('-'.repeat(30));
lines.push(`Input Tokens: ${d.cost.inputTokens.toLocaleString()}`);
lines.push(`Output Tokens: ${d.cost.outputTokens.toLocaleString()}`);
lines.push(`Total Tokens: ${d.cost.totalTokens.toLocaleString()}`);
lines.push(`Estimated Cost: $${d.cost.estimatedCostUsd.toFixed(4)}`);
lines.push('');
}
lines.push('COMPLETED BEADS');
lines.push('-'.repeat(30));
for (const bead of d.beadsCompleted) {
const time = new Date(bead.completedAt).toLocaleString();
const duration = bead.durationMs ? ` (${this.formatDuration(bead.durationMs)})` : '';
lines.push(`${bead.beadId} by ${bead.workerId.slice(0, 8)} at ${time}${duration}`);
}
if (d.beadsCompleted.length === 0) {
lines.push('No beads completed');
}
lines.push('');
lines.push('FILES MODIFIED');
lines.push('-'.repeat(30));
for (const file of d.filesModified) {
lines.push(`${file.path} (${file.modifications} mods by ${file.workers.length} workers)`);
}
if (d.filesModified.length === 0) {
lines.push('No files modified');
}
lines.push('');
lines.push('ERRORS');
lines.push('-'.repeat(30));
for (const err of d.errors) {
const time = new Date(err.timestamp).toLocaleTimeString();
lines.push(`[${err.category.toUpperCase()}] ${time} ${err.workerId.slice(0, 8)}: ${err.message.slice(0, 100)}`);
}
if (d.errors.length === 0) {
lines.push('No errors encountered');
}
lines.push('');
lines.push('---');
lines.push(`Generated by FABRIC at ${new Date().toLocaleString()}`);
return lines.join('\n');
}
/**
* Show the digest panel
*/
show(): void {
this.container.show();
this.contentBox.focus();
this.container.screen.render();
}
/**
* Hide the digest panel
*/
hide(): void {
this.container.hide();
this.container.screen.render();
}
/**
* Toggle visibility
*/
toggle(): void {
if (this.container.hidden) {
this.show();
} else {
this.hide();
}
}
/**
* Check if visible
*/
isVisible(): boolean {
return !this.container.hidden;
}
/**
* Focus this component
*/
focus(): void {
this.contentBox.focus();
}
/**
* Get the underlying blessed element
*/
getElement(): blessed.Widgets.BoxElement {
return this.container;
}
/**
* Get current tab
*/
getCurrentTab(): DigestViewTab {
return this.currentTab;
}
}
/**
* Generate a session digest from events and worker data
*/
export function generateSessionDigest(
events: LogEvent[],
workers: WorkerSessionSummary[],
options: {
sessionId?: string;
startTime?: number;
endTime?: number;
includeCost?: boolean;
} = {}
): SessionDigest {
const startTime = options.startTime || (events.length > 0 ? events[0].ts : Date.now());
const endTime = options.endTime || (events.length > 0 ? events[events.length - 1].ts : Date.now());
const sessionId = options.sessionId || `session-${Date.now()}`;
// Extract bead completions
const beadsCompleted: BeadCompletion[] = [];
const completedEvents = events.filter(e =>
e.msg.toLowerCase().includes('completed') ||
e.msg.toLowerCase().includes('complete')
);
for (const event of completedEvents) {
if (event.bead) {
beadsCompleted.push({
beadId: event.bead,
workerId: event.worker,
completedAt: event.ts,
durationMs: event.duration_ms,
});
}
}
// Extract file modifications
const fileModMap = new Map<string, {
modifications: number;
workers: Set<string>;
tools: Set<string>;
}>();
const fileEvents = events.filter(e => e.path && e.tool);
for (const event of fileEvents) {
const existing = fileModMap.get(event.path!);
if (existing) {
existing.modifications++;
existing.workers.add(event.worker);
if (event.tool) existing.tools.add(event.tool);
} else {
fileModMap.set(event.path!, {
modifications: 1,
workers: new Set([event.worker]),
tools: new Set(event.tool ? [event.tool] : []),
});
}
}
const filesModified: FileModificationSummary[] = [];
for (const [path, data] of fileModMap) {
filesModified.push({
path,
modifications: data.modifications,
workers: Array.from(data.workers),
tools: Array.from(data.tools),
});
}
// Extract errors
const errors: ErrorOccurrence[] = events
.filter(e => e.level === 'error')
.map(e => ({
message: e.error || e.msg,
category: categorizeError(e.error || e.msg) as ErrorCategory,
workerId: e.worker,
timestamp: e.ts,
fingerprint: e.error ? generateFingerprint(e.error) : undefined,
}));
// Calculate totals
const totalEvents = events.length;
const totalWorkers = workers.length;
const totalBeads = beadsCompleted.length;
const totalFiles = filesModified.length;
const totalErrors = errors.length;
// Calculate cost (placeholder - would need actual token tracking)
const cost = {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
};
// If we have token info in events, aggregate it
for (const event of events) {
const tokens = (event as any).tokens;
if (tokens) {
cost.totalTokens += tokens;
}
}
return {
sessionId,
startTime,
endTime,
durationMs: endTime - startTime,
beadsCompleted,
filesModified,
errors,
workers,
cost,
stats: {
totalEvents,
totalWorkers,
totalBeads,
totalFiles,
totalErrors,
avgEventsPerWorker: totalWorkers > 0 ? totalEvents / totalWorkers : 0,
avgBeadsPerWorker: totalWorkers > 0 ? totalBeads / totalWorkers : 0,
},
};
}
/**
* Categorize an error message
*/
function categorizeError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes('econnrefused') || lower.includes('enotfound') ||
lower.includes('network') || lower.includes('dns') ||
lower.includes('socket') || lower.includes('connection')) {
return 'network';
}
if (lower.includes('permission') || lower.includes('access denied') ||
lower.includes('unauthorized') || lower.includes('forbidden') ||
lower.includes('auth')) {
return 'permission';
}
if (lower.includes('validation') || lower.includes('invalid') ||
lower.includes('schema') || lower.includes('type error')) {
return 'validation';
}
if (lower.includes('out of memory') || lower.includes('disk full') ||
lower.includes('quota') || lower.includes('resource')) {
return 'resource';
}
if (lower.includes('not found') || lower.includes('enoent') ||
lower.includes('404')) {
return 'not_found';
}
if (lower.includes('timeout') || lower.includes('timed out')) {
return 'timeout';
}
if (lower.includes('syntax') || lower.includes('parse') ||
lower.includes('unexpected token')) {
return 'syntax';
}
if (lower.includes('tool') || lower.includes('command failed')) {
return 'tool';
}
return 'unknown';
}
/**
* Generate a fingerprint for error grouping
*/
function generateFingerprint(message: string): string {
// Simple fingerprint based on first 50 chars normalized
const normalized = message
.toLowerCase()
.replace(/\d+/g, 'N')
.replace(/['"]/g, '')
.slice(0, 50);
return normalized;
}
export function createSessionDigest(options: SessionDigestOptions): SessionDigest {
return new SessionDigest(options);
}

View file

@ -36,3 +36,6 @@ export { formatRecoveryForConsole, getRecoverySummary } from './RecoveryPanel.js
export { ErrorGroupPanel } from './ErrorGroupPanel.js';
export type { ErrorGroupPanelOptions } from './ErrorGroupPanel.js';
export { SessionDigest, createSessionDigest, generateSessionDigest } from './SessionDigest.js';
export type { SessionDigestOptions, DigestViewTab } from './SessionDigest.js';

View file

@ -1751,3 +1751,225 @@ export interface WorkerAnalyticsStore {
/** Get analytics summary as formatted string */
getSummary(options?: WorkerAnalyticsOptions): string;
}
// ============================================
// Semantic Narrative Types
// ============================================
/**
* Narrative style for summarization
*/
export type NarrativeStyle = 'brief' | 'detailed' | 'timeline' | 'technical';
/**
* Event pattern types that drive narrative generation
*/
export type EventPattern =
| 'bead_started' // Worker started working on a bead
| 'bead_completed' // Worker completed a bead
| 'file_editing' // Editing files
| 'file_created' // Creating new files
| 'testing' // Running tests
| 'debugging' // Debugging errors
| 'git_operations' // Git commits, pushes, etc.
| 'dependency_install' // Installing dependencies
| 'collision_detected' // Workers colliding
| 'error_recovery' // Recovering from errors
| 'iteration' // Iterative refinement
| 'investigation'; // Investigating/researching
/**
* A single narrative segment describing a sequence of events
*/
export interface NarrativeSegment {
/** Unique segment ID */
id: string;
/** Event pattern this segment describes */
pattern: EventPattern;
/** Natural language summary */
summary: string;
/** Detailed narrative (if available) */
details?: string;
/** Start timestamp */
startTime: number;
/** End timestamp */
endTime: number;
/** Duration in milliseconds */
durationMs: number;
/** Worker ID */
workerId: string;
/** Associated bead (if any) */
beadId?: string;
/** Events that comprise this segment */
events: LogEvent[];
/** Key entities mentioned (files, tools, etc.) */
entities: {
files?: string[];
tools?: string[];
beads?: string[];
errors?: string[];
};
/** Confidence in pattern detection (0-1) */
confidence: number;
/** Whether this segment is still active/ongoing */
isActive: boolean;
}
/**
* A complete narrative for a worker session or time period
*/
export interface SemanticNarrative {
/** Narrative ID */
id: string;
/** Worker ID (or 'all' for multi-worker narratives) */
workerId: string;
/** Narrative title */
title: string;
/** High-level summary (1-2 sentences) */
summary: string;
/** All narrative segments in chronological order */
segments: NarrativeSegment[];
/** Full narrative text */
fullNarrative: string;
/** Timeline of key events */
timeline: string[];
/** Start timestamp */
startTime: number;
/** End timestamp */
endTime: number;
/** Total duration */
durationMs: number;
/** Key accomplishments */
accomplishments: string[];
/** Challenges encountered */
challenges: string[];
/** Overall sentiment: 'productive' | 'struggling' | 'mixed' | 'idle' */
sentiment: 'productive' | 'struggling' | 'mixed' | 'idle';
/** Statistics */
stats: {
totalEvents: number;
segmentCount: number;
beadsWorked: number;
filesModified: number;
errorsEncountered: number;
toolsUsed: number;
};
/** When this narrative was generated */
generatedAt: number;
/** Whether this narrative is still being updated */
isLive: boolean;
}
/**
* Options for narrative generation
*/
export interface NarrativeOptions {
/** Narrative style */
style?: NarrativeStyle;
/** Filter by worker ID */
workerId?: string;
/** Filter by bead ID */
beadId?: string;
/** Time range start */
startTime?: number;
/** Time range end */
endTime?: number;
/** Minimum confidence for pattern detection */
minConfidence?: number;
/** Maximum segments to generate */
maxSegments?: number;
/** Include technical details */
includeTechnicalDetails?: boolean;
/** Include timeline */
includeTimeline?: boolean;
/** Group events by time window (ms) */
segmentWindowMs?: number;
/** Minimum events per segment */
minEventsPerSegment?: number;
}
/**
* Narrative update event - emitted when narrative changes
*/
export interface NarrativeUpdate {
/** Narrative ID */
narrativeId: string;
/** Update type */
type: 'segment_added' | 'segment_updated' | 'segment_completed' | 'narrative_completed';
/** Updated segment (if applicable) */
segment?: NarrativeSegment;
/** Timestamp of update */
timestamp: number;
/** New summary text */
summary?: string;
}
/**
* Semantic narrative manager interface
*/
export interface SemanticNarrativeManager {
/** Process an event and update narratives */
processEvent(event: LogEvent): void;
/** Generate narrative for a worker */
generateNarrative(workerId: string, options?: NarrativeOptions): SemanticNarrative;
/** Generate narrative for all workers */
generateAggregatedNarrative(options?: NarrativeOptions): SemanticNarrative;
/** Get current active narratives */
getActiveNarratives(): SemanticNarrative[];
/** Get narrative by ID */
getNarrative(narrativeId: string): SemanticNarrative | undefined;
/** Subscribe to narrative updates */
onUpdate(callback: (update: NarrativeUpdate) => void): () => void;
/** Clear all narratives */
clear(): void;
/** Get narrative as formatted string */
formatNarrative(narrative: SemanticNarrative, style?: NarrativeStyle): string;
}