@reactive-skills/runtime 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,20 @@
1
1
 
2
+ ## [0.4.4] - 2026-09-14
3
+
4
+ - fix(ci): calibrate perf budget for virtualized CI runners & automate GitHub Releases (e2dd853)
5
+ - chore(release): bump version to 0.4.3 (2f89b7d)
6
+ - feat(telemetry): embed standalone live web dashboard at GET / (4ffb3a9)
7
+ - docs: add comprehensive job management documentation across readmes (e1a626a)
8
+ - docs: add practical telemetry inspection and metrics querying guide (ef37fb6)
9
+
10
+ ## [0.4.3] - 2026-09-14
11
+
12
+ - feat(telemetry): embed standalone live web dashboard at GET / (4ffb3a9)
13
+ - docs: add comprehensive job management documentation across readmes (e1a626a)
14
+ - docs: add practical telemetry inspection and metrics querying guide (ef37fb6)
15
+ - chore: release v0.4.2 (28c16b9)
16
+ - feat(runtime): add in-engine telemetry, template caching, and performance budget verification (7dfae37)
17
+
2
18
  ## [0.4.2] - 2026-09-14
3
19
 
4
20
  - feat(runtime): add in-engine telemetry, template caching, and performance budget verification (7dfae37)
package/README.md CHANGED
@@ -69,7 +69,46 @@ Dual-mode event sourcing:
69
69
  1. **JSONL** (`.reactive/skills/<skill>/events.jsonl`) - Human-readable append-only log
70
70
  2. **SQLite** (`.reactive/skills/<skill>/events.db`) - Indexed relational database
71
71
 
72
+ ## Job & Run Management
73
+
74
+ The runtime isolates execution runs through `JobManager`:
75
+
76
+ ```ts
77
+ import { JobManager, FSMEngine } from '@reactive-skills/runtime';
78
+
79
+ // 1. Manage isolated runs:
80
+ const jobManager = new JobManager();
81
+ jobManager.createJob('my-skill', { id: 'sprint-1', name: 'Sprint 1 Run', setActive: true });
82
+
83
+ // 2. Instantiate engine targeted to a specific run:
84
+ const engine = new FSMEngine({
85
+ skillDir: './skills/my-skill',
86
+ jobId: 'sprint-1',
87
+ });
88
+
89
+ // Deliverables automatically mirror to .docs/my-skill/jobs/sprint-1/ and canonical .docs/
90
+ ```
91
+
92
+ ## Performance & Telemetry
93
+
94
+ The runtime captures execution telemetry and token estimates with zero latency penalty:
95
+
96
+ ```ts
97
+ // 1. In-turn prompt slice telemetry:
98
+ const slice = engine.generatePromptSlice();
99
+ console.log(slice.metrics);
100
+ // => { slice_duration_ms: 0.23, slice_tokens_est: 282, allowed_tools_count: 4 }
101
+
102
+ // 2. State transition telemetry:
103
+ const res = await engine.handleSignal('TEST_RAN', { exit_code: 0 });
104
+ console.log(res.metrics);
105
+ // => { transition_duration_ms: 1.05, slice_duration_ms: 0.23, slice_tokens_est: 282 }
106
+ ```
107
+
108
+ See [PERFORMANCE-STANDARDS.md](../../PERFORMANCE-STANDARDS.md) for full latency budgets and caching architecture.
109
+
72
110
  ## License
73
111
 
74
112
  MIT
75
113
 
114
+
package/dist/index.d.ts CHANGED
@@ -13,3 +13,4 @@ export { runSync } from './sync/engine.js';
13
13
  export { syncEngineCommand } from './sync/cli.js';
14
14
  export * from './telemetry/types.js';
15
15
  export * from './telemetry/server.js';
16
+ export * from './telemetry/dashboard.js';
package/dist/index.js CHANGED
@@ -13,3 +13,4 @@ export { runSync } from './sync/engine.js';
13
13
  export { syncEngineCommand } from './sync/cli.js';
14
14
  export * from './telemetry/types.js';
15
15
  export * from './telemetry/server.js';
16
+ export * from './telemetry/dashboard.js';
@@ -0,0 +1,10 @@
1
+ export interface DashboardOptions {
2
+ skillName?: string;
3
+ port: number;
4
+ host: string;
5
+ }
6
+ /**
7
+ * Generates a self-contained, zero-dependency HTML dashboard for the TelemetryServer.
8
+ * Provides live SSE event streaming, state & context inspection, and signal dispatching.
9
+ */
10
+ export declare function renderDashboardHtml(options: DashboardOptions): string;
@@ -0,0 +1,939 @@
1
+ /**
2
+ * Generates a self-contained, zero-dependency HTML dashboard for the TelemetryServer.
3
+ * Provides live SSE event streaming, state & context inspection, and signal dispatching.
4
+ */
5
+ export function renderDashboardHtml(options) {
6
+ const titleSkill = options.skillName || 'Reactive Skill';
7
+ return `<!DOCTYPE html>
8
+ <html lang="en" class="dark">
9
+ <head>
10
+ <meta charset="UTF-8">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
12
+ <title>⚡ ${escapeHtml(titleSkill)} — Reactive Skills Telemetry</title>
13
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚡</text></svg>">
14
+ <style>
15
+ :root {
16
+ --bg: #090d16;
17
+ --card-bg: #111827;
18
+ --card-border: #1f2937;
19
+ --card-hover: #1e293b;
20
+ --text-main: #f3f4f6;
21
+ --text-muted: #9ca3af;
22
+ --text-dim: #6b7280;
23
+ --primary: #38bdf8;
24
+ --primary-hover: #0ea5e9;
25
+ --accent: #818cf8;
26
+ --success: #34d399;
27
+ --warning: #fbbf24;
28
+ --danger: #f87171;
29
+ --transition-bg: #312e81;
30
+ --transition-text: #c7d2fe;
31
+ --guard-bg: #064e3b;
32
+ --guard-text: #a7f3d0;
33
+ --tool-bg: #78350f;
34
+ --tool-text: #fde68a;
35
+ --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
36
+ --sans-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
37
+ }
38
+
39
+ * { box-sizing: border-box; margin: 0; padding: 0; }
40
+ body {
41
+ background-color: var(--bg);
42
+ color: var(--text-main);
43
+ font-family: var(--sans-font);
44
+ line-height: 1.5;
45
+ min-height: 100vh;
46
+ display: flex;
47
+ flex-direction: column;
48
+ }
49
+
50
+ header {
51
+ background-color: var(--card-bg);
52
+ border-bottom: 1px solid var(--card-border);
53
+ padding: 0.85rem 1.5rem;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ flex-wrap: wrap;
58
+ gap: 1rem;
59
+ position: sticky;
60
+ top: 0;
61
+ z-index: 50;
62
+ }
63
+
64
+ .brand {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 0.75rem;
68
+ }
69
+ .brand-logo {
70
+ font-size: 1.5rem;
71
+ line-height: 1;
72
+ }
73
+ .brand-title {
74
+ font-weight: 700;
75
+ font-size: 1.15rem;
76
+ letter-spacing: -0.02em;
77
+ }
78
+ .brand-skill {
79
+ background: linear-gradient(135deg, #0284c7 0%, #6366f1 100%);
80
+ color: #fff;
81
+ font-size: 0.75rem;
82
+ font-weight: 600;
83
+ padding: 0.2rem 0.6rem;
84
+ border-radius: 9999px;
85
+ letter-spacing: 0.05em;
86
+ text-transform: uppercase;
87
+ font-family: var(--mono-font);
88
+ }
89
+
90
+ .header-metrics {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 1rem;
94
+ font-size: 0.85rem;
95
+ }
96
+
97
+ .status-badge {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ gap: 0.4rem;
101
+ padding: 0.25rem 0.65rem;
102
+ border-radius: 9999px;
103
+ font-size: 0.75rem;
104
+ font-weight: 600;
105
+ background-color: #064e3b;
106
+ color: #34d399;
107
+ border: 1px solid #059669;
108
+ }
109
+ .status-badge.connecting {
110
+ background-color: #78350f;
111
+ color: #fbbf24;
112
+ border-color: #d97706;
113
+ }
114
+ .status-badge.disconnected {
115
+ background-color: #4c0519;
116
+ color: #f87171;
117
+ border-color: #e11d48;
118
+ }
119
+ .status-dot {
120
+ width: 0.5rem;
121
+ height: 0.5rem;
122
+ border-radius: 50%;
123
+ background-color: currentColor;
124
+ animation: pulse 2s infinite;
125
+ }
126
+
127
+ @keyframes pulse {
128
+ 0%, 100% { opacity: 1; }
129
+ 50% { opacity: 0.4; }
130
+ }
131
+
132
+ .header-links a {
133
+ color: var(--text-muted);
134
+ text-decoration: none;
135
+ font-size: 0.8rem;
136
+ padding: 0.25rem 0.5rem;
137
+ border-radius: 0.375rem;
138
+ transition: all 0.15s;
139
+ }
140
+ .header-links a:hover {
141
+ color: var(--primary);
142
+ background-color: var(--card-hover);
143
+ }
144
+
145
+ main {
146
+ flex: 1;
147
+ display: grid;
148
+ grid-template-columns: 380px 1fr;
149
+ gap: 1.25rem;
150
+ padding: 1.25rem;
151
+ max-width: 1700px;
152
+ margin: 0 auto;
153
+ width: 100%;
154
+ }
155
+
156
+ @media (max-width: 960px) {
157
+ main {
158
+ grid-template-columns: 1fr;
159
+ }
160
+ }
161
+
162
+ .card {
163
+ background-color: var(--card-bg);
164
+ border: 1px solid var(--card-border);
165
+ border-radius: 0.75rem;
166
+ overflow: hidden;
167
+ display: flex;
168
+ flex-direction: column;
169
+ }
170
+
171
+ .card-header {
172
+ padding: 0.85rem 1.15rem;
173
+ border-bottom: 1px solid var(--card-border);
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: space-between;
177
+ gap: 0.75rem;
178
+ }
179
+ .card-title {
180
+ font-size: 0.9rem;
181
+ font-weight: 700;
182
+ color: var(--text-main);
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 0.5rem;
186
+ text-transform: uppercase;
187
+ letter-spacing: 0.05em;
188
+ }
189
+
190
+ .state-panel {
191
+ padding: 1.15rem;
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: 1rem;
195
+ }
196
+
197
+ .active-state-banner {
198
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(99, 102, 241, 0.1) 100%);
199
+ border: 1px solid rgba(56, 189, 248, 0.3);
200
+ padding: 1rem;
201
+ border-radius: 0.625rem;
202
+ }
203
+ .active-state-label {
204
+ font-size: 0.75rem;
205
+ font-weight: 600;
206
+ color: var(--primary);
207
+ text-transform: uppercase;
208
+ letter-spacing: 0.05em;
209
+ }
210
+ .active-state-name {
211
+ font-size: 1.4rem;
212
+ font-weight: 800;
213
+ font-family: var(--mono-font);
214
+ color: #fff;
215
+ margin-top: 0.25rem;
216
+ word-break: break-all;
217
+ }
218
+
219
+ .meta-grid {
220
+ display: grid;
221
+ grid-template-columns: 1fr 1fr;
222
+ gap: 0.75rem;
223
+ }
224
+ .meta-box {
225
+ background-color: #0b1120;
226
+ border: 1px solid var(--card-border);
227
+ padding: 0.6rem 0.75rem;
228
+ border-radius: 0.5rem;
229
+ }
230
+ .meta-box-label {
231
+ font-size: 0.7rem;
232
+ color: var(--text-dim);
233
+ text-transform: uppercase;
234
+ }
235
+ .meta-box-val {
236
+ font-size: 0.95rem;
237
+ font-weight: 600;
238
+ font-family: var(--mono-font);
239
+ color: var(--text-main);
240
+ margin-top: 0.15rem;
241
+ }
242
+
243
+ .dispatch-box {
244
+ border-top: 1px solid var(--card-border);
245
+ padding: 1.15rem;
246
+ display: flex;
247
+ flex-direction: column;
248
+ gap: 0.75rem;
249
+ }
250
+ .form-group label {
251
+ display: block;
252
+ font-size: 0.75rem;
253
+ font-weight: 600;
254
+ color: var(--text-muted);
255
+ margin-bottom: 0.35rem;
256
+ text-transform: uppercase;
257
+ letter-spacing: 0.04em;
258
+ }
259
+ .input-field {
260
+ width: 100%;
261
+ background-color: #0b1120;
262
+ border: 1px solid var(--card-border);
263
+ color: var(--text-main);
264
+ padding: 0.55rem 0.75rem;
265
+ border-radius: 0.5rem;
266
+ font-family: var(--mono-font);
267
+ font-size: 0.85rem;
268
+ outline: none;
269
+ transition: border-color 0.15s;
270
+ }
271
+ .input-field:focus {
272
+ border-color: var(--primary);
273
+ }
274
+ textarea.input-field {
275
+ min-height: 70px;
276
+ resize: vertical;
277
+ }
278
+
279
+ .btn {
280
+ display: inline-flex;
281
+ align-items: center;
282
+ justify-content: center;
283
+ gap: 0.5rem;
284
+ padding: 0.55rem 1rem;
285
+ font-size: 0.85rem;
286
+ font-weight: 600;
287
+ border-radius: 0.5rem;
288
+ cursor: pointer;
289
+ border: none;
290
+ transition: all 0.15s;
291
+ }
292
+ .btn-primary {
293
+ background-color: #0284c7;
294
+ color: #fff;
295
+ }
296
+ .btn-primary:hover {
297
+ background-color: #0ea5e9;
298
+ }
299
+ .btn-secondary {
300
+ background-color: var(--card-hover);
301
+ color: var(--text-muted);
302
+ border: 1px solid var(--card-border);
303
+ }
304
+ .btn-secondary:hover {
305
+ background-color: #334155;
306
+ color: var(--text-main);
307
+ }
308
+ .btn:disabled {
309
+ opacity: 0.5;
310
+ cursor: not-allowed;
311
+ }
312
+
313
+ .btn-sm {
314
+ padding: 0.25rem 0.5rem;
315
+ font-size: 0.75rem;
316
+ }
317
+
318
+ .dispatch-feedback {
319
+ font-size: 0.8rem;
320
+ padding: 0.5rem 0.75rem;
321
+ border-radius: 0.375rem;
322
+ display: none;
323
+ font-family: var(--mono-font);
324
+ }
325
+ .dispatch-feedback.success {
326
+ display: block;
327
+ background-color: rgba(16, 185, 129, 0.15);
328
+ border: 1px solid rgba(16, 185, 129, 0.3);
329
+ color: #6ee7b7;
330
+ }
331
+ .dispatch-feedback.error {
332
+ display: block;
333
+ background-color: rgba(239, 68, 68, 0.15);
334
+ border: 1px solid rgba(239, 68, 68, 0.3);
335
+ color: #fca5a5;
336
+ }
337
+
338
+ .context-accordion {
339
+ border-top: 1px solid var(--card-border);
340
+ padding: 1.15rem;
341
+ }
342
+ .context-accordion summary {
343
+ cursor: pointer;
344
+ font-size: 0.8rem;
345
+ font-weight: 600;
346
+ color: var(--text-muted);
347
+ user-select: none;
348
+ outline: none;
349
+ }
350
+ .context-accordion summary:hover {
351
+ color: var(--primary);
352
+ }
353
+ .context-json {
354
+ background-color: #0b1120;
355
+ border: 1px solid var(--card-border);
356
+ padding: 0.75rem;
357
+ border-radius: 0.5rem;
358
+ font-family: var(--mono-font);
359
+ font-size: 0.75rem;
360
+ color: #cbd5e1;
361
+ overflow-x: auto;
362
+ max-height: 250px;
363
+ margin-top: 0.6rem;
364
+ }
365
+
366
+ /* Event Ledger View */
367
+ .ledger-container {
368
+ display: flex;
369
+ flex-direction: column;
370
+ height: 100%;
371
+ min-height: 600px;
372
+ }
373
+
374
+ .ledger-controls {
375
+ display: flex;
376
+ align-items: center;
377
+ gap: 0.5rem;
378
+ flex-wrap: wrap;
379
+ }
380
+
381
+ .filter-select {
382
+ background-color: #0b1120;
383
+ border: 1px solid var(--card-border);
384
+ color: var(--text-muted);
385
+ font-size: 0.75rem;
386
+ padding: 0.25rem 0.5rem;
387
+ border-radius: 0.375rem;
388
+ outline: none;
389
+ }
390
+
391
+ .events-scroll {
392
+ flex: 1;
393
+ overflow-y: auto;
394
+ padding: 0.5rem;
395
+ display: flex;
396
+ flex-direction: column;
397
+ gap: 0.4rem;
398
+ background-color: #0b1120;
399
+ max-height: calc(100vh - 160px);
400
+ }
401
+
402
+ .event-item {
403
+ background-color: #111827;
404
+ border: 1px solid #1f2937;
405
+ border-radius: 0.5rem;
406
+ padding: 0.6rem 0.85rem;
407
+ font-size: 0.8rem;
408
+ transition: background-color 0.15s, border-color 0.15s;
409
+ cursor: pointer;
410
+ }
411
+ .event-item:hover {
412
+ background-color: #1e293b;
413
+ border-color: #334155;
414
+ }
415
+
416
+ .event-header {
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ gap: 0.5rem;
421
+ }
422
+
423
+ .event-type-pill {
424
+ font-family: var(--mono-font);
425
+ font-size: 0.7rem;
426
+ font-weight: 700;
427
+ padding: 0.15rem 0.45rem;
428
+ border-radius: 0.25rem;
429
+ letter-spacing: 0.02em;
430
+ }
431
+ .type-STATE_TRANSITION {
432
+ background-color: var(--transition-bg);
433
+ color: var(--transition-text);
434
+ border: 1px solid #4338ca;
435
+ }
436
+ .type-GUARD_EVALUATED {
437
+ background-color: var(--guard-bg);
438
+ color: var(--guard-text);
439
+ border: 1px solid #047857;
440
+ }
441
+ .type-TOOL_CALL, .type-TOOL_EXECUTION {
442
+ background-color: var(--tool-bg);
443
+ color: var(--tool-text);
444
+ border: 1px solid #b45309;
445
+ }
446
+ .type-SKILL_INITIALIZED {
447
+ background-color: #082f49;
448
+ color: #7dd3fc;
449
+ border: 1px solid #0369a1;
450
+ }
451
+ .type-other {
452
+ background-color: #1f2937;
453
+ color: #9ca3af;
454
+ border: 1px solid #374151;
455
+ }
456
+
457
+ .event-seq {
458
+ font-family: var(--mono-font);
459
+ color: var(--text-dim);
460
+ font-size: 0.75rem;
461
+ }
462
+ .event-time {
463
+ font-size: 0.7rem;
464
+ color: var(--text-dim);
465
+ font-family: var(--mono-font);
466
+ }
467
+
468
+ .event-summary {
469
+ margin-top: 0.35rem;
470
+ font-size: 0.825rem;
471
+ color: #e2e8f0;
472
+ font-family: var(--mono-font);
473
+ word-break: break-word;
474
+ }
475
+
476
+ .event-payload-box {
477
+ margin-top: 0.5rem;
478
+ padding: 0.5rem;
479
+ background-color: #090d16;
480
+ border: 1px solid #1f2937;
481
+ border-radius: 0.375rem;
482
+ font-family: var(--mono-font);
483
+ font-size: 0.725rem;
484
+ color: #94a3b8;
485
+ overflow-x: auto;
486
+ display: none;
487
+ }
488
+ .event-item.expanded .event-payload-box {
489
+ display: block;
490
+ }
491
+
492
+ .empty-events {
493
+ padding: 3rem 1rem;
494
+ text-align: center;
495
+ color: var(--text-dim);
496
+ font-size: 0.85rem;
497
+ }
498
+
499
+ footer {
500
+ border-top: 1px solid var(--card-border);
501
+ padding: 0.75rem 1.5rem;
502
+ font-size: 0.75rem;
503
+ color: var(--text-dim);
504
+ display: flex;
505
+ justify-content: space-between;
506
+ align-items: center;
507
+ background-color: var(--card-bg);
508
+ }
509
+ footer a {
510
+ color: var(--primary);
511
+ text-decoration: none;
512
+ }
513
+ footer a:hover {
514
+ text-decoration: underline;
515
+ }
516
+ </style>
517
+ </head>
518
+ <body>
519
+
520
+ <header>
521
+ <div class="brand">
522
+ <span class="brand-logo">⚡</span>
523
+ <span class="brand-title">Reactive Skills</span>
524
+ <span class="brand-skill" id="skillNameBadge">${escapeHtml(titleSkill)}</span>
525
+ </div>
526
+
527
+ <div class="header-metrics">
528
+ <div class="status-badge connecting" id="connectionBadge">
529
+ <span class="status-dot"></span>
530
+ <span id="connectionText">Connecting...</span>
531
+ </div>
532
+
533
+ <div class="header-links">
534
+ <a href="/state" target="_blank">/state</a>
535
+ <a href="/events/history" target="_blank">/history</a>
536
+ <a href="/health" target="_blank">/health</a>
537
+ </div>
538
+ </div>
539
+ </header>
540
+
541
+ <main>
542
+ <!-- Left Column: Active State & Controls -->
543
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
544
+ <div class="card">
545
+ <div class="card-header">
546
+ <span class="card-title">Active State & Context</span>
547
+ <button class="btn btn-secondary btn-sm" id="refreshStateBtn" title="Refresh state from server">↻ Refresh</button>
548
+ </div>
549
+
550
+ <div class="state-panel">
551
+ <div class="active-state-banner">
552
+ <div class="active-state-label">Current State</div>
553
+ <div class="active-state-name" id="activeStateDisplay">LOADING...</div>
554
+ </div>
555
+
556
+ <div class="meta-grid">
557
+ <div class="meta-box">
558
+ <div class="meta-box-label">Sequence</div>
559
+ <div class="meta-box-val" id="latestSeqDisplay">0</div>
560
+ </div>
561
+ <div class="meta-box">
562
+ <div class="meta-box-label">Uptime</div>
563
+ <div class="meta-box-val" id="uptimeDisplay">0s</div>
564
+ </div>
565
+ </div>
566
+ </div>
567
+
568
+ <!-- Signal Dispatcher -->
569
+ <div class="dispatch-box">
570
+ <div class="card-title" style="font-size: 0.8rem; margin-bottom: 0.25rem;">Dispatch Signal</div>
571
+ <form id="signalForm">
572
+ <div class="form-group">
573
+ <label for="signalInput">Signal Name</label>
574
+ <input type="text" id="signalInput" class="input-field" placeholder="e.g. RUNTIME_READY" required autocomplete="off">
575
+ </div>
576
+
577
+ <div class="form-group" style="margin-top: 0.5rem;">
578
+ <label for="payloadInput">Payload (JSON, optional)</label>
579
+ <textarea id="payloadInput" class="input-field" placeholder='{"key": "value"}'></textarea>
580
+ </div>
581
+
582
+ <div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
583
+ <button type="submit" class="btn btn-primary" id="emitBtn" style="flex: 1;">⚡ Emit Signal</button>
584
+ </div>
585
+ </form>
586
+ <div class="dispatch-feedback" id="dispatchFeedback"></div>
587
+ </div>
588
+
589
+ <!-- Context Inspector -->
590
+ <details class="context-accordion" id="contextDetails">
591
+ <summary>Inspect FSM Context (<span id="contextKeyCount">0</span> keys)</summary>
592
+ <pre class="context-json" id="contextDisplay">{}</pre>
593
+ </details>
594
+ </div>
595
+ </div>
596
+
597
+ <!-- Right Column: Live Event Stream -->
598
+ <div class="card ledger-container">
599
+ <div class="card-header">
600
+ <div class="card-title">
601
+ <span>Live Event Ledger</span>
602
+ <span style="font-size: 0.75rem; color: var(--text-dim); font-family: var(--mono-font);" id="eventCountBadge">(0 events)</span>
603
+ </div>
604
+ <div class="ledger-controls">
605
+ <select class="filter-select" id="eventFilter">
606
+ <option value="ALL">All Events</option>
607
+ <option value="STATE_TRANSITION">Transitions</option>
608
+ <option value="GUARD_EVALUATED">Guards</option>
609
+ <option value="TOOL_CALL">Tools</option>
610
+ <option value="SKILL_INITIALIZED">Init</option>
611
+ </select>
612
+ <button class="btn btn-secondary btn-sm" id="pauseBtn">⏸ Pause</button>
613
+ <button class="btn btn-secondary btn-sm" id="clearBtn">Clear</button>
614
+ </div>
615
+ </div>
616
+
617
+ <div class="events-scroll" id="eventsList">
618
+ <div class="empty-events" id="emptyEvents">Connecting to event stream...</div>
619
+ </div>
620
+ </div>
621
+ </main>
622
+
623
+ <footer>
624
+ <div>Reactive Skills Architecture (RSA) • Telemetry Daemon v0.4.1</div>
625
+ <div>Running on <code>http://${escapeHtml(options.host)}:${options.port}</code></div>
626
+ </footer>
627
+
628
+ <script>
629
+ (function () {
630
+ let eventSource = null;
631
+ let isPaused = false;
632
+ let allEvents = [];
633
+ let autoScroll = true;
634
+
635
+ const connectionBadge = document.getElementById('connectionBadge');
636
+ const connectionText = document.getElementById('connectionText');
637
+ const activeStateDisplay = document.getElementById('activeStateDisplay');
638
+ const latestSeqDisplay = document.getElementById('latestSeqDisplay');
639
+ const uptimeDisplay = document.getElementById('uptimeDisplay');
640
+ const contextDisplay = document.getElementById('contextDisplay');
641
+ const contextKeyCount = document.getElementById('contextKeyCount');
642
+ const eventsList = document.getElementById('eventsList');
643
+ const emptyEvents = document.getElementById('emptyEvents');
644
+ const eventCountBadge = document.getElementById('eventCountBadge');
645
+ const eventFilter = document.getElementById('eventFilter');
646
+ const pauseBtn = document.getElementById('pauseBtn');
647
+ const clearBtn = document.getElementById('clearBtn');
648
+ const refreshStateBtn = document.getElementById('refreshStateBtn');
649
+ const signalForm = document.getElementById('signalForm');
650
+ const signalInput = document.getElementById('signalInput');
651
+ const payloadInput = document.getElementById('payloadInput');
652
+ const emitBtn = document.getElementById('emitBtn');
653
+ const dispatchFeedback = document.getElementById('dispatchFeedback');
654
+ const skillNameBadge = document.getElementById('skillNameBadge');
655
+
656
+ // Auto-detect scroll position
657
+ eventsList.addEventListener('scroll', () => {
658
+ const atBottom = eventsList.scrollHeight - eventsList.scrollTop - eventsList.clientHeight < 40;
659
+ autoScroll = atBottom;
660
+ });
661
+
662
+ function setConnectionStatus(status, text) {
663
+ connectionBadge.className = 'status-badge ' + status;
664
+ connectionText.textContent = text;
665
+ }
666
+
667
+ async function fetchState() {
668
+ try {
669
+ const res = await fetch('/state');
670
+ if (!res.ok) throw new Error('HTTP ' + res.status);
671
+ const data = await res.json();
672
+ if (data.skillName) {
673
+ skillNameBadge.textContent = data.skillName;
674
+ document.title = '⚡ ' + data.skillName + ' — Reactive Skills Telemetry';
675
+ }
676
+ activeStateDisplay.textContent = data.activeState || 'UNKNOWN';
677
+ latestSeqDisplay.textContent = data.latestSeq !== undefined ? data.latestSeq : '0';
678
+
679
+ const ctx = data.context || {};
680
+ contextDisplay.textContent = JSON.stringify(ctx, null, 2);
681
+ contextKeyCount.textContent = Object.keys(ctx).length;
682
+ } catch (err) {
683
+ console.warn('Failed to fetch state:', err);
684
+ }
685
+ }
686
+
687
+ async function fetchHealth() {
688
+ try {
689
+ const res = await fetch('/health');
690
+ if (res.ok) {
691
+ const data = await res.json();
692
+ if (data.uptimeSeconds !== undefined) {
693
+ const u = data.uptimeSeconds;
694
+ const mins = Math.floor(u / 60);
695
+ const secs = u % 60;
696
+ uptimeDisplay.textContent = mins > 0 ? (mins + 'm ' + secs + 's') : (secs + 's');
697
+ }
698
+ }
699
+ } catch {}
700
+ }
701
+
702
+ async function fetchHistory() {
703
+ try {
704
+ const res = await fetch('/events/history?limit=100');
705
+ if (!res.ok) return;
706
+ const data = await res.json();
707
+ if (Array.isArray(data.events) && data.events.length > 0) {
708
+ allEvents = data.events;
709
+ renderEvents();
710
+ }
711
+ } catch (err) {
712
+ console.warn('Failed to fetch initial history:', err);
713
+ }
714
+ }
715
+
716
+ function formatSummary(evt) {
717
+ if (!evt) return '';
718
+ const p = evt.payload || {};
719
+ if (evt.type === 'STATE_TRANSITION') {
720
+ return (p.from || '?') + ' ──► ' + (p.to || '?') + (p.signal ? (' (via ' + p.signal + ')') : '');
721
+ }
722
+ if (evt.type === 'GUARD_EVALUATED') {
723
+ return 'Guard [' + (p.guard || '') + ']: ' + (p.result ? 'PASS ✅' : 'FAIL ❌');
724
+ }
725
+ if (evt.type === 'TOOL_CALL') {
726
+ return 'Tool call: ' + (p.tool || p.name || 'unnamed');
727
+ }
728
+ if (evt.type === 'SKILL_INITIALIZED') {
729
+ return 'Skill ' + (p.skill || '') + ' initialized at ' + (p.initial_state || 'INIT');
730
+ }
731
+ return JSON.stringify(p);
732
+ }
733
+
734
+ function renderEvents() {
735
+ const filter = eventFilter.value;
736
+ const filtered = filter === 'ALL'
737
+ ? allEvents
738
+ : allEvents.filter(e => {
739
+ if (filter === 'TOOL_CALL') return e.type === 'TOOL_CALL' || e.type === 'TOOL_EXECUTION';
740
+ return e.type === filter;
741
+ });
742
+
743
+ eventCountBadge.textContent = '(' + filtered.length + ' events)';
744
+
745
+ if (filtered.length === 0) {
746
+ emptyEvents.style.display = 'block';
747
+ emptyEvents.textContent = allEvents.length === 0 ? 'No events recorded yet.' : 'No events match the selected filter.';
748
+ return;
749
+ }
750
+
751
+ emptyEvents.style.display = 'none';
752
+
753
+ // Clear existing children except emptyEvents
754
+ Array.from(eventsList.children).forEach(child => {
755
+ if (child !== emptyEvents) eventsList.removeChild(child);
756
+ });
757
+
758
+ filtered.forEach((evt, idx) => {
759
+ const item = document.createElement('div');
760
+ item.className = 'event-item';
761
+
762
+ const typeClass = 'type-' + (evt.type in {
763
+ STATE_TRANSITION: 1, GUARD_EVALUATED: 1, TOOL_CALL: 1, TOOL_EXECUTION: 1, SKILL_INITIALIZED: 1
764
+ } ? evt.type : 'other');
765
+
766
+ const timeStr = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '';
767
+ const seqStr = evt.sequence !== undefined ? ('#' + evt.sequence) : ('#' + (idx + 1));
768
+
769
+ item.innerHTML =
770
+ '<div class="event-header">' +
771
+ '<span class="event-type-pill ' + typeClass + '">' + escapeHtml(evt.type || 'EVENT') + '</span>' +
772
+ '<div style="display:flex; gap:0.5rem; align-items:center;">' +
773
+ '<span class="event-time">' + escapeHtml(timeStr) + '</span>' +
774
+ '<span class="event-seq">' + escapeHtml(seqStr) + '</span>' +
775
+ '</div>' +
776
+ '</div>' +
777
+ '<div class="event-summary">' + escapeHtml(formatSummary(evt)) + '</div>' +
778
+ '<pre class="event-payload-box">' + escapeHtml(JSON.stringify(evt.payload || {}, null, 2)) + '</pre>';
779
+
780
+ item.addEventListener('click', () => {
781
+ item.classList.toggle('expanded');
782
+ });
783
+
784
+ eventsList.appendChild(item);
785
+ });
786
+
787
+ if (autoScroll) {
788
+ eventsList.scrollTop = eventsList.scrollHeight;
789
+ }
790
+ }
791
+
792
+ function appendEvent(evt) {
793
+ if (!evt) return;
794
+ allEvents.push(evt);
795
+ if (allEvents.length > 500) allEvents.shift();
796
+
797
+ if (evt.type === 'STATE_TRANSITION' || evt.type === 'SKILL_INITIALIZED') {
798
+ fetchState();
799
+ }
800
+ if (evt.sequence !== undefined) {
801
+ latestSeqDisplay.textContent = evt.sequence;
802
+ }
803
+
804
+ if (!isPaused) {
805
+ renderEvents();
806
+ }
807
+ }
808
+
809
+ function initSse() {
810
+ if (eventSource) {
811
+ try { eventSource.close(); } catch {}
812
+ }
813
+
814
+ setConnectionStatus('connecting', 'Connecting...');
815
+ eventSource = new EventSource('/events');
816
+
817
+ eventSource.addEventListener('connected', () => {
818
+ setConnectionStatus('', 'Connected (SSE)');
819
+ });
820
+
821
+ eventSource.onopen = () => {
822
+ setConnectionStatus('', 'Connected (SSE)');
823
+ };
824
+
825
+ eventSource.onmessage = (e) => {
826
+ try {
827
+ const parsed = JSON.parse(e.data);
828
+ appendEvent(parsed);
829
+ } catch (err) {
830
+ console.warn('Failed to parse event JSON:', err);
831
+ }
832
+ };
833
+
834
+ eventSource.onerror = () => {
835
+ setConnectionStatus('disconnected', 'Reconnecting...');
836
+ };
837
+ }
838
+
839
+ function escapeHtml(str) {
840
+ if (!str) return '';
841
+ return String(str)
842
+ .replace(/&/g, '&amp;')
843
+ .replace(/</g, '&lt;')
844
+ .replace(/>/g, '&gt;')
845
+ .replace(/"/g, '&quot;')
846
+ .replace(/'/g, '&#039;');
847
+ }
848
+
849
+ // Signal Form Submit
850
+ signalForm.addEventListener('submit', async (e) => {
851
+ e.preventDefault();
852
+ const signal = signalInput.value.trim();
853
+ if (!signal) return;
854
+
855
+ let payload = {};
856
+ const rawPayload = payloadInput.value.trim();
857
+ if (rawPayload) {
858
+ try {
859
+ payload = JSON.parse(rawPayload);
860
+ } catch (err) {
861
+ dispatchFeedback.className = 'dispatch-feedback error';
862
+ dispatchFeedback.textContent = 'Invalid JSON payload: ' + err.message;
863
+ return;
864
+ }
865
+ }
866
+
867
+ emitBtn.disabled = true;
868
+ emitBtn.textContent = 'Emitting...';
869
+ dispatchFeedback.style.display = 'none';
870
+
871
+ try {
872
+ const res = await fetch('/signal', {
873
+ method: 'POST',
874
+ headers: { 'Content-Type': 'application/json' },
875
+ body: JSON.stringify({ signal, payload })
876
+ });
877
+ const result = await res.json();
878
+
879
+ if (!res.ok || result.error) {
880
+ dispatchFeedback.className = 'dispatch-feedback error';
881
+ dispatchFeedback.textContent = 'Emission error: ' + (result.error || 'HTTP ' + res.status);
882
+ } else {
883
+ dispatchFeedback.className = 'dispatch-feedback success';
884
+ dispatchFeedback.textContent = result.transitioned
885
+ ? '✅ Transitioned ──► ' + result.newState
886
+ : '⚡ Signal evaluated (no transition occurred)';
887
+ signalInput.value = '';
888
+ fetchState();
889
+ }
890
+ } catch (err) {
891
+ dispatchFeedback.className = 'dispatch-feedback error';
892
+ dispatchFeedback.textContent = 'Network error: ' + err.message;
893
+ } finally {
894
+ emitBtn.disabled = false;
895
+ emitBtn.textContent = '⚡ Emit Signal';
896
+ }
897
+ });
898
+
899
+ // Controls
900
+ pauseBtn.addEventListener('click', () => {
901
+ isPaused = !isPaused;
902
+ pauseBtn.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
903
+ if (!isPaused) renderEvents();
904
+ });
905
+
906
+ clearBtn.addEventListener('click', () => {
907
+ allEvents = [];
908
+ renderEvents();
909
+ });
910
+
911
+ eventFilter.addEventListener('change', () => {
912
+ renderEvents();
913
+ });
914
+
915
+ refreshStateBtn.addEventListener('click', () => {
916
+ fetchState();
917
+ fetchHealth();
918
+ });
919
+
920
+ // Boot
921
+ fetchState();
922
+ fetchHealth();
923
+ fetchHistory().then(initSse);
924
+ setInterval(fetchHealth, 5000);
925
+ })();
926
+ </script>
927
+ </body>
928
+ </html>`;
929
+ }
930
+ function escapeHtml(str) {
931
+ if (!str)
932
+ return '';
933
+ return String(str)
934
+ .replace(/&/g, '&amp;')
935
+ .replace(/</g, '&lt;')
936
+ .replace(/>/g, '&gt;')
937
+ .replace(/"/g, '&quot;')
938
+ .replace(/'/g, '&#039;');
939
+ }
@@ -21,6 +21,7 @@ export declare class TelemetryServer {
21
21
  stop(): Promise<void>;
22
22
  private setCorsHeaders;
23
23
  private handleRequest;
24
+ private handleDashboard;
24
25
  private handleHealth;
25
26
  private handleState;
26
27
  private handleEventsHistory;
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { URL } from 'node:url';
3
+ import { renderDashboardHtml } from './dashboard.js';
3
4
  export class TelemetryServer {
4
5
  server = null;
5
6
  eventStore;
@@ -110,16 +111,21 @@ export class TelemetryServer {
110
111
  const hostHeader = req.headers.host || `${this.host}:${this.port}`;
111
112
  const parsedUrl = new URL(req.url || '/', `http://${hostHeader}`);
112
113
  const pathname = parsedUrl.pathname;
113
- if (req.method === 'GET' && (pathname === '/health' || pathname === '/status')) {
114
- this.handleHealth(res);
114
+ const isGetOrHead = req.method === 'GET' || req.method === 'HEAD';
115
+ if (isGetOrHead && (pathname === '/' || pathname === '/index.html')) {
116
+ this.handleDashboard(req, res);
115
117
  return;
116
118
  }
117
- if (req.method === 'GET' && pathname === '/state') {
118
- this.handleState(res);
119
+ if (isGetOrHead && (pathname === '/health' || pathname === '/status')) {
120
+ this.handleHealth(req, res);
119
121
  return;
120
122
  }
121
- if (req.method === 'GET' && pathname === '/events/history') {
122
- this.handleEventsHistory(parsedUrl, res);
123
+ if (isGetOrHead && pathname === '/state') {
124
+ this.handleState(req, res);
125
+ return;
126
+ }
127
+ if (isGetOrHead && pathname === '/events/history') {
128
+ this.handleEventsHistory(req, parsedUrl, res);
123
129
  return;
124
130
  }
125
131
  if (req.method === 'GET' && pathname === '/events') {
@@ -133,17 +139,43 @@ export class TelemetryServer {
133
139
  res.writeHead(404, { 'Content-Type': 'application/json' });
134
140
  res.end(JSON.stringify({ error: `Not found: ${pathname}` }));
135
141
  }
136
- handleHealth(res) {
142
+ handleDashboard(req, res) {
143
+ const html = renderDashboardHtml({
144
+ skillName: this.skillName,
145
+ port: this.getPort(),
146
+ host: this.host,
147
+ });
148
+ res.writeHead(200, {
149
+ 'Content-Type': 'text/html; charset=utf-8',
150
+ 'Content-Length': Buffer.byteLength(html),
151
+ });
152
+ if (req.method === 'HEAD') {
153
+ res.end();
154
+ }
155
+ else {
156
+ res.end(html);
157
+ }
158
+ }
159
+ handleHealth(req, res) {
137
160
  const payload = {
138
161
  status: 'ok',
139
162
  skillName: this.skillName,
140
163
  latestSeq: this.eventStore.getLatestSequence(),
141
164
  uptimeSeconds: Math.floor((Date.now() - this.startTime) / 1000),
142
165
  };
143
- res.writeHead(200, { 'Content-Type': 'application/json' });
144
- res.end(JSON.stringify(payload));
166
+ const body = JSON.stringify(payload);
167
+ res.writeHead(200, {
168
+ 'Content-Type': 'application/json',
169
+ 'Content-Length': Buffer.byteLength(body),
170
+ });
171
+ if (req.method === 'HEAD') {
172
+ res.end();
173
+ }
174
+ else {
175
+ res.end(body);
176
+ }
145
177
  }
146
- handleState(res) {
178
+ handleState(req, res) {
147
179
  const latestSeq = this.eventStore.getLatestSequence();
148
180
  const snapshot = this.eventStore.getLatestSnapshot();
149
181
  const activeState = this.fsmEngine ? this.fsmEngine.getCurrentState() : snapshot?.state;
@@ -155,10 +187,19 @@ export class TelemetryServer {
155
187
  context,
156
188
  snapshot,
157
189
  };
158
- res.writeHead(200, { 'Content-Type': 'application/json' });
159
- res.end(JSON.stringify(payload));
190
+ const body = JSON.stringify(payload);
191
+ res.writeHead(200, {
192
+ 'Content-Type': 'application/json',
193
+ 'Content-Length': Buffer.byteLength(body),
194
+ });
195
+ if (req.method === 'HEAD') {
196
+ res.end();
197
+ }
198
+ else {
199
+ res.end(body);
200
+ }
160
201
  }
161
- handleEventsHistory(url, res) {
202
+ handleEventsHistory(req, url, res) {
162
203
  const sinceSeqParam = url.searchParams.get('sinceSeq');
163
204
  const limitParam = url.searchParams.get('limit');
164
205
  const sinceSeq = sinceSeqParam !== null ? parseInt(sinceSeqParam, 10) : undefined;
@@ -173,8 +214,21 @@ export class TelemetryServer {
173
214
  if (limit !== undefined && !isNaN(limit) && limit > 0) {
174
215
  events = events.slice(-limit);
175
216
  }
176
- res.writeHead(200, { 'Content-Type': 'application/json' });
177
- res.end(JSON.stringify({ count: events.length, events }));
217
+ const payload = {
218
+ count: events.length,
219
+ events,
220
+ };
221
+ const body = JSON.stringify(payload);
222
+ res.writeHead(200, {
223
+ 'Content-Type': 'application/json',
224
+ 'Content-Length': Buffer.byteLength(body),
225
+ });
226
+ if (req.method === 'HEAD') {
227
+ res.end();
228
+ }
229
+ else {
230
+ res.end(body);
231
+ }
178
232
  }
179
233
  handleSseEvents(req, url, res) {
180
234
  res.writeHead(200, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactive-skills/runtime",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Reactive Skills Architecture (RSA) core runtime — FSM engine, event store, guard evaluator, projection engine, MCP server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,9 @@
17
17
  "dist",
18
18
  "CHANGELOG.md"
19
19
  ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
20
23
  "keywords": [
21
24
  "ai",
22
25
  "agent",
@@ -37,9 +40,6 @@
37
40
  "engines": {
38
41
  "node": ">=22.5.0"
39
42
  },
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/sdk": "^1.30.0",
45
45
  "handlebars": "^4.7.8",