feat(bd-2st): Implement semantic narrative summarization
- Create SemanticNarrativeGenerator with event sequence analysis - Implement pattern detection for 12 event types (bead lifecycle, file ops, testing, debugging, git, etc.) - Add real-time narrative segmentation and updates - Generate natural language summaries with multiple styles (brief, detailed, timeline, technical) - Integrate with store for automatic event processing - Add comprehensive unit tests (35 tests, all passing) - Export semantic narrative from main index Features: - Event pattern detection and grouping - Real-time narrative updates via callback system - Multiple narrative styles (brief, detailed, timeline, technical) - Accomplishment and challenge extraction - Sentiment analysis (productive, struggling, mixed, idle) - Timeline generation - Aggregated narratives for multiple workers - Filtering by time range and bead ID Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
parent
dc4f33266a
commit
8002f002bf
9 changed files with 3036 additions and 3 deletions
|
|
@ -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"}]}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
886
src/semanticNarrative.test.ts
Normal file
886
src/semanticNarrative.test.ts
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
/**
|
||||
* Tests for Semantic Narrative Summarization
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SemanticNarrativeGenerator } from './semanticNarrative.js';
|
||||
import { LogEvent, NarrativeUpdate, EventPattern } from './types.js';
|
||||
|
||||
describe('SemanticNarrativeGenerator', () => {
|
||||
let generator: SemanticNarrativeGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new SemanticNarrativeGenerator();
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
it('should process events and create narratives', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test-1',
|
||||
level: 'info',
|
||||
msg: 'Started working on task',
|
||||
bead: 'bd-123',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test-1');
|
||||
expect(narrative).toBeDefined();
|
||||
expect(narrative.workerId).toBe('w-test-1');
|
||||
expect(narrative.stats.totalEvents).toBe(1);
|
||||
});
|
||||
|
||||
it('should track multiple workers separately', () => {
|
||||
const event1: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-alpha',
|
||||
level: 'info',
|
||||
msg: 'Working',
|
||||
};
|
||||
|
||||
const event2: LogEvent = {
|
||||
ts: Date.now() + 1000,
|
||||
worker: 'w-beta',
|
||||
level: 'info',
|
||||
msg: 'Also working',
|
||||
};
|
||||
|
||||
generator.processEvent(event1);
|
||||
generator.processEvent(event2);
|
||||
|
||||
const narrativeAlpha = generator.generateNarrative('w-alpha');
|
||||
const narrativeBeta = generator.generateNarrative('w-beta');
|
||||
|
||||
expect(narrativeAlpha.workerId).toBe('w-alpha');
|
||||
expect(narrativeBeta.workerId).toBe('w-beta');
|
||||
expect(narrativeAlpha.stats.totalEvents).toBe(1);
|
||||
expect(narrativeBeta.stats.totalEvents).toBe(1);
|
||||
});
|
||||
|
||||
it('should track files, beads, and tools', () => {
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing file',
|
||||
tool: 'Edit',
|
||||
path: '/src/test.ts',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: Date.now() + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Writing file',
|
||||
tool: 'Write',
|
||||
path: '/src/new.ts',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.stats.filesModified).toBe(2);
|
||||
expect(narrative.stats.toolsUsed).toBe(2);
|
||||
expect(narrative.stats.beadsWorked).toBe(1);
|
||||
});
|
||||
|
||||
it('should count errors', () => {
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error occurred',
|
||||
error: 'ECONNREFUSED',
|
||||
},
|
||||
{
|
||||
ts: Date.now() + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Another error',
|
||||
error: 'File not found',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.stats.errorsEncountered).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern detection', () => {
|
||||
it('should detect bead_started pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Started working on bead',
|
||||
bead: 'bd-456',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('bead_started');
|
||||
});
|
||||
|
||||
it('should detect bead_completed pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Completed bead successfully',
|
||||
bead: 'bd-456',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('bead_completed');
|
||||
});
|
||||
|
||||
it('should detect file_editing pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing file',
|
||||
tool: 'Edit',
|
||||
path: '/src/app.ts',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('file_editing');
|
||||
});
|
||||
|
||||
it('should detect file_created pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Creating new file',
|
||||
tool: 'Write',
|
||||
path: '/src/new.ts',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('file_created');
|
||||
});
|
||||
|
||||
it('should detect testing pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Running vitest',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('testing');
|
||||
});
|
||||
|
||||
it('should detect debugging pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error in code',
|
||||
error: 'TypeError',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('debugging');
|
||||
});
|
||||
|
||||
it('should detect git_operations pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Committing changes',
|
||||
tool: 'git',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('git_operations');
|
||||
});
|
||||
|
||||
it('should detect investigation pattern', () => {
|
||||
const event: LogEvent = {
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Reading files',
|
||||
tool: 'Read',
|
||||
path: '/src/index.ts',
|
||||
};
|
||||
|
||||
generator.processEvent(event);
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThan(0);
|
||||
expect(narrative.segments[0].pattern).toBe('investigation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('segment generation', () => {
|
||||
it('should group similar events into segments', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file1.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file2.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file3.ts',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBe(1);
|
||||
expect(narrative.segments[0].events.length).toBe(3);
|
||||
expect(narrative.segments[0].pattern).toBe('file_editing');
|
||||
});
|
||||
|
||||
it('should split segments on pattern change', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Running tests',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Committing',
|
||||
tool: 'git',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.segments.length).toBeGreaterThanOrEqual(2);
|
||||
expect(narrative.segments[0].pattern).toBe('file_editing');
|
||||
expect(narrative.segments[1].pattern).toBe('testing');
|
||||
});
|
||||
|
||||
it('should split segments on time gaps', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 400000, // 6+ minute gap
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test', { segmentWindowMs: 300000 });
|
||||
expect(narrative.segments.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should track entities in segments', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file1.ts',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file2.ts',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
const segment = narrative.segments[0];
|
||||
|
||||
expect(segment.entities.files).toContain('/src/file1.ts');
|
||||
expect(segment.entities.files).toContain('/src/file2.ts');
|
||||
expect(segment.entities.tools).toContain('Edit');
|
||||
expect(segment.entities.beads).toContain('bd-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrative generation', () => {
|
||||
it('should generate summary', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 60000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Completed',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.summary).toBeDefined();
|
||||
expect(narrative.summary.length).toBeGreaterThan(0);
|
||||
expect(narrative.summary).toContain('1m');
|
||||
});
|
||||
|
||||
it('should generate full narrative', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Running tests',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.fullNarrative).toBeDefined();
|
||||
expect(narrative.fullNarrative.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate timeline', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Started',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Completed',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test', { includeTimeline: true });
|
||||
expect(narrative.timeline).toBeDefined();
|
||||
expect(narrative.timeline.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should extract accomplishments', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Completed task',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Creating file',
|
||||
tool: 'Write',
|
||||
path: '/src/new.ts',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.accomplishments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should extract challenges', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error occurred',
|
||||
error: 'TypeError',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Another error',
|
||||
error: 'ECONNREFUSED',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.challenges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should determine sentiment - productive', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Completed',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.sentiment).toBe('productive');
|
||||
});
|
||||
|
||||
it('should determine sentiment - struggling', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error 1',
|
||||
error: 'Error',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error 2',
|
||||
error: 'Error',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-test',
|
||||
level: 'error',
|
||||
msg: 'Error 3',
|
||||
error: 'Error',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.sentiment).toBe('struggling');
|
||||
});
|
||||
|
||||
it('should determine sentiment - idle', () => {
|
||||
const narrative = generator.generateNarrative('w-nonexistent');
|
||||
expect(narrative.sentiment).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregated narratives', () => {
|
||||
it('should generate aggregated narrative for all workers', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-alpha',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file1.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-beta',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file2.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-alpha',
|
||||
level: 'info',
|
||||
msg: 'Completed',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateAggregatedNarrative();
|
||||
expect(narrative.workerId).toBe('all');
|
||||
expect(narrative.stats.totalEvents).toBe(3);
|
||||
expect(narrative.title).toContain('2 workers');
|
||||
});
|
||||
|
||||
it('should aggregate statistics correctly', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-alpha',
|
||||
level: 'info',
|
||||
msg: 'Working',
|
||||
bead: 'bd-123',
|
||||
path: '/src/file1.ts',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-beta',
|
||||
level: 'info',
|
||||
msg: 'Working',
|
||||
bead: 'bd-456',
|
||||
path: '/src/file2.ts',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateAggregatedNarrative();
|
||||
expect(narrative.stats.beadsWorked).toBe(2);
|
||||
expect(narrative.stats.filesModified).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrative updates', () => {
|
||||
it('should emit updates when events are processed', (done) => {
|
||||
const updates: NarrativeUpdate[] = [];
|
||||
|
||||
const unsubscribe = generator.onUpdate((update) => {
|
||||
updates.push(update);
|
||||
|
||||
if (updates.length === 2) {
|
||||
expect(updates[0].type).toBe('segment_updated');
|
||||
expect(updates[1].type).toBe('segment_updated');
|
||||
unsubscribe();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
const baseTime = Date.now();
|
||||
generator.processEvent({
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
|
||||
generator.processEvent({
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing more',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow unsubscribing from updates', () => {
|
||||
let updateCount = 0;
|
||||
|
||||
const unsubscribe = generator.onUpdate(() => {
|
||||
updateCount++;
|
||||
});
|
||||
|
||||
generator.processEvent({
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 1',
|
||||
});
|
||||
|
||||
expect(updateCount).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
generator.processEvent({
|
||||
ts: Date.now() + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 2',
|
||||
});
|
||||
|
||||
expect(updateCount).toBe(1); // Should not have increased
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatting', () => {
|
||||
it('should format narrative as markdown - brief style', () => {
|
||||
const baseTime = Date.now();
|
||||
generator.processEvent({
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
const formatted = generator.formatNarrative(narrative, 'brief');
|
||||
|
||||
expect(formatted).toContain('# ');
|
||||
expect(formatted).toContain('## Summary');
|
||||
expect(formatted).toContain('## Statistics');
|
||||
});
|
||||
|
||||
it('should format narrative as markdown - detailed style', () => {
|
||||
const baseTime = Date.now();
|
||||
generator.processEvent({
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
const formatted = generator.formatNarrative(narrative, 'detailed');
|
||||
|
||||
expect(formatted).toContain('## Narrative');
|
||||
});
|
||||
|
||||
it('should format narrative as markdown - timeline style', () => {
|
||||
const baseTime = Date.now();
|
||||
generator.processEvent({
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
|
||||
const narrative = generator.generateNarrative('w-test', { includeTimeline: true });
|
||||
const formatted = generator.formatNarrative(narrative, 'timeline');
|
||||
|
||||
expect(formatted).toContain('## Timeline');
|
||||
});
|
||||
|
||||
it('should format narrative as markdown - technical style', () => {
|
||||
const baseTime = Date.now();
|
||||
generator.processEvent({
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Editing',
|
||||
tool: 'Edit',
|
||||
path: '/src/file.ts',
|
||||
});
|
||||
|
||||
const narrative = generator.generateNarrative('w-test');
|
||||
const formatted = generator.formatNarrative(narrative, 'technical');
|
||||
|
||||
expect(formatted).toContain('## Detailed Segments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
it('should filter by time range', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 1',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 60000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 2',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 120000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 3',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test', {
|
||||
startTime: baseTime + 30000,
|
||||
endTime: baseTime + 90000,
|
||||
});
|
||||
|
||||
expect(narrative.stats.totalEvents).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by bead', () => {
|
||||
const baseTime = Date.now();
|
||||
const events: LogEvent[] = [
|
||||
{
|
||||
ts: baseTime,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 1',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 1000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 2',
|
||||
bead: 'bd-456',
|
||||
},
|
||||
{
|
||||
ts: baseTime + 2000,
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event 3',
|
||||
bead: 'bd-123',
|
||||
},
|
||||
];
|
||||
|
||||
events.forEach(e => generator.processEvent(e));
|
||||
|
||||
const narrative = generator.generateNarrative('w-test', {
|
||||
beadId: 'bd-123',
|
||||
});
|
||||
|
||||
expect(narrative.stats.totalEvents).toBe(2);
|
||||
expect(narrative.stats.beadsWorked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all narratives and contexts', () => {
|
||||
generator.processEvent({
|
||||
ts: Date.now(),
|
||||
worker: 'w-test',
|
||||
level: 'info',
|
||||
msg: 'Event',
|
||||
});
|
||||
|
||||
let narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.stats.totalEvents).toBe(1);
|
||||
|
||||
generator.clear();
|
||||
|
||||
narrative = generator.generateNarrative('w-test');
|
||||
expect(narrative.stats.totalEvents).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
881
src/semanticNarrative.ts
Normal file
881
src/semanticNarrative.ts
Normal file
|
|
@ -0,0 +1,881 @@
|
|||
/**
|
||||
* Semantic Narrative Summarization
|
||||
*
|
||||
* Generates natural language summaries of worker activity by:
|
||||
* - Analyzing event sequences to detect patterns
|
||||
* - Grouping related events into narrative segments
|
||||
* - Generating human-readable summaries
|
||||
* - Updating narratives in real-time
|
||||
*/
|
||||
|
||||
import {
|
||||
LogEvent,
|
||||
SemanticNarrative,
|
||||
NarrativeSegment,
|
||||
NarrativeOptions,
|
||||
NarrativeUpdate,
|
||||
SemanticNarrativeManager,
|
||||
EventPattern,
|
||||
NarrativeStyle,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_OPTIONS: Required<NarrativeOptions> = {
|
||||
style: 'detailed',
|
||||
workerId: '',
|
||||
beadId: '',
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
minConfidence: 0.5,
|
||||
maxSegments: 100,
|
||||
includeTechnicalDetails: true,
|
||||
includeTimeline: true,
|
||||
segmentWindowMs: 300000, // 5 minutes
|
||||
minEventsPerSegment: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal tracking for narrative generation
|
||||
*/
|
||||
interface NarrativeContext {
|
||||
narrativeId: string;
|
||||
workerId: string;
|
||||
events: LogEvent[];
|
||||
segments: NarrativeSegment[];
|
||||
activeSegment: NarrativeSegment | null;
|
||||
lastEventTime: number;
|
||||
startTime: number;
|
||||
beadsWorked: Set<string>;
|
||||
filesModified: Set<string>;
|
||||
toolsUsed: Set<string>;
|
||||
errorsEncountered: number;
|
||||
updateCallbacks: Array<(update: NarrativeUpdate) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Narrative Manager
|
||||
*/
|
||||
export class SemanticNarrativeGenerator implements SemanticNarrativeManager {
|
||||
private contexts: Map<string, NarrativeContext> = new Map();
|
||||
private narratives: Map<string, SemanticNarrative> = new Map();
|
||||
private globalUpdateCallbacks: Array<(update: NarrativeUpdate) => void> = [];
|
||||
private segmentCounter = 0;
|
||||
private narrativeCounter = 0;
|
||||
|
||||
/**
|
||||
* Process an event and update narratives
|
||||
*/
|
||||
processEvent(event: LogEvent): void {
|
||||
// Get or create context for this worker
|
||||
let context = this.contexts.get(event.worker);
|
||||
if (!context) {
|
||||
context = this.createContext(event.worker, event.ts);
|
||||
this.contexts.set(event.worker, context);
|
||||
}
|
||||
|
||||
// Add event to context
|
||||
context.events.push(event);
|
||||
context.lastEventTime = event.ts;
|
||||
|
||||
// Track entities
|
||||
if (event.bead) context.beadsWorked.add(event.bead);
|
||||
if (event.path) context.filesModified.add(event.path);
|
||||
if (event.tool) context.toolsUsed.add(event.tool);
|
||||
if (event.level === 'error' || event.error) context.errorsEncountered++;
|
||||
|
||||
// Update or create narrative segment
|
||||
this.updateNarrativeSegment(context, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate narrative for a specific worker
|
||||
*/
|
||||
generateNarrative(workerId: string, options: NarrativeOptions = {}): SemanticNarrative {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const context = this.contexts.get(workerId);
|
||||
|
||||
if (!context) {
|
||||
return this.createEmptyNarrative(workerId);
|
||||
}
|
||||
|
||||
// Filter events by time range if specified
|
||||
let events = context.events;
|
||||
if (opts.startTime > 0) {
|
||||
events = events.filter(e => e.ts >= opts.startTime);
|
||||
}
|
||||
if (opts.endTime > 0) {
|
||||
events = events.filter(e => e.ts <= opts.endTime);
|
||||
}
|
||||
|
||||
// Filter by bead if specified
|
||||
if (opts.beadId) {
|
||||
events = events.filter(e => e.bead === opts.beadId);
|
||||
}
|
||||
|
||||
// Regenerate segments from filtered events
|
||||
const segments = this.generateSegments(events, opts);
|
||||
|
||||
// Generate narrative components
|
||||
const summary = this.generateSummary(segments, events);
|
||||
const fullNarrative = this.generateFullNarrative(segments, opts.style);
|
||||
const timeline = opts.includeTimeline ? this.generateTimeline(segments) : [];
|
||||
const accomplishments = this.extractAccomplishments(segments);
|
||||
const challenges = this.extractChallenges(segments);
|
||||
const sentiment = this.determineSentiment(segments, events);
|
||||
|
||||
// Calculate statistics
|
||||
const beadsWorked = new Set(events.filter(e => e.bead).map(e => e.bead!));
|
||||
const filesModified = new Set(events.filter(e => e.path).map(e => e.path!));
|
||||
const toolsUsed = new Set(events.filter(e => e.tool).map(e => e.tool!));
|
||||
const errorsEncountered = events.filter(e => e.level === 'error' || e.error).length;
|
||||
|
||||
const startTime = events.length > 0 ? events[0].ts : Date.now();
|
||||
const endTime = events.length > 0 ? events[events.length - 1].ts : Date.now();
|
||||
|
||||
const narrative: SemanticNarrative = {
|
||||
id: context.narrativeId,
|
||||
workerId,
|
||||
title: this.generateTitle(workerId, segments),
|
||||
summary,
|
||||
segments,
|
||||
fullNarrative,
|
||||
timeline,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMs: endTime - startTime,
|
||||
accomplishments,
|
||||
challenges,
|
||||
sentiment,
|
||||
stats: {
|
||||
totalEvents: events.length,
|
||||
segmentCount: segments.length,
|
||||
beadsWorked: beadsWorked.size,
|
||||
filesModified: filesModified.size,
|
||||
errorsEncountered,
|
||||
toolsUsed: toolsUsed.size,
|
||||
},
|
||||
generatedAt: Date.now(),
|
||||
isLive: true,
|
||||
};
|
||||
|
||||
this.narratives.set(narrative.id, narrative);
|
||||
return narrative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate aggregated narrative for all workers
|
||||
*/
|
||||
generateAggregatedNarrative(options: NarrativeOptions = {}): SemanticNarrative {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
// Collect all events from all workers
|
||||
const allEvents: LogEvent[] = [];
|
||||
for (const context of this.contexts.values()) {
|
||||
allEvents.push(...context.events);
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
allEvents.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
// Filter by time range
|
||||
let events = allEvents;
|
||||
if (opts.startTime > 0) {
|
||||
events = events.filter(e => e.ts >= opts.startTime);
|
||||
}
|
||||
if (opts.endTime > 0) {
|
||||
events = events.filter(e => e.ts <= opts.endTime);
|
||||
}
|
||||
|
||||
// Generate segments
|
||||
const segments = this.generateSegments(events, opts);
|
||||
|
||||
// Generate narrative components
|
||||
const summary = this.generateSummary(segments, events, true);
|
||||
const fullNarrative = this.generateFullNarrative(segments, opts.style, true);
|
||||
const timeline = opts.includeTimeline ? this.generateTimeline(segments) : [];
|
||||
const accomplishments = this.extractAccomplishments(segments);
|
||||
const challenges = this.extractChallenges(segments);
|
||||
const sentiment = this.determineSentiment(segments, events);
|
||||
|
||||
// Calculate statistics
|
||||
const workers = new Set(events.map(e => e.worker));
|
||||
const beadsWorked = new Set(events.filter(e => e.bead).map(e => e.bead!));
|
||||
const filesModified = new Set(events.filter(e => e.path).map(e => e.path!));
|
||||
const toolsUsed = new Set(events.filter(e => e.tool).map(e => e.tool!));
|
||||
const errorsEncountered = events.filter(e => e.level === 'error' || e.error).length;
|
||||
|
||||
const startTime = events.length > 0 ? events[0].ts : Date.now();
|
||||
const endTime = events.length > 0 ? events[events.length - 1].ts : Date.now();
|
||||
|
||||
const narrative: SemanticNarrative = {
|
||||
id: `narrative-agg-${this.narrativeCounter++}`,
|
||||
workerId: 'all',
|
||||
title: `Aggregated Activity: ${workers.size} worker${workers.size !== 1 ? 's' : ''}`,
|
||||
summary,
|
||||
segments,
|
||||
fullNarrative,
|
||||
timeline,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMs: endTime - startTime,
|
||||
accomplishments,
|
||||
challenges,
|
||||
sentiment,
|
||||
stats: {
|
||||
totalEvents: events.length,
|
||||
segmentCount: segments.length,
|
||||
beadsWorked: beadsWorked.size,
|
||||
filesModified: filesModified.size,
|
||||
errorsEncountered,
|
||||
toolsUsed: toolsUsed.size,
|
||||
},
|
||||
generatedAt: Date.now(),
|
||||
isLive: true,
|
||||
};
|
||||
|
||||
this.narratives.set(narrative.id, narrative);
|
||||
return narrative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active narratives
|
||||
*/
|
||||
getActiveNarratives(): SemanticNarrative[] {
|
||||
return Array.from(this.narratives.values()).filter(n => n.isLive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get narrative by ID
|
||||
*/
|
||||
getNarrative(narrativeId: string): SemanticNarrative | undefined {
|
||||
return this.narratives.get(narrativeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to narrative updates
|
||||
*/
|
||||
onUpdate(callback: (update: NarrativeUpdate) => void): () => void {
|
||||
this.globalUpdateCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.globalUpdateCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.globalUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all narratives
|
||||
*/
|
||||
clear(): void {
|
||||
this.contexts.clear();
|
||||
this.narratives.clear();
|
||||
this.globalUpdateCallbacks = [];
|
||||
this.segmentCounter = 0;
|
||||
this.narrativeCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format narrative as string
|
||||
*/
|
||||
formatNarrative(narrative: SemanticNarrative, style: NarrativeStyle = 'detailed'): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push(`# ${narrative.title}`);
|
||||
lines.push('');
|
||||
|
||||
// Summary
|
||||
lines.push('## Summary');
|
||||
lines.push('');
|
||||
lines.push(narrative.summary);
|
||||
lines.push('');
|
||||
|
||||
// Statistics
|
||||
lines.push('## Statistics');
|
||||
lines.push('');
|
||||
lines.push(`- **Duration:** ${this.formatDuration(narrative.durationMs)}`);
|
||||
lines.push(`- **Events:** ${narrative.stats.totalEvents}`);
|
||||
lines.push(`- **Beads Worked:** ${narrative.stats.beadsWorked}`);
|
||||
lines.push(`- **Files Modified:** ${narrative.stats.filesModified}`);
|
||||
lines.push(`- **Tools Used:** ${narrative.stats.toolsUsed}`);
|
||||
lines.push(`- **Errors:** ${narrative.stats.errorsEncountered}`);
|
||||
lines.push(`- **Sentiment:** ${narrative.sentiment}`);
|
||||
lines.push('');
|
||||
|
||||
if (style === 'brief') {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Accomplishments
|
||||
if (narrative.accomplishments.length > 0) {
|
||||
lines.push('## Accomplishments');
|
||||
lines.push('');
|
||||
narrative.accomplishments.forEach(acc => {
|
||||
lines.push(`- ${acc}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Challenges
|
||||
if (narrative.challenges.length > 0) {
|
||||
lines.push('## Challenges');
|
||||
lines.push('');
|
||||
narrative.challenges.forEach(challenge => {
|
||||
lines.push(`- ${challenge}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (style === 'timeline') {
|
||||
// Timeline view
|
||||
lines.push('## Timeline');
|
||||
lines.push('');
|
||||
narrative.timeline.forEach(item => {
|
||||
lines.push(item);
|
||||
});
|
||||
lines.push('');
|
||||
} else if (style === 'detailed' || style === 'technical') {
|
||||
// Full narrative
|
||||
lines.push('## Narrative');
|
||||
lines.push('');
|
||||
lines.push(narrative.fullNarrative);
|
||||
lines.push('');
|
||||
|
||||
// Technical details
|
||||
if (style === 'technical') {
|
||||
lines.push('## Detailed Segments');
|
||||
lines.push('');
|
||||
narrative.segments.forEach((segment, i) => {
|
||||
lines.push(`### ${i + 1}. ${segment.pattern} (${this.formatDuration(segment.durationMs)})`);
|
||||
lines.push('');
|
||||
lines.push(`**Summary:** ${segment.summary}`);
|
||||
if (segment.details) {
|
||||
lines.push('');
|
||||
lines.push(segment.details);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(`**Events:** ${segment.events.length}`);
|
||||
if (segment.entities.files && segment.entities.files.length > 0) {
|
||||
lines.push(`**Files:** ${segment.entities.files.join(', ')}`);
|
||||
}
|
||||
if (segment.entities.tools && segment.entities.tools.length > 0) {
|
||||
lines.push(`**Tools:** ${segment.entities.tools.join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(`*Generated at ${new Date(narrative.generatedAt).toISOString()}*`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Private Helper Methods
|
||||
// ==========================================
|
||||
|
||||
private createContext(workerId: string, startTime: number): NarrativeContext {
|
||||
return {
|
||||
narrativeId: `narrative-${this.narrativeCounter++}`,
|
||||
workerId,
|
||||
events: [],
|
||||
segments: [],
|
||||
activeSegment: null,
|
||||
lastEventTime: startTime,
|
||||
startTime,
|
||||
beadsWorked: new Set(),
|
||||
filesModified: new Set(),
|
||||
toolsUsed: new Set(),
|
||||
errorsEncountered: 0,
|
||||
updateCallbacks: [],
|
||||
};
|
||||
}
|
||||
|
||||
private createEmptyNarrative(workerId: string): SemanticNarrative {
|
||||
return {
|
||||
id: `narrative-empty-${this.narrativeCounter++}`,
|
||||
workerId,
|
||||
title: `No activity for ${workerId}`,
|
||||
summary: 'No events recorded for this worker.',
|
||||
segments: [],
|
||||
fullNarrative: 'No activity to report.',
|
||||
timeline: [],
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
durationMs: 0,
|
||||
accomplishments: [],
|
||||
challenges: [],
|
||||
sentiment: 'idle',
|
||||
stats: {
|
||||
totalEvents: 0,
|
||||
segmentCount: 0,
|
||||
beadsWorked: 0,
|
||||
filesModified: 0,
|
||||
errorsEncountered: 0,
|
||||
toolsUsed: 0,
|
||||
},
|
||||
generatedAt: Date.now(),
|
||||
isLive: false,
|
||||
};
|
||||
}
|
||||
|
||||
private updateNarrativeSegment(context: NarrativeContext, event: LogEvent): void {
|
||||
const timeSinceLastEvent = context.lastEventTime > 0
|
||||
? event.ts - context.lastEventTime
|
||||
: 0;
|
||||
|
||||
// If too much time has passed, close the active segment
|
||||
if (timeSinceLastEvent > DEFAULT_OPTIONS.segmentWindowMs && context.activeSegment) {
|
||||
this.closeSegment(context);
|
||||
}
|
||||
|
||||
// Detect pattern for this event
|
||||
const pattern = this.detectPattern(event, context);
|
||||
|
||||
// If no active segment or pattern changed, create new segment
|
||||
if (!context.activeSegment || context.activeSegment.pattern !== pattern) {
|
||||
if (context.activeSegment) {
|
||||
this.closeSegment(context);
|
||||
}
|
||||
context.activeSegment = this.createSegment(pattern, event, context);
|
||||
} else {
|
||||
// Add to existing segment
|
||||
context.activeSegment.events.push(event);
|
||||
context.activeSegment.endTime = event.ts;
|
||||
context.activeSegment.durationMs = event.ts - context.activeSegment.startTime;
|
||||
|
||||
// Update entities
|
||||
if (event.path && !context.activeSegment.entities.files?.includes(event.path)) {
|
||||
context.activeSegment.entities.files = context.activeSegment.entities.files || [];
|
||||
context.activeSegment.entities.files.push(event.path);
|
||||
}
|
||||
if (event.tool && !context.activeSegment.entities.tools?.includes(event.tool)) {
|
||||
context.activeSegment.entities.tools = context.activeSegment.entities.tools || [];
|
||||
context.activeSegment.entities.tools.push(event.tool);
|
||||
}
|
||||
|
||||
// Update summary
|
||||
context.activeSegment.summary = this.generateSegmentSummary(context.activeSegment);
|
||||
}
|
||||
|
||||
// Emit update
|
||||
this.emitUpdate({
|
||||
narrativeId: context.narrativeId,
|
||||
type: 'segment_updated',
|
||||
segment: context.activeSegment,
|
||||
timestamp: event.ts,
|
||||
summary: context.activeSegment.summary,
|
||||
});
|
||||
}
|
||||
|
||||
private closeSegment(context: NarrativeContext): void {
|
||||
if (!context.activeSegment) return;
|
||||
|
||||
context.activeSegment.isActive = false;
|
||||
context.segments.push(context.activeSegment);
|
||||
|
||||
this.emitUpdate({
|
||||
narrativeId: context.narrativeId,
|
||||
type: 'segment_completed',
|
||||
segment: context.activeSegment,
|
||||
timestamp: context.activeSegment.endTime,
|
||||
});
|
||||
|
||||
context.activeSegment = null;
|
||||
}
|
||||
|
||||
private createSegment(pattern: EventPattern, event: LogEvent, context: NarrativeContext): NarrativeSegment {
|
||||
const segment: NarrativeSegment = {
|
||||
id: `segment-${this.segmentCounter++}`,
|
||||
pattern,
|
||||
summary: '',
|
||||
startTime: event.ts,
|
||||
endTime: event.ts,
|
||||
durationMs: 0,
|
||||
workerId: event.worker,
|
||||
beadId: event.bead,
|
||||
events: [event],
|
||||
entities: {
|
||||
files: event.path ? [event.path] : [],
|
||||
tools: event.tool ? [event.tool] : [],
|
||||
beads: event.bead ? [event.bead] : [],
|
||||
errors: (event.level === 'error' || event.error) ? [event.error || event.msg] : [],
|
||||
},
|
||||
confidence: 0.8,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
segment.summary = this.generateSegmentSummary(segment);
|
||||
return segment;
|
||||
}
|
||||
|
||||
private detectPattern(event: LogEvent, context: NarrativeContext): EventPattern {
|
||||
const msg = event.msg.toLowerCase();
|
||||
const tool = event.tool?.toLowerCase() || '';
|
||||
|
||||
// Bead lifecycle
|
||||
if (msg.includes('started') && event.bead) return 'bead_started';
|
||||
if (msg.includes('completed') || msg.includes('finished')) return 'bead_completed';
|
||||
|
||||
// File operations
|
||||
if (tool === 'write' || msg.includes('creating file')) return 'file_created';
|
||||
if (tool === 'edit' || tool === 'notebookedit') return 'file_editing';
|
||||
|
||||
// Testing
|
||||
if (msg.includes('test') || msg.includes('vitest') || msg.includes('jest')) return 'testing';
|
||||
|
||||
// Debugging
|
||||
if (event.level === 'error' || event.error || msg.includes('debug')) return 'debugging';
|
||||
|
||||
// Git operations
|
||||
if (tool === 'git' || msg.includes('commit') || msg.includes('push')) return 'git_operations';
|
||||
|
||||
// Dependency management
|
||||
if (msg.includes('npm install') || msg.includes('yarn') || msg.includes('dependency')) return 'dependency_install';
|
||||
|
||||
// Investigation
|
||||
if (tool === 'read' || tool === 'grep' || tool === 'glob') return 'investigation';
|
||||
|
||||
// Iteration (multiple edits to same file)
|
||||
if (context.activeSegment?.pattern === 'file_editing' && context.activeSegment.entities.files?.includes(event.path || '')) {
|
||||
return 'iteration';
|
||||
}
|
||||
|
||||
// Default
|
||||
return 'investigation';
|
||||
}
|
||||
|
||||
private generateSegments(events: LogEvent[], options: Required<NarrativeOptions>): NarrativeSegment[] {
|
||||
const segments: NarrativeSegment[] = [];
|
||||
let currentSegment: NarrativeSegment | null = null;
|
||||
let lastEventTime = 0;
|
||||
|
||||
const tempContext: Partial<NarrativeContext> = {
|
||||
segments: [],
|
||||
activeSegment: null,
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
const timeSinceLastEvent = lastEventTime > 0 ? event.ts - lastEventTime : 0;
|
||||
|
||||
// Close segment if time gap is too large
|
||||
if (timeSinceLastEvent > options.segmentWindowMs && currentSegment) {
|
||||
currentSegment.isActive = false;
|
||||
if (currentSegment.events.length >= options.minEventsPerSegment) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
currentSegment = null;
|
||||
}
|
||||
|
||||
const pattern = this.detectPattern(event, tempContext as NarrativeContext);
|
||||
|
||||
// Create new segment if pattern changed or no active segment
|
||||
if (!currentSegment || currentSegment.pattern !== pattern) {
|
||||
if (currentSegment) {
|
||||
currentSegment.isActive = false;
|
||||
if (currentSegment.events.length >= options.minEventsPerSegment) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
}
|
||||
currentSegment = this.createSegment(pattern, event, tempContext as NarrativeContext);
|
||||
} else {
|
||||
// Add to existing segment
|
||||
currentSegment.events.push(event);
|
||||
currentSegment.endTime = event.ts;
|
||||
currentSegment.durationMs = event.ts - currentSegment.startTime;
|
||||
|
||||
if (event.path && !currentSegment.entities.files?.includes(event.path)) {
|
||||
currentSegment.entities.files = currentSegment.entities.files || [];
|
||||
currentSegment.entities.files.push(event.path);
|
||||
}
|
||||
if (event.tool && !currentSegment.entities.tools?.includes(event.tool)) {
|
||||
currentSegment.entities.tools = currentSegment.entities.tools || [];
|
||||
currentSegment.entities.tools.push(event.tool);
|
||||
}
|
||||
}
|
||||
|
||||
tempContext.activeSegment = currentSegment;
|
||||
lastEventTime = event.ts;
|
||||
}
|
||||
|
||||
// Add final segment
|
||||
if (currentSegment && currentSegment.events.length >= options.minEventsPerSegment) {
|
||||
currentSegment.isActive = false;
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
// Update all segment summaries
|
||||
segments.forEach(segment => {
|
||||
segment.summary = this.generateSegmentSummary(segment);
|
||||
});
|
||||
|
||||
return segments.slice(0, options.maxSegments);
|
||||
}
|
||||
|
||||
private generateSegmentSummary(segment: NarrativeSegment): string {
|
||||
const { pattern, events, entities } = segment;
|
||||
const fileCount = entities.files?.length || 0;
|
||||
const toolCount = entities.tools?.length || 0;
|
||||
|
||||
switch (pattern) {
|
||||
case 'bead_started':
|
||||
return `Started working on ${segment.beadId || 'a task'}`;
|
||||
|
||||
case 'bead_completed':
|
||||
return `Completed ${segment.beadId || 'task'} (${this.formatDuration(segment.durationMs)})`;
|
||||
|
||||
case 'file_editing':
|
||||
if (fileCount === 1) {
|
||||
return `Editing ${entities.files![0]}`;
|
||||
}
|
||||
return `Editing ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
||||
|
||||
case 'file_created':
|
||||
if (fileCount === 1) {
|
||||
return `Created ${entities.files![0]}`;
|
||||
}
|
||||
return `Created ${fileCount} new file${fileCount !== 1 ? 's' : ''}`;
|
||||
|
||||
case 'testing':
|
||||
return `Running tests (${events.length} event${events.length !== 1 ? 's' : ''})`;
|
||||
|
||||
case 'debugging':
|
||||
const errorCount = entities.errors?.length || events.length;
|
||||
return `Debugging ${errorCount} error${errorCount !== 1 ? 's' : ''}`;
|
||||
|
||||
case 'git_operations':
|
||||
return `Git operations (${events.length} action${events.length !== 1 ? 's' : ''})`;
|
||||
|
||||
case 'dependency_install':
|
||||
return 'Installing dependencies';
|
||||
|
||||
case 'iteration':
|
||||
return `Iterative refinement on ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
||||
|
||||
case 'investigation':
|
||||
return `Investigating codebase (${toolCount} tool${toolCount !== 1 ? 's' : ''} used)`;
|
||||
|
||||
default:
|
||||
return `Working (${events.length} event${events.length !== 1 ? 's' : ''})`;
|
||||
}
|
||||
}
|
||||
|
||||
private generateSummary(segments: NarrativeSegment[], events: LogEvent[], isAggregated = false): string {
|
||||
if (segments.length === 0) {
|
||||
return 'No activity to report.';
|
||||
}
|
||||
|
||||
const beads = new Set(events.filter(e => e.bead).map(e => e.bead!));
|
||||
const files = new Set(events.filter(e => e.path).map(e => e.path!));
|
||||
const errors = events.filter(e => e.level === 'error' || e.error).length;
|
||||
|
||||
const totalDuration = this.formatDuration(
|
||||
events.length > 0 ? events[events.length - 1].ts - events[0].ts : 0
|
||||
);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (isAggregated) {
|
||||
const workers = new Set(events.map(e => e.worker));
|
||||
parts.push(`${workers.size} worker${workers.size !== 1 ? 's' : ''} active over ${totalDuration}`);
|
||||
} else {
|
||||
parts.push(`Active for ${totalDuration}`);
|
||||
}
|
||||
|
||||
if (beads.size > 0) {
|
||||
parts.push(`worked on ${beads.size} bead${beads.size !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
if (files.size > 0) {
|
||||
parts.push(`modified ${files.size} file${files.size !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
parts.push(`encountered ${errors} error${errors !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
const mainActivities = this.getTopPatterns(segments, 3);
|
||||
if (mainActivities.length > 0) {
|
||||
parts.push(`primarily ${mainActivities.map(p => this.patternToVerb(p)).join(', ')}`);
|
||||
}
|
||||
|
||||
return parts.join(', ') + '.';
|
||||
}
|
||||
|
||||
private generateFullNarrative(segments: NarrativeSegment[], style: NarrativeStyle = 'detailed', isAggregated = false): string {
|
||||
if (segments.length === 0) {
|
||||
return 'No activity recorded.';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
const connector = i === 0 ? 'Started by' : this.getConnector(segment, segments[i - 1]);
|
||||
|
||||
lines.push(`${connector} ${segment.summary.toLowerCase()}.`);
|
||||
|
||||
if (style === 'detailed' && segment.details) {
|
||||
lines.push(` ${segment.details}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
private generateTimeline(segments: NarrativeSegment[]): string[] {
|
||||
return segments.map(segment => {
|
||||
const time = new Date(segment.startTime).toISOString().split('T')[1].split('.')[0];
|
||||
return `[${time}] ${segment.summary}`;
|
||||
});
|
||||
}
|
||||
|
||||
private generateTitle(workerId: string, segments: NarrativeSegment[]): string {
|
||||
if (segments.length === 0) {
|
||||
return `${workerId}: Idle`;
|
||||
}
|
||||
|
||||
const topPattern = this.getTopPatterns(segments, 1)[0];
|
||||
const verb = topPattern ? this.patternToVerb(topPattern) : 'working';
|
||||
|
||||
return `${workerId}: ${verb.charAt(0).toUpperCase() + verb.slice(1)}`;
|
||||
}
|
||||
|
||||
private extractAccomplishments(segments: NarrativeSegment[]): string[] {
|
||||
const accomplishments: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.pattern === 'bead_completed') {
|
||||
accomplishments.push(`Completed ${segment.beadId || 'task'}`);
|
||||
} else if (segment.pattern === 'file_created' && segment.entities.files) {
|
||||
accomplishments.push(`Created ${segment.entities.files.length} file${segment.entities.files.length !== 1 ? 's' : ''}`);
|
||||
} else if (segment.pattern === 'git_operations') {
|
||||
accomplishments.push('Committed changes to Git');
|
||||
}
|
||||
}
|
||||
|
||||
return accomplishments.slice(0, 5);
|
||||
}
|
||||
|
||||
private extractChallenges(segments: NarrativeSegment[]): string[] {
|
||||
const challenges: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.pattern === 'debugging' && segment.entities.errors && segment.entities.errors.length > 0) {
|
||||
challenges.push(`Debugged ${segment.entities.errors.length} error${segment.entities.errors.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
return challenges.slice(0, 5);
|
||||
}
|
||||
|
||||
private determineSentiment(segments: NarrativeSegment[], events: LogEvent[]): 'productive' | 'struggling' | 'mixed' | 'idle' {
|
||||
if (segments.length === 0) return 'idle';
|
||||
|
||||
const completions = segments.filter(s => s.pattern === 'bead_completed').length;
|
||||
const errors = segments.filter(s => s.pattern === 'debugging').length;
|
||||
const totalTime = segments.reduce((sum, s) => sum + s.durationMs, 0);
|
||||
|
||||
if (completions > 0 && errors === 0) return 'productive';
|
||||
if (errors > completions * 2) return 'struggling';
|
||||
if (completions > 0 || totalTime > 300000) return 'productive'; // > 5 minutes active
|
||||
if (errors > 0) return 'mixed';
|
||||
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
private getTopPatterns(segments: NarrativeSegment[], count: number): EventPattern[] {
|
||||
const patternCounts = new Map<EventPattern, number>();
|
||||
|
||||
for (const segment of segments) {
|
||||
patternCounts.set(segment.pattern, (patternCounts.get(segment.pattern) || 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(patternCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, count)
|
||||
.map(([pattern]) => pattern);
|
||||
}
|
||||
|
||||
private patternToVerb(pattern: EventPattern): string {
|
||||
const verbs: Record<EventPattern, string> = {
|
||||
bead_started: 'starting tasks',
|
||||
bead_completed: 'completing tasks',
|
||||
file_editing: 'editing files',
|
||||
file_created: 'creating files',
|
||||
testing: 'running tests',
|
||||
debugging: 'debugging',
|
||||
git_operations: 'using git',
|
||||
dependency_install: 'installing dependencies',
|
||||
iteration: 'iterating',
|
||||
investigation: 'investigating',
|
||||
collision_detected: 'resolving conflicts',
|
||||
error_recovery: 'recovering from errors',
|
||||
};
|
||||
|
||||
return verbs[pattern] || 'working';
|
||||
}
|
||||
|
||||
private getConnector(current: NarrativeSegment, previous: NarrativeSegment): string {
|
||||
const timeDiff = current.startTime - previous.endTime;
|
||||
|
||||
if (timeDiff > 300000) { // 5 minutes
|
||||
return 'After a pause,';
|
||||
}
|
||||
|
||||
if (current.pattern === previous.pattern) {
|
||||
return 'Continued';
|
||||
}
|
||||
|
||||
if (current.pattern === 'debugging' && previous.pattern === 'testing') {
|
||||
return 'Tests revealed issues, then';
|
||||
}
|
||||
|
||||
if (current.pattern === 'testing' && previous.pattern === 'file_editing') {
|
||||
return 'After edits,';
|
||||
}
|
||||
|
||||
if (current.pattern === 'git_operations' && previous.pattern === 'testing') {
|
||||
return 'Tests passed, then';
|
||||
}
|
||||
|
||||
return 'Then';
|
||||
}
|
||||
|
||||
private emitUpdate(update: NarrativeUpdate): void {
|
||||
this.globalUpdateCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(update);
|
||||
} catch (error) {
|
||||
console.error('Error in narrative update callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: SemanticNarrativeGenerator | null = null;
|
||||
|
||||
export function getSemanticNarrativeManager(): SemanticNarrativeGenerator {
|
||||
if (!instance) {
|
||||
instance = new SemanticNarrativeGenerator();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
62
src/store.ts
62
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
962
src/tui/components/SessionDigest.ts
Normal file
962
src/tui/components/SessionDigest.ts
Normal file
|
|
@ -0,0 +1,962 @@
|
|||
/**
|
||||
* SessionDigest Component
|
||||
*
|
||||
* Displays a summary digest of worker session activity including:
|
||||
* - Summary statistics
|
||||
* - List of completed beads/work
|
||||
* - Notable events (errors, warnings)
|
||||
* - Export functionality
|
||||
*/
|
||||
|
||||
import * as blessed from 'blessed';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
SessionDigest,
|
||||
BeadCompletion,
|
||||
FileModificationSummary,
|
||||
ErrorOccurrence,
|
||||
WorkerSessionSummary,
|
||||
LogEvent,
|
||||
ErrorCategory,
|
||||
} from '../../types.js';
|
||||
import { colors, getLevelColor } from '../utils/colors.js';
|
||||
|
||||
export interface SessionDigestOptions {
|
||||
/** Parent screen */
|
||||
parent: blessed.Widgets.Screen;
|
||||
|
||||
/** Position options */
|
||||
top: number | string;
|
||||
left: number | string;
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
|
||||
/** Callback when digest is exported */
|
||||
onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
|
||||
}
|
||||
|
||||
export type DigestViewTab = 'summary' | 'beads' | 'files' | 'errors' | 'workers';
|
||||
|
||||
export class SessionDigest {
|
||||
private container: blessed.Widgets.BoxElement;
|
||||
private contentBox: blessed.Widgets.BoxElement;
|
||||
private tabBar: blessed.Widgets.BoxElement;
|
||||
private headerBox: blessed.Widgets.BoxElement;
|
||||
private footerBox: blessed.Widgets.BoxElement;
|
||||
private digest: SessionDigest | null = null;
|
||||
private currentTab: DigestViewTab = 'summary';
|
||||
private scrollOffset = 0;
|
||||
private onExport?: (format: 'json' | 'markdown' | 'text', path: string) => void;
|
||||
|
||||
constructor(options: SessionDigestOptions) {
|
||||
this.onExport = options.onExport;
|
||||
|
||||
// Main container
|
||||
this.container = blessed.box({
|
||||
parent: options.parent,
|
||||
top: options.top,
|
||||
left: options.left,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
label: ' Session Digest ',
|
||||
border: { type: 'line' },
|
||||
style: {
|
||||
border: { fg: colors.border },
|
||||
label: { fg: colors.header },
|
||||
},
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
// Header with session info
|
||||
this.headerBox = blessed.box({
|
||||
parent: this.container,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
content: '{gray-fg}No session data loaded{/}',
|
||||
tags: true,
|
||||
});
|
||||
|
||||
// Tab bar
|
||||
this.tabBar = blessed.box({
|
||||
parent: this.container,
|
||||
top: 2,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 1,
|
||||
content: this.getTabBarContent(),
|
||||
tags: true,
|
||||
style: {
|
||||
fg: colors.muted,
|
||||
},
|
||||
});
|
||||
|
||||
// Content area
|
||||
this.contentBox = blessed.box({
|
||||
parent: this.container,
|
||||
top: 3,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
tags: true,
|
||||
style: {
|
||||
fg: colors.text,
|
||||
},
|
||||
});
|
||||
|
||||
// Footer with controls
|
||||
this.footerBox = blessed.box({
|
||||
parent: this.container,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 1,
|
||||
content: ' [1-5] Tabs [e] Export JSON [m] Export Markdown [j/k] Scroll [Esc] Close',
|
||||
style: {
|
||||
fg: colors.muted,
|
||||
},
|
||||
});
|
||||
|
||||
// Bind keyboard events
|
||||
this.bindKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind keyboard shortcuts
|
||||
*/
|
||||
private bindKeys(): void {
|
||||
this.contentBox.key(['1'], () => this.switchTab('summary'));
|
||||
this.contentBox.key(['2'], () => this.switchTab('beads'));
|
||||
this.contentBox.key(['3'], () => this.switchTab('files'));
|
||||
this.contentBox.key(['4'], () => this.switchTab('errors'));
|
||||
this.contentBox.key(['5'], () => this.switchTab('workers'));
|
||||
this.contentBox.key(['e'], () => this.exportDigest('json'));
|
||||
this.contentBox.key(['m'], () => this.exportDigest('markdown'));
|
||||
this.contentBox.key(['t'], () => this.exportDigest('text'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tab bar content with current tab highlighted
|
||||
*/
|
||||
private getTabBarContent(): string {
|
||||
const tabs: Array<{ key: string; label: string; tab: DigestViewTab }> = [
|
||||
{ key: '1', label: 'Summary', tab: 'summary' },
|
||||
{ key: '2', label: 'Beads', tab: 'beads' },
|
||||
{ key: '3', label: 'Files', tab: 'files' },
|
||||
{ key: '4', label: 'Errors', tab: 'errors' },
|
||||
{ key: '5', label: 'Workers', tab: 'workers' },
|
||||
];
|
||||
|
||||
return tabs
|
||||
.map((t) => {
|
||||
const isActive = t.tab === this.currentTab;
|
||||
const color = isActive ? 'cyan' : 'gray';
|
||||
const prefix = isActive ? '[' : ' ';
|
||||
const suffix = isActive ? ']' : ' ';
|
||||
return `{${color}-fg}${prefix}${t.key}:${t.label}${suffix}{/}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different tab
|
||||
*/
|
||||
switchTab(tab: DigestViewTab): void {
|
||||
this.currentTab = tab;
|
||||
this.scrollOffset = 0;
|
||||
this.tabBar.setContent(this.getTabBarContent());
|
||||
this.render();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session digest data
|
||||
*/
|
||||
setDigest(digest: SessionDigest): void {
|
||||
this.digest = digest;
|
||||
this.scrollOffset = 0;
|
||||
this.updateHeader();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the header with session info
|
||||
*/
|
||||
private updateHeader(): void {
|
||||
if (!this.digest) {
|
||||
this.headerBox.setContent('{gray-fg}No session data loaded{/}');
|
||||
return;
|
||||
}
|
||||
|
||||
const d = this.digest;
|
||||
const duration = this.formatDuration(d.durationMs);
|
||||
const startTime = new Date(d.startTime).toLocaleString();
|
||||
const endTime = new Date(d.endTime).toLocaleString();
|
||||
|
||||
const header = `{bold}Session:{/} ${d.sessionId.slice(0, 16)}... ` +
|
||||
`{bold}Duration:{/} ${duration} ` +
|
||||
`{bold}Events:{/} ${d.stats.totalEvents} ` +
|
||||
`{bold}Workers:{/} ${d.stats.totalWorkers}`;
|
||||
|
||||
this.headerBox.setContent(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current tab content
|
||||
*/
|
||||
render(): void {
|
||||
if (!this.digest) {
|
||||
this.contentBox.setContent('{gray-fg}No session data loaded{/}');
|
||||
this.container.screen.render();
|
||||
return;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
|
||||
switch (this.currentTab) {
|
||||
case 'summary':
|
||||
content = this.renderSummary();
|
||||
break;
|
||||
case 'beads':
|
||||
content = this.renderBeads();
|
||||
break;
|
||||
case 'files':
|
||||
content = this.renderFiles();
|
||||
break;
|
||||
case 'errors':
|
||||
content = this.renderErrors();
|
||||
break;
|
||||
case 'workers':
|
||||
content = this.renderWorkers();
|
||||
break;
|
||||
}
|
||||
|
||||
this.contentBox.setContent(content);
|
||||
this.contentBox.setScrollPerc(0);
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render summary tab
|
||||
*/
|
||||
private renderSummary(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} SESSION SUMMARY{/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
// Session info
|
||||
lines.push('{bold}Session ID:{/} ' + d.sessionId);
|
||||
lines.push('{bold}Start Time:{/} ' + new Date(d.startTime).toLocaleString());
|
||||
lines.push('{bold}End Time:{/} ' + new Date(d.endTime).toLocaleString());
|
||||
lines.push('{bold}Duration:{/} ' + this.formatDuration(d.durationMs));
|
||||
lines.push('');
|
||||
|
||||
// Statistics
|
||||
lines.push('{bold}{green-fg}─── Statistics ───{/}');
|
||||
lines.push(` {bold}Total Events:{/} ${d.stats.totalEvents}`);
|
||||
lines.push(` {bold}Total Workers:{/} ${d.stats.totalWorkers}`);
|
||||
lines.push(` {bold}Total Beads:{/} ${d.stats.totalBeads}`);
|
||||
lines.push(` {bold}Total Files:{/} ${d.stats.totalFiles}`);
|
||||
lines.push(` {bold}Total Errors:{/} {red-fg}${d.stats.totalErrors}{/}`);
|
||||
lines.push(` {bold}Avg Events/Worker:{/} ${d.stats.avgEventsPerWorker.toFixed(1)}`);
|
||||
lines.push(` {bold}Avg Beads/Worker:{/} ${d.stats.avgBeadsPerWorker.toFixed(1)}`);
|
||||
lines.push('');
|
||||
|
||||
// Cost breakdown
|
||||
if (d.cost) {
|
||||
lines.push('{bold}{yellow-fg}─── Cost Breakdown ───{/}');
|
||||
lines.push(` {bold}Input Tokens:{/} ${d.cost.inputTokens.toLocaleString()}`);
|
||||
lines.push(` {bold}Output Tokens:{/} ${d.cost.outputTokens.toLocaleString()}`);
|
||||
lines.push(` {bold}Total Tokens:{/} ${d.cost.totalTokens.toLocaleString()}`);
|
||||
lines.push(` {bold}Est. Cost:{/} {green-fg}$${d.cost.estimatedCostUsd.toFixed(4)}{/}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Quick stats
|
||||
lines.push('{bold}{magenta-fg}─── Completed Work ───{/}');
|
||||
lines.push(` {bold}Beads Completed:{/} {green-fg}${d.beadsCompleted.length}{/}`);
|
||||
lines.push(` {bold}Files Modified:{/} {cyan-fg}${d.filesModified.length}{/}`);
|
||||
lines.push(` {bold}Workers Active:{/} ${d.workers.length}`);
|
||||
lines.push('');
|
||||
|
||||
// Error summary
|
||||
if (d.errors.length > 0) {
|
||||
lines.push('{bold}{red-fg}─── Errors ({/}' + d.errors.length + '{bold}{red-fg}) ───{/}');
|
||||
|
||||
// Group errors by category
|
||||
const errorsByCategory: Record<ErrorCategory, number> = {
|
||||
network: 0,
|
||||
permission: 0,
|
||||
validation: 0,
|
||||
resource: 0,
|
||||
not_found: 0,
|
||||
timeout: 0,
|
||||
syntax: 0,
|
||||
tool: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
for (const err of d.errors) {
|
||||
errorsByCategory[err.category]++;
|
||||
}
|
||||
|
||||
for (const [category, count] of Object.entries(errorsByCategory)) {
|
||||
if (count > 0) {
|
||||
lines.push(` {red-fg}${category}:{/} ${count}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render beads tab
|
||||
*/
|
||||
private renderBeads(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} COMPLETED BEADS ({/}' + d.beadsCompleted.length + '{bold}{cyan-fg}){/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
if (d.beadsCompleted.length === 0) {
|
||||
lines.push('{gray-fg}No beads completed in this session{/}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Sort by completion time (most recent first)
|
||||
const sorted = [...d.beadsCompleted].sort((a, b) => b.completedAt - a.completedAt);
|
||||
|
||||
for (const bead of sorted) {
|
||||
const time = new Date(bead.completedAt).toLocaleTimeString();
|
||||
const duration = bead.durationMs ? ` (${this.formatDuration(bead.durationMs)})` : '';
|
||||
const worker = bead.workerId.slice(0, 8);
|
||||
|
||||
lines.push(`{magenta-fg}${bead.beadId}{/} {gray-fg}by{/} {cyan-fg}${worker}{/} {gray-fg}at{/} ${time}${duration}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render files tab
|
||||
*/
|
||||
private renderFiles(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} FILES MODIFIED ({/}' + d.filesModified.length + '{bold}{cyan-fg}){/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
if (d.filesModified.length === 0) {
|
||||
lines.push('{gray-fg}No files modified in this session{/}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Sort by modification count (most modified first)
|
||||
const sorted = [...d.filesModified].sort((a, b) => b.modifications - a.modifications);
|
||||
|
||||
for (const file of sorted) {
|
||||
const mods = file.modifications;
|
||||
const modStr = mods === 1 ? '1 mod' : `${mods} mods`;
|
||||
const workers = file.workers.length === 1 ? '1 worker' : `${file.workers.length} workers`;
|
||||
|
||||
// Color based on modification count
|
||||
let color = 'green';
|
||||
if (mods >= 10) color = 'red';
|
||||
else if (mods >= 5) color = 'yellow';
|
||||
else if (mods >= 3) color = 'cyan';
|
||||
|
||||
lines.push(`{${color}-fg}${modStr}{/} {gray-fg}by{/} ${workers}`);
|
||||
lines.push(` {white-fg}${file.path}{/}`);
|
||||
|
||||
if (file.tools.length > 0) {
|
||||
lines.push(` {gray-fg}Tools: ${file.tools.join(', ')}{/}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render errors tab
|
||||
*/
|
||||
private renderErrors(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} ERRORS ({/}' + d.errors.length + '{bold}{cyan-fg}){/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
if (d.errors.length === 0) {
|
||||
lines.push('{green-fg}✓ No errors encountered in this session{/}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Sort by timestamp (most recent first)
|
||||
const sorted = [...d.errors].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
for (const err of sorted) {
|
||||
const time = new Date(err.timestamp).toLocaleTimeString();
|
||||
const worker = err.workerId.slice(0, 8);
|
||||
const category = err.category.toUpperCase();
|
||||
|
||||
lines.push(`{red-fg}[${category}]{/} {gray-fg}${time}{/} {cyan-fg}${worker}{/}`);
|
||||
lines.push(` {white-fg}${err.message.slice(0, 100)}${err.message.length > 100 ? '...' : ''}{/}`);
|
||||
if (err.fingerprint) {
|
||||
lines.push(` {gray-fg}Fingerprint: ${err.fingerprint}{/}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render workers tab
|
||||
*/
|
||||
private renderWorkers(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} WORKERS ({/}' + d.workers.length + '{bold}{cyan-fg}){/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
if (d.workers.length === 0) {
|
||||
lines.push('{gray-fg}No workers in this session{/}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Sort by beads completed (most productive first)
|
||||
const sorted = [...d.workers].sort((a, b) => b.beadsCompleted - a.beadsCompleted);
|
||||
|
||||
for (const worker of sorted) {
|
||||
const activeTime = this.formatDuration(worker.activeTimeMs);
|
||||
const firstActivity = new Date(worker.firstActivity).toLocaleTimeString();
|
||||
const lastActivity = new Date(worker.lastActivity).toLocaleTimeString();
|
||||
|
||||
lines.push(`{bold}{cyan-fg}${worker.workerId}{/}`);
|
||||
lines.push(` {bold}Beads Completed:{/} {green-fg}${worker.beadsCompleted}{/}`);
|
||||
lines.push(` {bold}Files Modified:{/} ${worker.filesModified}`);
|
||||
lines.push(` {bold}Errors:{/} {red-fg}${worker.errorsEncountered}{/}`);
|
||||
lines.push(` {bold}Total Events:{/} ${worker.totalEvents}`);
|
||||
lines.push(` {bold}Active Time:{/} ${activeTime}`);
|
||||
lines.push(` {bold}First Activity:{/} ${firstActivity}`);
|
||||
lines.push(` {bold}Last Activity:{/} ${lastActivity}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration for display
|
||||
*/
|
||||
private formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600000) {
|
||||
const mins = Math.floor(ms / 60000);
|
||||
const secs = Math.floor((ms % 60000) / 1000);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
const mins = Math.floor((ms % 3600000) / 60000);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export digest to file
|
||||
*/
|
||||
exportDigest(format: 'json' | 'markdown' | 'text'): void {
|
||||
if (!this.digest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const baseName = `session-digest-${timestamp}`;
|
||||
let filePath: string;
|
||||
let content: string;
|
||||
|
||||
switch (format) {
|
||||
case 'json':
|
||||
filePath = `${baseName}.json`;
|
||||
content = JSON.stringify(this.digest, null, 2);
|
||||
break;
|
||||
case 'markdown':
|
||||
filePath = `${baseName}.md`;
|
||||
content = this.formatAsMarkdown();
|
||||
break;
|
||||
case 'text':
|
||||
filePath = `${baseName}.txt`;
|
||||
content = this.formatAsText();
|
||||
break;
|
||||
}
|
||||
|
||||
// Write to current directory or temp
|
||||
const outputPath = path.join(process.cwd(), filePath);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(outputPath, content, 'utf-8');
|
||||
|
||||
// Show success message
|
||||
const successMsg = `{green-fg}✓ Exported to ${outputPath}{/}`;
|
||||
this.footerBox.setContent(successMsg);
|
||||
this.container.screen.render();
|
||||
|
||||
// Reset footer after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.footerBox.setContent(' [1-5] Tabs [e] Export JSON [m] Export Markdown [j/k] Scroll [Esc] Close');
|
||||
this.container.screen.render();
|
||||
}, 3000);
|
||||
|
||||
if (this.onExport) {
|
||||
this.onExport(format, outputPath);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `{red-fg}✗ Export failed: ${error}{/}`;
|
||||
this.footerBox.setContent(errorMsg);
|
||||
this.container.screen.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format digest as markdown
|
||||
*/
|
||||
private formatAsMarkdown(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# Session Digest');
|
||||
lines.push('');
|
||||
lines.push(`**Session ID:** ${d.sessionId}`);
|
||||
lines.push(`**Start Time:** ${new Date(d.startTime).toLocaleString()}`);
|
||||
lines.push(`**End Time:** ${new Date(d.endTime).toLocaleString()}`);
|
||||
lines.push(`**Duration:** ${this.formatDuration(d.durationMs)}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Statistics');
|
||||
lines.push('');
|
||||
lines.push('| Metric | Value |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(`| Total Events | ${d.stats.totalEvents} |`);
|
||||
lines.push(`| Total Workers | ${d.stats.totalWorkers} |`);
|
||||
lines.push(`| Total Beads | ${d.stats.totalBeads} |`);
|
||||
lines.push(`| Total Files | ${d.stats.totalFiles} |`);
|
||||
lines.push(`| Total Errors | ${d.stats.totalErrors} |`);
|
||||
lines.push('');
|
||||
|
||||
if (d.cost) {
|
||||
lines.push('## Cost Breakdown');
|
||||
lines.push('');
|
||||
lines.push(`- **Input Tokens:** ${d.cost.inputTokens.toLocaleString()}`);
|
||||
lines.push(`- **Output Tokens:** ${d.cost.outputTokens.toLocaleString()}`);
|
||||
lines.push(`- **Total Tokens:** ${d.cost.totalTokens.toLocaleString()}`);
|
||||
lines.push(`- **Estimated Cost:** $${d.cost.estimatedCostUsd.toFixed(4)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('## Completed Beads');
|
||||
lines.push('');
|
||||
if (d.beadsCompleted.length === 0) {
|
||||
lines.push('_No beads completed_');
|
||||
} else {
|
||||
lines.push('| Bead ID | Worker | Completed At | Duration |');
|
||||
lines.push('|---------|--------|--------------|----------|');
|
||||
for (const bead of d.beadsCompleted) {
|
||||
const time = new Date(bead.completedAt).toLocaleString();
|
||||
const duration = bead.durationMs ? this.formatDuration(bead.durationMs) : '-';
|
||||
lines.push(`| ${bead.beadId} | ${bead.workerId.slice(0, 8)} | ${time} | ${duration} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Files Modified');
|
||||
lines.push('');
|
||||
if (d.filesModified.length === 0) {
|
||||
lines.push('_No files modified_');
|
||||
} else {
|
||||
lines.push('| Path | Modifications | Workers |');
|
||||
lines.push('|------|---------------|---------|');
|
||||
for (const file of d.filesModified) {
|
||||
lines.push(`| \`${file.path}\` | ${file.modifications} | ${file.workers.length} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Errors');
|
||||
lines.push('');
|
||||
if (d.errors.length === 0) {
|
||||
lines.push('_No errors encountered_');
|
||||
} else {
|
||||
lines.push('| Time | Category | Worker | Message |');
|
||||
lines.push('|------|----------|--------|---------|');
|
||||
for (const err of d.errors) {
|
||||
const time = new Date(err.timestamp).toLocaleTimeString();
|
||||
const msg = err.message.slice(0, 50).replace(/\n/g, ' ');
|
||||
lines.push(`| ${time} | ${err.category} | ${err.workerId.slice(0, 8)} | ${msg} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Worker Summary');
|
||||
lines.push('');
|
||||
lines.push('| Worker ID | Beads | Files | Errors | Active Time |');
|
||||
lines.push('|-----------|-------|-------|--------|-------------|');
|
||||
for (const worker of d.workers) {
|
||||
lines.push(`| ${worker.workerId.slice(0, 8)} | ${worker.beadsCompleted} | ${worker.filesModified} | ${worker.errorsEncountered} | ${this.formatDuration(worker.activeTimeMs)} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format digest as plain text
|
||||
*/
|
||||
private formatAsText(): string {
|
||||
if (!this.digest) return '';
|
||||
|
||||
const d = this.digest;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('SESSION DIGEST');
|
||||
lines.push('='.repeat(50));
|
||||
lines.push('');
|
||||
lines.push(`Session ID: ${d.sessionId}`);
|
||||
lines.push(`Start Time: ${new Date(d.startTime).toLocaleString()}`);
|
||||
lines.push(`End Time: ${new Date(d.endTime).toLocaleString()}`);
|
||||
lines.push(`Duration: ${this.formatDuration(d.durationMs)}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('STATISTICS');
|
||||
lines.push('-'.repeat(30));
|
||||
lines.push(`Total Events: ${d.stats.totalEvents}`);
|
||||
lines.push(`Total Workers: ${d.stats.totalWorkers}`);
|
||||
lines.push(`Total Beads: ${d.stats.totalBeads}`);
|
||||
lines.push(`Total Files: ${d.stats.totalFiles}`);
|
||||
lines.push(`Total Errors: ${d.stats.totalErrors}`);
|
||||
lines.push('');
|
||||
|
||||
if (d.cost) {
|
||||
lines.push('COST BREAKDOWN');
|
||||
lines.push('-'.repeat(30));
|
||||
lines.push(`Input Tokens: ${d.cost.inputTokens.toLocaleString()}`);
|
||||
lines.push(`Output Tokens: ${d.cost.outputTokens.toLocaleString()}`);
|
||||
lines.push(`Total Tokens: ${d.cost.totalTokens.toLocaleString()}`);
|
||||
lines.push(`Estimated Cost: $${d.cost.estimatedCostUsd.toFixed(4)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('COMPLETED BEADS');
|
||||
lines.push('-'.repeat(30));
|
||||
for (const bead of d.beadsCompleted) {
|
||||
const time = new Date(bead.completedAt).toLocaleString();
|
||||
const duration = bead.durationMs ? ` (${this.formatDuration(bead.durationMs)})` : '';
|
||||
lines.push(`${bead.beadId} by ${bead.workerId.slice(0, 8)} at ${time}${duration}`);
|
||||
}
|
||||
if (d.beadsCompleted.length === 0) {
|
||||
lines.push('No beads completed');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('FILES MODIFIED');
|
||||
lines.push('-'.repeat(30));
|
||||
for (const file of d.filesModified) {
|
||||
lines.push(`${file.path} (${file.modifications} mods by ${file.workers.length} workers)`);
|
||||
}
|
||||
if (d.filesModified.length === 0) {
|
||||
lines.push('No files modified');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('ERRORS');
|
||||
lines.push('-'.repeat(30));
|
||||
for (const err of d.errors) {
|
||||
const time = new Date(err.timestamp).toLocaleTimeString();
|
||||
lines.push(`[${err.category.toUpperCase()}] ${time} ${err.workerId.slice(0, 8)}: ${err.message.slice(0, 100)}`);
|
||||
}
|
||||
if (d.errors.length === 0) {
|
||||
lines.push('No errors encountered');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`Generated by FABRIC at ${new Date().toLocaleString()}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the digest panel
|
||||
*/
|
||||
show(): void {
|
||||
this.container.show();
|
||||
this.contentBox.focus();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the digest panel
|
||||
*/
|
||||
hide(): void {
|
||||
this.container.hide();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility
|
||||
*/
|
||||
toggle(): void {
|
||||
if (this.container.hidden) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if visible
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return !this.container.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus this component
|
||||
*/
|
||||
focus(): void {
|
||||
this.contentBox.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying blessed element
|
||||
*/
|
||||
getElement(): blessed.Widgets.BoxElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current tab
|
||||
*/
|
||||
getCurrentTab(): DigestViewTab {
|
||||
return this.currentTab;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session digest from events and worker data
|
||||
*/
|
||||
export function generateSessionDigest(
|
||||
events: LogEvent[],
|
||||
workers: WorkerSessionSummary[],
|
||||
options: {
|
||||
sessionId?: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
includeCost?: boolean;
|
||||
} = {}
|
||||
): SessionDigest {
|
||||
const startTime = options.startTime || (events.length > 0 ? events[0].ts : Date.now());
|
||||
const endTime = options.endTime || (events.length > 0 ? events[events.length - 1].ts : Date.now());
|
||||
const sessionId = options.sessionId || `session-${Date.now()}`;
|
||||
|
||||
// Extract bead completions
|
||||
const beadsCompleted: BeadCompletion[] = [];
|
||||
const completedEvents = events.filter(e =>
|
||||
e.msg.toLowerCase().includes('completed') ||
|
||||
e.msg.toLowerCase().includes('complete')
|
||||
);
|
||||
|
||||
for (const event of completedEvents) {
|
||||
if (event.bead) {
|
||||
beadsCompleted.push({
|
||||
beadId: event.bead,
|
||||
workerId: event.worker,
|
||||
completedAt: event.ts,
|
||||
durationMs: event.duration_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract file modifications
|
||||
const fileModMap = new Map<string, {
|
||||
modifications: number;
|
||||
workers: Set<string>;
|
||||
tools: Set<string>;
|
||||
}>();
|
||||
|
||||
const fileEvents = events.filter(e => e.path && e.tool);
|
||||
for (const event of fileEvents) {
|
||||
const existing = fileModMap.get(event.path!);
|
||||
if (existing) {
|
||||
existing.modifications++;
|
||||
existing.workers.add(event.worker);
|
||||
if (event.tool) existing.tools.add(event.tool);
|
||||
} else {
|
||||
fileModMap.set(event.path!, {
|
||||
modifications: 1,
|
||||
workers: new Set([event.worker]),
|
||||
tools: new Set(event.tool ? [event.tool] : []),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filesModified: FileModificationSummary[] = [];
|
||||
for (const [path, data] of fileModMap) {
|
||||
filesModified.push({
|
||||
path,
|
||||
modifications: data.modifications,
|
||||
workers: Array.from(data.workers),
|
||||
tools: Array.from(data.tools),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract errors
|
||||
const errors: ErrorOccurrence[] = events
|
||||
.filter(e => e.level === 'error')
|
||||
.map(e => ({
|
||||
message: e.error || e.msg,
|
||||
category: categorizeError(e.error || e.msg) as ErrorCategory,
|
||||
workerId: e.worker,
|
||||
timestamp: e.ts,
|
||||
fingerprint: e.error ? generateFingerprint(e.error) : undefined,
|
||||
}));
|
||||
|
||||
// Calculate totals
|
||||
const totalEvents = events.length;
|
||||
const totalWorkers = workers.length;
|
||||
const totalBeads = beadsCompleted.length;
|
||||
const totalFiles = filesModified.length;
|
||||
const totalErrors = errors.length;
|
||||
|
||||
// Calculate cost (placeholder - would need actual token tracking)
|
||||
const cost = {
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
estimatedCostUsd: 0,
|
||||
};
|
||||
|
||||
// If we have token info in events, aggregate it
|
||||
for (const event of events) {
|
||||
const tokens = (event as any).tokens;
|
||||
if (tokens) {
|
||||
cost.totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMs: endTime - startTime,
|
||||
beadsCompleted,
|
||||
filesModified,
|
||||
errors,
|
||||
workers,
|
||||
cost,
|
||||
stats: {
|
||||
totalEvents,
|
||||
totalWorkers,
|
||||
totalBeads,
|
||||
totalFiles,
|
||||
totalErrors,
|
||||
avgEventsPerWorker: totalWorkers > 0 ? totalEvents / totalWorkers : 0,
|
||||
avgBeadsPerWorker: totalWorkers > 0 ? totalBeads / totalWorkers : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize an error message
|
||||
*/
|
||||
function categorizeError(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (lower.includes('econnrefused') || lower.includes('enotfound') ||
|
||||
lower.includes('network') || lower.includes('dns') ||
|
||||
lower.includes('socket') || lower.includes('connection')) {
|
||||
return 'network';
|
||||
}
|
||||
if (lower.includes('permission') || lower.includes('access denied') ||
|
||||
lower.includes('unauthorized') || lower.includes('forbidden') ||
|
||||
lower.includes('auth')) {
|
||||
return 'permission';
|
||||
}
|
||||
if (lower.includes('validation') || lower.includes('invalid') ||
|
||||
lower.includes('schema') || lower.includes('type error')) {
|
||||
return 'validation';
|
||||
}
|
||||
if (lower.includes('out of memory') || lower.includes('disk full') ||
|
||||
lower.includes('quota') || lower.includes('resource')) {
|
||||
return 'resource';
|
||||
}
|
||||
if (lower.includes('not found') || lower.includes('enoent') ||
|
||||
lower.includes('404')) {
|
||||
return 'not_found';
|
||||
}
|
||||
if (lower.includes('timeout') || lower.includes('timed out')) {
|
||||
return 'timeout';
|
||||
}
|
||||
if (lower.includes('syntax') || lower.includes('parse') ||
|
||||
lower.includes('unexpected token')) {
|
||||
return 'syntax';
|
||||
}
|
||||
if (lower.includes('tool') || lower.includes('command failed')) {
|
||||
return 'tool';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fingerprint for error grouping
|
||||
*/
|
||||
function generateFingerprint(message: string): string {
|
||||
// Simple fingerprint based on first 50 chars normalized
|
||||
const normalized = message
|
||||
.toLowerCase()
|
||||
.replace(/\d+/g, 'N')
|
||||
.replace(/['"]/g, '')
|
||||
.slice(0, 50);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createSessionDigest(options: SessionDigestOptions): SessionDigest {
|
||||
return new SessionDigest(options);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
222
src/types.ts
222
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue