diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f75978b..4ce2764 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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"}]} diff --git a/src/index.ts b/src/index.ts index 72b5fec..7418194 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/semanticNarrative.test.ts b/src/semanticNarrative.test.ts new file mode 100644 index 0000000..0885f39 --- /dev/null +++ b/src/semanticNarrative.test.ts @@ -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); + }); + }); +}); diff --git a/src/semanticNarrative.ts b/src/semanticNarrative.ts new file mode 100644 index 0000000..3127c45 --- /dev/null +++ b/src/semanticNarrative.ts @@ -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 = { + 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; + filesModified: Set; + toolsUsed: Set; + errorsEncountered: number; + updateCallbacks: Array<(update: NarrativeUpdate) => void>; +} + +/** + * Semantic Narrative Manager + */ +export class SemanticNarrativeGenerator implements SemanticNarrativeManager { + private contexts: Map = new Map(); + private narratives: Map = 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): NarrativeSegment[] { + const segments: NarrativeSegment[] = []; + let currentSegment: NarrativeSegment | null = null; + let lastEventTime = 0; + + const tempContext: Partial = { + 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(); + + 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 = { + 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; +} diff --git a/src/store.ts b/src/store.ts index f5b67bb..bd5de31 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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; + } } /** diff --git a/src/tui/app.ts b/src/tui/app.ts index 8519dc8..cd8d978 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -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, diff --git a/src/tui/components/SessionDigest.ts b/src/tui/components/SessionDigest.ts new file mode 100644 index 0000000..8ebc872 --- /dev/null +++ b/src/tui/components/SessionDigest.ts @@ -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 = { + 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; + tools: Set; + }>(); + + 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); +} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index e84c094..d2dee0d 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -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'; diff --git a/src/types.ts b/src/types.ts index cd99380..33e8bff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}