agentgui 1.0.214 → 1.0.215

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.214",
3
+ "version": "1.0.215",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -2168,6 +2168,7 @@ wss.on('connection', (ws, req) => {
2168
2168
  } else if (data.type === 'latency_report') {
2169
2169
  ws.latencyTier = data.quality || 'good';
2170
2170
  ws.latencyAvg = data.avg || 0;
2171
+ ws.latencyTrend = data.trend || 'stable';
2171
2172
  } else if (data.type === 'ping') {
2172
2173
  ws.send(JSON.stringify({
2173
2174
  type: 'pong',
@@ -2207,8 +2208,16 @@ const BROADCAST_TYPES = new Set([
2207
2208
  const wsBatchQueues = new Map();
2208
2209
  const BATCH_BY_TIER = { excellent: 16, good: 32, fair: 50, poor: 100, bad: 200 };
2209
2210
 
2211
+ const TIER_ORDER = ['excellent', 'good', 'fair', 'poor', 'bad'];
2210
2212
  function getBatchInterval(ws) {
2211
- return BATCH_BY_TIER[ws.latencyTier] || 32;
2213
+ const tier = ws.latencyTier || 'good';
2214
+ const trend = ws.latencyTrend;
2215
+ if (trend === 'rising' || trend === 'falling') {
2216
+ const idx = TIER_ORDER.indexOf(tier);
2217
+ if (trend === 'rising' && idx < TIER_ORDER.length - 1) return BATCH_BY_TIER[TIER_ORDER[idx + 1]] || 32;
2218
+ if (trend === 'falling' && idx > 0) return BATCH_BY_TIER[TIER_ORDER[idx - 1]] || 32;
2219
+ }
2220
+ return BATCH_BY_TIER[tier] || 32;
2212
2221
  }
2213
2222
 
2214
2223
  function flushWsBatch(ws) {
package/static/index.html CHANGED
@@ -2292,6 +2292,21 @@
2292
2292
  .streaming-blocks > details.block-tool-use {
2293
2293
  transition: max-height 0.3s ease;
2294
2294
  }
2295
+ @keyframes chunk-placeholder-pulse {
2296
+ 0%, 100% { opacity: 0.3; }
2297
+ 50% { opacity: 0.6; }
2298
+ }
2299
+ .chunk-placeholder {
2300
+ height: 2rem;
2301
+ border-radius: 0.375rem;
2302
+ background: var(--color-bg-secondary);
2303
+ animation: chunk-placeholder-pulse 1s ease-in-out infinite;
2304
+ margin: 0.25rem 0;
2305
+ }
2306
+ .connection-dot.degrading {
2307
+ animation: pulse 1s ease-in-out infinite;
2308
+ background-color: var(--color-warning) !important;
2309
+ }
2295
2310
  </style>
2296
2311
  </head>
2297
2312
  <body>
@@ -2483,6 +2498,8 @@
2483
2498
  </script>
2484
2499
  <script defer src="/gm/js/event-processor.js"></script>
2485
2500
  <script defer src="/gm/js/streaming-renderer.js"></script>
2501
+ <script defer src="/gm/js/kalman-filter.js"></script>
2502
+ <script defer src="/gm/js/event-consolidator.js"></script>
2486
2503
  <script defer src="/gm/js/websocket-manager.js"></script>
2487
2504
  <script defer src="/gm/js/event-filter.js"></script>
2488
2505
  <script defer src="/gm/js/syntax-highlighter.js"></script>
@@ -62,6 +62,22 @@ class AgentGUIClient {
62
62
  this._inflightRequests = new Map();
63
63
  this._previousConvAbort = null;
64
64
 
65
+ this._scrollKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 50, measurementNoise: 100 }) : null;
66
+ this._scrollTarget = 0;
67
+ this._scrollAnimating = false;
68
+ this._scrollLerpFactor = config.scrollAnimationSpeed || 0.15;
69
+
70
+ this._chunkTimingKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 10, measurementNoise: 200 }) : null;
71
+ this._lastChunkArrival = 0;
72
+ this._chunkTimingUpdateCount = 0;
73
+ this._chunkMissedPredictions = 0;
74
+
75
+ this._consolidator = typeof EventConsolidator !== 'undefined' ? new EventConsolidator() : null;
76
+
77
+ this._serverProcessingEstimate = 2000;
78
+ this._lastSendTime = 0;
79
+ this._countdownTimer = null;
80
+
65
81
  // Router state
66
82
  this.routerState = {
67
83
  currentConversationId: null,
@@ -103,6 +119,7 @@ class AgentGUIClient {
103
119
 
104
120
  this.state.isInitialized = true;
105
121
  this.emit('initialized');
122
+ this._setupDebugHooks();
106
123
 
107
124
  console.log('AgentGUI client initialized');
108
125
  return this;
@@ -147,6 +164,16 @@ class AgentGUIClient {
147
164
  this.wsManager.on('latency_update', (data) => {
148
165
  this._updateConnectionIndicator(data.quality);
149
166
  });
167
+
168
+ this.wsManager.on('connection_degrading', () => {
169
+ const dot = document.querySelector('.connection-dot');
170
+ if (dot) dot.classList.add('degrading');
171
+ });
172
+
173
+ this.wsManager.on('connection_recovering', () => {
174
+ const dot = document.querySelector('.connection-dot');
175
+ if (dot) dot.classList.remove('degrading');
176
+ });
150
177
  }
151
178
 
152
179
  /**
@@ -409,6 +436,13 @@ class AgentGUIClient {
409
436
 
410
437
  async handleStreamingStart(data) {
411
438
  console.log('Streaming started:', data);
439
+ this._clearThinkingCountdown();
440
+ if (this._lastSendTime > 0) {
441
+ const actual = Date.now() - this._lastSendTime;
442
+ const predicted = this.wsManager?.latency?.predicted || 0;
443
+ const serverTime = Math.max(500, actual - predicted);
444
+ this._serverProcessingEstimate = 0.7 * this._serverProcessingEstimate + 0.3 * serverTime;
445
+ }
412
446
 
413
447
  // If this streaming event is for a different conversation than what we are viewing,
414
448
  // just track the state but do not modify the DOM or start polling
@@ -585,21 +619,54 @@ class AgentGUIClient {
585
619
  }
586
620
 
587
621
  scrollToBottom() {
588
- if (this._scrollRafPending) return;
589
- this._scrollRafPending = true;
590
- requestAnimationFrame(() => {
591
- this._scrollRafPending = false;
592
- const scrollContainer = document.getElementById('output-scroll');
593
- if (!scrollContainer) return;
594
- const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
595
- if (distFromBottom < 150) {
596
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
622
+ const scrollContainer = document.getElementById('output-scroll');
623
+ if (!scrollContainer) return;
624
+ const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
625
+
626
+ if (distFromBottom > 150) {
627
+ this._unseenCount = (this._unseenCount || 0) + 1;
628
+ this._showNewContentPill();
629
+ return;
630
+ }
631
+
632
+ const maxScroll = scrollContainer.scrollHeight - scrollContainer.clientHeight;
633
+ const isStreaming = this.state.streamingConversations.size > 0;
634
+
635
+ if (!isStreaming || !this._scrollKalman || Math.abs(maxScroll - scrollContainer.scrollTop) > 2000) {
636
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
637
+ this._removeNewContentPill();
638
+ this._scrollAnimating = false;
639
+ return;
640
+ }
641
+
642
+ this._scrollKalman.update(maxScroll);
643
+ this._scrollTarget = this._scrollKalman.predict();
644
+
645
+ const conf = this._chunkArrivalConfidence();
646
+ if (conf > 0.5) {
647
+ const estHeight = this._estimatedBlockHeight('text') * 0.5 * conf;
648
+ this._scrollTarget += estHeight;
649
+ const trueMax = scrollContainer.scrollHeight - scrollContainer.clientHeight;
650
+ if (this._scrollTarget > trueMax + 100) this._scrollTarget = trueMax + 100;
651
+ }
652
+
653
+ if (!this._scrollAnimating) {
654
+ this._scrollAnimating = true;
655
+ const animate = () => {
656
+ if (!this._scrollAnimating) return;
657
+ const sc = document.getElementById('output-scroll');
658
+ if (!sc) { this._scrollAnimating = false; return; }
659
+ const diff = this._scrollTarget - sc.scrollTop;
660
+ if (Math.abs(diff) < 1) {
661
+ sc.scrollTop = this._scrollTarget;
662
+ if (this.state.streamingConversations.size === 0) { this._scrollAnimating = false; return; }
663
+ }
664
+ sc.scrollTop += diff * this._scrollLerpFactor;
597
665
  this._removeNewContentPill();
598
- } else {
599
- this._unseenCount = (this._unseenCount || 0) + 1;
600
- this._showNewContentPill();
601
- }
602
- });
666
+ requestAnimationFrame(animate);
667
+ };
668
+ requestAnimationFrame(animate);
669
+ }
603
670
  }
604
671
 
605
672
  _showNewContentPill() {
@@ -627,6 +694,7 @@ class AgentGUIClient {
627
694
 
628
695
  handleStreamingError(data) {
629
696
  console.error('Streaming error:', data);
697
+ this._clearThinkingCountdown();
630
698
 
631
699
  const conversationId = data.conversationId || this.state.currentSession?.conversationId;
632
700
 
@@ -658,6 +726,7 @@ class AgentGUIClient {
658
726
 
659
727
  handleStreamingComplete(data) {
660
728
  console.log('Streaming completed:', data);
729
+ this._clearThinkingCountdown();
661
730
 
662
731
  const conversationId = data.conversationId || this.state.currentSession?.conversationId;
663
732
  if (conversationId) this.invalidateCache(conversationId);
@@ -1157,7 +1226,130 @@ class AgentGUIClient {
1157
1226
 
1158
1227
  _getAdaptivePollInterval() {
1159
1228
  const quality = this.wsManager?.latency?.quality || 'unknown';
1160
- return this._pollIntervalByTier[quality] || 200;
1229
+ const base = this._pollIntervalByTier[quality] || 200;
1230
+ const trend = this.wsManager?.latency?.trend;
1231
+ if (!trend || trend === 'stable') return base;
1232
+ const tiers = ['excellent', 'good', 'fair', 'poor', 'bad'];
1233
+ const idx = tiers.indexOf(quality);
1234
+ if (trend === 'rising' && idx < tiers.length - 1) return this._pollIntervalByTier[tiers[idx + 1]];
1235
+ if (trend === 'falling' && idx > 0) return this._pollIntervalByTier[tiers[idx - 1]];
1236
+ return base;
1237
+ }
1238
+
1239
+ _chunkArrivalConfidence() {
1240
+ if (this._chunkTimingUpdateCount < 2) return 0;
1241
+ const base = Math.min(1, this._chunkTimingUpdateCount / 8);
1242
+ const penalty = Math.min(1, this._chunkMissedPredictions * 0.33);
1243
+ return Math.max(0, base - penalty);
1244
+ }
1245
+
1246
+ _predictedNextChunkArrival() {
1247
+ if (!this._chunkTimingKalman || this._chunkTimingUpdateCount < 2) return 0;
1248
+ return this._lastChunkArrival + Math.min(this._chunkTimingKalman.predict(), 5000);
1249
+ }
1250
+
1251
+ _schedulePreAllocation(sessionId) {
1252
+ if (this._placeholderTimer) clearTimeout(this._placeholderTimer);
1253
+ if (this._chunkArrivalConfidence() < 0.5) return;
1254
+ const scrollContainer = document.getElementById('output-scroll');
1255
+ if (!scrollContainer) return;
1256
+ const distFromBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
1257
+ if (distFromBottom > 150) return;
1258
+ const nextArrival = this._predictedNextChunkArrival();
1259
+ if (!nextArrival) return;
1260
+ const delay = Math.max(0, nextArrival - performance.now() - 100);
1261
+ this._placeholderTimer = setTimeout(() => {
1262
+ this._placeholderTimer = null;
1263
+ this._insertPlaceholder(sessionId);
1264
+ }, delay);
1265
+ }
1266
+
1267
+ _insertPlaceholder(sessionId) {
1268
+ this._removePlaceholder();
1269
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
1270
+ if (!streamingEl) return;
1271
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
1272
+ if (!blocksEl) return;
1273
+ const ph = document.createElement('div');
1274
+ ph.className = 'chunk-placeholder';
1275
+ ph.id = 'chunk-placeholder-active';
1276
+ blocksEl.appendChild(ph);
1277
+ this._placeholderAutoRemove = setTimeout(() => this._removePlaceholder(), 500);
1278
+ }
1279
+
1280
+ _removePlaceholder() {
1281
+ if (this._placeholderAutoRemove) { clearTimeout(this._placeholderAutoRemove); this._placeholderAutoRemove = null; }
1282
+ const ph = document.getElementById('chunk-placeholder-active');
1283
+ if (ph && ph.parentNode) ph.remove();
1284
+ }
1285
+
1286
+ _trackBlockHeight(block, element) {
1287
+ if (!element || !block?.type) return;
1288
+ const h = element.offsetHeight;
1289
+ if (h <= 0) return;
1290
+ if (!this._blockHeightAvg) this._blockHeightAvg = {};
1291
+ const t = block.type;
1292
+ if (!this._blockHeightAvg[t]) this._blockHeightAvg[t] = { sum: 0, count: 0 };
1293
+ this._blockHeightAvg[t].sum += h;
1294
+ this._blockHeightAvg[t].count++;
1295
+ }
1296
+
1297
+ _estimatedBlockHeight(type) {
1298
+ const defaults = { text: 40, tool_use: 60, tool_result: 40 };
1299
+ if (this._blockHeightAvg?.[type]?.count >= 3) {
1300
+ return this._blockHeightAvg[type].sum / this._blockHeightAvg[type].count;
1301
+ }
1302
+ return defaults[type] || 40;
1303
+ }
1304
+
1305
+ _startThinkingCountdown() {
1306
+ this._clearThinkingCountdown();
1307
+ if (!this._lastSendTime) return;
1308
+ const predicted = this.wsManager?.latency?.predicted || 0;
1309
+ const estimatedWait = predicted + this._serverProcessingEstimate;
1310
+ if (estimatedWait < 1000) return;
1311
+ let remaining = Math.ceil(estimatedWait / 1000);
1312
+ const update = () => {
1313
+ const indicator = document.querySelector('.streaming-indicator');
1314
+ if (!indicator) return;
1315
+ if (remaining > 0) {
1316
+ indicator.textContent = `Thinking... (~${remaining}s)`;
1317
+ remaining--;
1318
+ this._countdownTimer = setTimeout(update, 1000);
1319
+ } else {
1320
+ indicator.textContent = 'Thinking... (taking longer than expected)';
1321
+ }
1322
+ };
1323
+ this._countdownTimer = setTimeout(update, 100);
1324
+ }
1325
+
1326
+ _clearThinkingCountdown() {
1327
+ if (this._countdownTimer) { clearTimeout(this._countdownTimer); this._countdownTimer = null; }
1328
+ }
1329
+
1330
+ _setupDebugHooks() {
1331
+ if (typeof window === 'undefined') return;
1332
+ const kalmanHistory = { latency: [], scroll: [], chunkTiming: [] };
1333
+ const self = this;
1334
+ window.__kalman = {
1335
+ latency: this.wsManager?._latencyKalman || null,
1336
+ scroll: this._scrollKalman || null,
1337
+ chunkTiming: this._chunkTimingKalman || null,
1338
+ history: kalmanHistory,
1339
+ getState: () => ({
1340
+ latency: self.wsManager?._latencyKalman?.getState() || null,
1341
+ scroll: self._scrollKalman?.getState() || null,
1342
+ chunkTiming: self._chunkTimingKalman?.getState() || null,
1343
+ serverProcessingEstimate: self._serverProcessingEstimate,
1344
+ chunkConfidence: self._chunkArrivalConfidence(),
1345
+ latencyTrend: self.wsManager?.latency?.trend || null
1346
+ })
1347
+ };
1348
+
1349
+ this.wsManager.on('latency_prediction', (data) => {
1350
+ kalmanHistory.latency.push({ time: Date.now(), ...data });
1351
+ if (kalmanHistory.latency.length > 100) kalmanHistory.latency.shift();
1352
+ });
1161
1353
  }
1162
1354
 
1163
1355
  _showSkeletonLoading(conversationId) {
@@ -1230,6 +1422,8 @@ class AgentGUIClient {
1230
1422
  this.wsManager.subscribeToSession(result.session.id);
1231
1423
  }
1232
1424
 
1425
+ this._lastSendTime = Date.now();
1426
+ this._startThinkingCountdown();
1233
1427
  this.emit('execution:started', result);
1234
1428
  } catch (error) {
1235
1429
  console.error('Stream execution error:', error);
@@ -1334,14 +1528,38 @@ class AgentGUIClient {
1334
1528
  pollState.emptyPollCount = 0;
1335
1529
  const lastChunk = chunks[chunks.length - 1];
1336
1530
  pollState.lastFetchTimestamp = lastChunk.created_at;
1531
+
1532
+ const now = performance.now();
1533
+ if (this._lastChunkArrival > 0 && this._chunkTimingKalman) {
1534
+ const delta = now - this._lastChunkArrival;
1535
+ this._chunkTimingKalman.update(delta);
1536
+ this._chunkTimingUpdateCount++;
1537
+ this._chunkMissedPredictions = 0;
1538
+ }
1539
+ this._lastChunkArrival = now;
1540
+
1337
1541
  this.renderChunkBatch(chunks.filter(c => c.block && c.block.type));
1542
+ if (this.state.currentSession?.id) this._schedulePreAllocation(this.state.currentSession.id);
1338
1543
  } else {
1339
1544
  pollState.emptyPollCount++;
1545
+ if (this._chunkTimingUpdateCount > 0) this._chunkMissedPredictions++;
1340
1546
  pollState.backoffDelay = Math.min(pollState.backoffDelay + 50, 500);
1341
1547
  }
1342
1548
 
1343
1549
  if (pollState.isPolling) {
1344
- pollState.pollTimer = setTimeout(pollOnce, pollState.backoffDelay);
1550
+ let nextDelay = pollState.backoffDelay;
1551
+ if (this._chunkArrivalConfidence() >= 0.3 && this._chunkTimingKalman) {
1552
+ const predicted = this._chunkTimingKalman.predict();
1553
+ const elapsed = performance.now() - this._lastChunkArrival;
1554
+ const untilNext = predicted - elapsed - 20;
1555
+ nextDelay = Math.max(50, Math.min(2000, untilNext));
1556
+ if (this._chunkMissedPredictions >= 3) {
1557
+ this._chunkTimingKalman.setProcessNoise(20);
1558
+ } else {
1559
+ this._chunkTimingKalman.setProcessNoise(10);
1560
+ }
1561
+ }
1562
+ pollState.pollTimer = setTimeout(pollOnce, nextDelay);
1345
1563
  }
1346
1564
  } catch (error) {
1347
1565
  console.warn('Chunk poll error:', error.message);
@@ -1372,6 +1590,13 @@ class AgentGUIClient {
1372
1590
  }
1373
1591
 
1374
1592
  pollState.isPolling = false;
1593
+ this._scrollAnimating = false;
1594
+ if (this._scrollKalman) this._scrollKalman.reset();
1595
+ if (this._chunkTimingKalman) this._chunkTimingKalman.reset();
1596
+ this._chunkTimingUpdateCount = 0;
1597
+ this._chunkMissedPredictions = 0;
1598
+ this._lastChunkArrival = 0;
1599
+ if (this._placeholderTimer) { clearTimeout(this._placeholderTimer); this._placeholderTimer = null; }
1375
1600
  }
1376
1601
 
1377
1602
  /**
@@ -1397,13 +1622,36 @@ class AgentGUIClient {
1397
1622
 
1398
1623
  renderChunkBatch(chunks) {
1399
1624
  if (!chunks.length) return;
1400
- const groups = {};
1625
+ const deduped = [];
1401
1626
  for (const chunk of chunks) {
1402
1627
  const sid = chunk.sessionId;
1403
1628
  if (!this._renderedSeqs.has(sid)) this._renderedSeqs.set(sid, new Set());
1404
1629
  const seqSet = this._renderedSeqs.get(sid);
1405
1630
  if (chunk.sequence !== undefined && seqSet.has(chunk.sequence)) continue;
1406
1631
  if (chunk.sequence !== undefined) seqSet.add(chunk.sequence);
1632
+ deduped.push(chunk);
1633
+ }
1634
+ if (!deduped.length) return;
1635
+
1636
+ let toRender = deduped;
1637
+ if (this._consolidator) {
1638
+ const { consolidated, stats } = this._consolidator.consolidate(deduped);
1639
+ toRender = consolidated;
1640
+ for (const c of consolidated) {
1641
+ if (c._mergedSequences) {
1642
+ const seqSet = this._renderedSeqs.get(c.sessionId);
1643
+ if (seqSet) c._mergedSequences.forEach(s => seqSet.add(s));
1644
+ }
1645
+ }
1646
+ if (stats.textMerged || stats.toolsCollapsed || stats.systemSuperseded) {
1647
+ console.log('Consolidation:', stats);
1648
+ }
1649
+ }
1650
+
1651
+ this._removePlaceholder();
1652
+ const groups = {};
1653
+ for (const chunk of toRender) {
1654
+ const sid = chunk.sessionId;
1407
1655
  if (!groups[sid]) groups[sid] = [];
1408
1656
  groups[sid].push(chunk);
1409
1657
  }
@@ -1542,6 +1790,8 @@ class AgentGUIClient {
1542
1790
  tooltip.innerHTML = [
1543
1791
  `<div>State: ${state}</div>`,
1544
1792
  `<div>Latency: ${Math.round(latency.avg || 0)}ms</div>`,
1793
+ `<div>Predicted: ${Math.round(latency.predicted || 0)}ms (Kalman)</div>`,
1794
+ `<div>Trend: ${latency.trend || 'unknown'}</div>`,
1545
1795
  `<div>Jitter: ${Math.round(latency.jitter || 0)}ms</div>`,
1546
1796
  `<div>Quality: ${latency.quality || 'unknown'}</div>`,
1547
1797
  `<div>Reconnects: ${stats.totalReconnects || 0}</div>`,
@@ -0,0 +1,98 @@
1
+ class EventConsolidator {
2
+ consolidate(chunks) {
3
+ const stats = { original: chunks.length, deduplicated: 0, textMerged: 0, toolsCollapsed: 0, systemSuperseded: 0 };
4
+ if (chunks.length <= 1) return { consolidated: chunks, stats };
5
+
6
+ const sorted = chunks.slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
7
+
8
+ const seen = new Set();
9
+ const deduped = [];
10
+ for (const c of sorted) {
11
+ const key = c.sessionId + ':' + c.sequence;
12
+ if (c.sequence !== undefined && seen.has(key)) { stats.deduplicated++; continue; }
13
+ if (c.sequence !== undefined) seen.add(key);
14
+ deduped.push(c);
15
+ }
16
+
17
+ const bySession = {};
18
+ for (const c of deduped) {
19
+ const sid = c.sessionId || '_';
20
+ if (!bySession[sid]) bySession[sid] = [];
21
+ bySession[sid].push(c);
22
+ }
23
+
24
+ const result = [];
25
+ for (const sid of Object.keys(bySession)) {
26
+ const sessionChunks = bySession[sid];
27
+ const merged = this._mergeTextBlocks(sessionChunks, stats);
28
+ this._collapseToolPairs(merged, stats);
29
+ const superseded = this._supersedeSystemBlocks(merged, stats);
30
+ result.push(...superseded);
31
+ }
32
+
33
+ result.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
34
+ return { consolidated: result, stats };
35
+ }
36
+
37
+ _mergeTextBlocks(chunks, stats) {
38
+ const result = [];
39
+ let pending = null;
40
+ const MAX_MERGE = 50 * 1024;
41
+
42
+ for (const c of chunks) {
43
+ if (c.block?.type === 'text') {
44
+ if (pending) {
45
+ const combined = (pending.block.text || '') + '\n' + (c.block.text || '');
46
+ if (combined.length <= MAX_MERGE) {
47
+ pending = {
48
+ ...pending,
49
+ block: { ...pending.block, text: combined },
50
+ created_at: c.created_at,
51
+ _mergedSequences: [...(pending._mergedSequences || [pending.sequence]), c.sequence]
52
+ };
53
+ stats.textMerged++;
54
+ continue;
55
+ }
56
+ }
57
+ if (pending) result.push(pending);
58
+ pending = { ...c, _mergedSequences: [c.sequence] };
59
+ } else {
60
+ if (pending) { result.push(pending); pending = null; }
61
+ result.push(c);
62
+ }
63
+ }
64
+ if (pending) result.push(pending);
65
+ return result;
66
+ }
67
+
68
+ _collapseToolPairs(chunks, stats) {
69
+ const toolUseMap = {};
70
+ for (const c of chunks) {
71
+ if (c.block?.type === 'tool_use' && c.block.id) toolUseMap[c.block.id] = c;
72
+ }
73
+ for (const c of chunks) {
74
+ if (c.block?.type === 'tool_result' && c.block.tool_use_id) {
75
+ const match = toolUseMap[c.block.tool_use_id];
76
+ if (match) {
77
+ match.block._hasResult = true;
78
+ c.block._collapsed = true;
79
+ stats.toolsCollapsed++;
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ _supersedeSystemBlocks(chunks, stats) {
86
+ const systemIndices = [];
87
+ for (let i = 0; i < chunks.length; i++) {
88
+ if (chunks[i].block?.type === 'system') systemIndices.push(i);
89
+ }
90
+ if (systemIndices.length <= 1) return chunks;
91
+ const keep = new Set();
92
+ keep.add(systemIndices[systemIndices.length - 1]);
93
+ stats.systemSuperseded += systemIndices.length - 1;
94
+ return chunks.filter((_, i) => !systemIndices.includes(i) || keep.has(i));
95
+ }
96
+ }
97
+
98
+ if (typeof module !== 'undefined' && module.exports) module.exports = EventConsolidator;
@@ -0,0 +1,67 @@
1
+ class KalmanFilter {
2
+ constructor(config = {}) {
3
+ this._initEst = config.initialEstimate || 0;
4
+ this._initErr = config.initialError || 1000;
5
+ this._q = Math.max(config.processNoise || 1, 0.001);
6
+ this._r = config.measurementNoise || 10;
7
+ this._est = this._initEst;
8
+ this._err = this._initErr;
9
+ this._gain = 0;
10
+ this._initialized = false;
11
+ this._lastValid = this._initEst;
12
+ }
13
+
14
+ update(measurement) {
15
+ if (!Number.isFinite(measurement)) {
16
+ return { estimate: this._est, error: this._err, gain: this._gain };
17
+ }
18
+ if (measurement < 0) measurement = 0;
19
+ if (!this._initialized) {
20
+ this._est = measurement;
21
+ this._err = this._r;
22
+ this._initialized = true;
23
+ this._lastValid = measurement;
24
+ this._gain = 1;
25
+ return { estimate: this._est, error: this._err, gain: this._gain };
26
+ }
27
+ let r = this._r;
28
+ if (this._est > 0 && Math.abs(measurement - this._est) > this._est * 10) {
29
+ r = r * 100;
30
+ }
31
+ const predErr = this._err + this._q;
32
+ this._gain = predErr / (predErr + r);
33
+ this._est = this._est + this._gain * (measurement - this._est);
34
+ this._err = (1 - this._gain) * predErr;
35
+ if (this._err < 1e-10) this._err = 1e-10;
36
+ this._lastValid = this._est;
37
+ return { estimate: this._est, error: this._err, gain: this._gain };
38
+ }
39
+
40
+ predict() {
41
+ return this._est;
42
+ }
43
+
44
+ setProcessNoise(q) { this._q = Math.max(q, 0.001); }
45
+ setMeasurementNoise(r) { this._r = r; }
46
+
47
+ getState() {
48
+ return {
49
+ estimate: this._est,
50
+ error: this._err,
51
+ gain: this._gain,
52
+ processNoise: this._q,
53
+ measurementNoise: this._r,
54
+ initialized: this._initialized
55
+ };
56
+ }
57
+
58
+ reset() {
59
+ this._est = this._initEst;
60
+ this._err = this._initErr;
61
+ this._gain = 0;
62
+ this._initialized = false;
63
+ this._lastValid = this._initEst;
64
+ }
65
+ }
66
+
67
+ if (typeof module !== 'undefined' && module.exports) module.exports = KalmanFilter;
@@ -31,10 +31,18 @@ class WebSocketManager {
31
31
  avg: 0,
32
32
  jitter: 0,
33
33
  quality: 'unknown',
34
+ predicted: 0,
35
+ predictedNext: 0,
36
+ trend: 'stable',
34
37
  missedPongs: 0,
35
38
  pingCounter: 0
36
39
  };
37
40
 
41
+ this._latencyKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 1, measurementNoise: 10 }) : null;
42
+ this._trendHistory = [];
43
+ this._trendCount = 0;
44
+ this._reconnectedAt = 0;
45
+
38
46
  this.stats = {
39
47
  totalConnections: 0,
40
48
  totalReconnects: 0,
@@ -106,6 +114,7 @@ class WebSocketManager {
106
114
  this.isConnected = true;
107
115
  this.isConnecting = false;
108
116
  this.connectionEstablishedAt = Date.now();
117
+ this._reconnectedAt = this.stats.totalConnections > 0 ? Date.now() : 0;
109
118
  this.stats.totalConnections++;
110
119
  this.stats.lastConnectedTime = Date.now();
111
120
  this.latency.missedPongs = 0;
@@ -162,21 +171,47 @@ class WebSocketManager {
162
171
  if (samples.length > this.config.latencyWindowSize) samples.shift();
163
172
 
164
173
  this.latency.current = rtt;
165
- this.latency.avg = samples.reduce((a, b) => a + b, 0) / samples.length;
174
+
175
+ if (this._latencyKalman && samples.length > 3) {
176
+ if (this._reconnectedAt && Date.now() - this._reconnectedAt < 5000) {
177
+ this._latencyKalman.setMeasurementNoise(50);
178
+ } else {
179
+ this._latencyKalman.setMeasurementNoise(10);
180
+ }
181
+ const result = this._latencyKalman.update(rtt);
182
+ this.latency.predicted = result.estimate;
183
+ this.latency.predictedNext = this._latencyKalman.predict();
184
+ this.latency.avg = result.estimate;
185
+ } else {
186
+ this.latency.avg = samples.reduce((a, b) => a + b, 0) / samples.length;
187
+ this.latency.predicted = this.latency.avg;
188
+ this.latency.predictedNext = this.latency.avg;
189
+ }
166
190
 
167
191
  if (samples.length > 1) {
168
- const mean = this.latency.avg;
192
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
169
193
  const variance = samples.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / samples.length;
170
194
  this.latency.jitter = Math.sqrt(variance);
171
195
  }
172
196
 
173
- const prev = this.latency.quality;
197
+ this._trendHistory.push(this.latency.predicted);
198
+ if (this._trendHistory.length > 3) this._trendHistory.shift();
199
+ if (this._trendHistory.length >= 3) {
200
+ const [a, b, c] = this._trendHistory;
201
+ const rising = b > a * 1.05 && c > b * 1.05;
202
+ const falling = b < a * 0.95 && c < b * 0.95;
203
+ this.latency.trend = rising ? 'rising' : falling ? 'falling' : 'stable';
204
+ }
205
+
174
206
  this.latency.quality = this._qualityTier(this.latency.avg);
175
207
  this.stats.avgLatency = this.latency.avg;
176
208
 
177
209
  this.emit('latency_update', {
178
210
  latency: rtt,
179
211
  avg: this.latency.avg,
212
+ predicted: this.latency.predicted,
213
+ predictedNext: this.latency.predictedNext,
214
+ trend: this.latency.trend,
180
215
  jitter: this.latency.jitter,
181
216
  quality: this.latency.quality
182
217
  });
@@ -184,6 +219,37 @@ class WebSocketManager {
184
219
  if (rtt > this.latency.avg * 3 && samples.length >= 3) {
185
220
  this.emit('latency_spike', { latency: rtt, avg: this.latency.avg });
186
221
  }
222
+
223
+ this.emit('latency_prediction', {
224
+ predicted: this.latency.predicted,
225
+ predictedNext: this.latency.predictedNext,
226
+ trend: this.latency.trend,
227
+ gain: this._latencyKalman ? this._latencyKalman.getState().gain : 0
228
+ });
229
+
230
+ this._checkDegradation();
231
+ }
232
+
233
+ _checkDegradation() {
234
+ if (this.latency.trend === 'rising') {
235
+ this._trendCount = (this._trendCount || 0) + 1;
236
+ } else {
237
+ if (this._trendCount >= 5 && (this.latency.trend === 'stable' || this.latency.trend === 'falling')) {
238
+ this.emit('connection_recovering', { currentTier: this.latency.quality });
239
+ }
240
+ this._trendCount = 0;
241
+ return;
242
+ }
243
+ if (this._trendCount < 5) return;
244
+ const currentTier = this.latency.quality;
245
+ const predictedTier = this._qualityTier(this.latency.predictedNext);
246
+ if (predictedTier === currentTier) return;
247
+ const thresholds = { excellent: 50, good: 150, fair: 300, poor: 500 };
248
+ const threshold = thresholds[currentTier];
249
+ if (!threshold) return;
250
+ const rate = this._trendHistory.length >= 2 ? this._trendHistory[this._trendHistory.length - 1] - this._trendHistory[0] : 0;
251
+ const timeToChange = rate > 0 ? Math.round((threshold - this.latency.predicted) / rate * 1000) : Infinity;
252
+ this.emit('connection_degrading', { currentTier, predictedTier, predictedLatency: this.latency.predictedNext, timeToChange });
187
253
  }
188
254
 
189
255
  _qualityTier(avg) {
@@ -283,13 +349,24 @@ class WebSocketManager {
283
349
  type: 'latency_report',
284
350
  avg: Math.round(this.latency.avg),
285
351
  jitter: Math.round(this.latency.jitter),
286
- quality: this.latency.quality
352
+ quality: this.latency.quality,
353
+ trend: this.latency.trend,
354
+ predictedNext: Math.round(this.latency.predictedNext)
287
355
  });
288
356
  }
289
357
  }
290
358
 
291
359
  _handleVisibilityChange() {
292
- if (typeof document !== 'undefined' && document.hidden) return;
360
+ if (typeof document !== 'undefined' && document.hidden) {
361
+ this._hiddenAt = Date.now();
362
+ return;
363
+ }
364
+ if (this._hiddenAt && this._latencyKalman && Date.now() - this._hiddenAt > 30000) {
365
+ this._latencyKalman.reset();
366
+ this._trendHistory = [];
367
+ this.latency.trend = 'stable';
368
+ }
369
+ this._hiddenAt = 0;
293
370
  if (!this.isConnected && !this.isConnecting && !this.isManuallyDisconnected) {
294
371
  if (this.reconnectTimer) {
295
372
  clearTimeout(this.reconnectTimer);