clawmatrix 0.2.11 → 0.4.0

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/src/handoff.ts CHANGED
@@ -54,16 +54,33 @@ export class HandoffManager {
54
54
  // Multi-device sync: track which nodes are watching each handoff session (by sessionId)
55
55
  private sessionWatchers = new Map<string, Set<string>>();
56
56
 
57
+ // Pre-built indexes for O(1) agent lookup
58
+ private agentById = new Map<string, ClawMatrixConfig["agents"][number]>();
59
+ private agentsByTag = new Map<string, ClawMatrixConfig["agents"][number]>();
60
+
57
61
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, gatewayInfo: GatewayInfo) {
58
62
  this.config = config;
59
63
  this.peerManager = peerManager;
60
64
  this.gatewayInfo = gatewayInfo;
61
65
  this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
62
66
 
67
+ // Build agent indexes
68
+ for (const a of config.agents) {
69
+ this.agentById.set(a.id, a);
70
+ for (const t of a.tags) this.agentsByTag.set(t, a);
71
+ }
72
+
63
73
  // Periodically clean up stale input_required entries
64
74
  this.staleCleanupTimer = setInterval(() => this.cleanupStale(), STALE_CLEANUP_INTERVAL);
65
75
  }
66
76
 
77
+ private findLocalAgent(target: string): ClawMatrixConfig["agents"][number] | undefined {
78
+ if (target.startsWith("tags:")) {
79
+ return this.agentsByTag.get(target.slice(5));
80
+ }
81
+ return this.agentById.get(target);
82
+ }
83
+
67
84
  // ── Multi-device sync helpers ──────────────────────────────────
68
85
 
69
86
  private addSessionWatcher(sessionId: string, nodeId: string) {
@@ -271,13 +288,7 @@ export class HandoffManager {
271
288
  const { id, from, payload } = frame;
272
289
 
273
290
  // Find matching local agent
274
- const agent = this.config.agents.find((a) => {
275
- if (payload.target.startsWith("tags:")) {
276
- const tag = payload.target.slice(5);
277
- return a.tags.includes(tag);
278
- }
279
- return a.id === payload.target;
280
- });
291
+ const agent = this.findLocalAgent(payload.target);
281
292
 
282
293
  if (!agent) {
283
294
  this.peerManager.sendTo(from, {
@@ -477,6 +488,8 @@ export class HandoffManager {
477
488
  const decoder = new TextDecoder();
478
489
  const chunks: string[] = [];
479
490
  let buffer = "";
491
+ // Cache active entry lookup outside the hot loop — stable during the stream
492
+ const activeEntry = this.active.get(handoffId);
480
493
 
481
494
  try {
482
495
  while (true) {
@@ -510,7 +523,6 @@ export class HandoffManager {
510
523
 
511
524
  // Broadcast progress to mobile nodes (throttled, detail is just
512
525
  // a heartbeat — don't send token-level deltas as they're meaningless fragments)
513
- const activeEntry = this.active.get(handoffId);
514
526
  if (activeEntry) {
515
527
  this.taskActivity.broadcast(
516
528
  handoffId, "handoff", "progress", activeEntry.agent, activeEntry.startedAt,
@@ -605,6 +617,7 @@ export class HandoffManager {
605
617
 
606
618
  // If canceled during input_required, no runAgentTurn is running to clean up
607
619
  if (wasInputRequired) {
620
+ this.sessionWatchers.delete(entry.sessionId);
608
621
  this.active.delete(frame.id);
609
622
  }
610
623
 
@@ -257,9 +257,18 @@ export class HealthTracker {
257
257
  // Build timeline for each node (including self)
258
258
  const nodes: NodeTimeline[] = [];
259
259
 
260
+ // Collect nodeIds that have ever had peer_online events (actually connected)
261
+ const everConnected = new Set<string>();
262
+ for (const [, entry] of Object.entries(this.doc.nodes)) {
263
+ for (const ev of entry.events) {
264
+ if (ev.type === "peer_online" && ev.peer) everConnected.add(ev.peer);
265
+ }
266
+ }
267
+
260
268
  for (const [nodeId, entry] of Object.entries(this.doc.nodes)) {
261
- // For self, use start/stop events to determine uptime
262
- // For other nodes, use peer_online/peer_offline from the observing node
269
+ // Remote nodes: must have successfully connected at some point
270
+ if (nodeId !== this.nodeId && !everConnected.has(nodeId)) continue;
271
+
263
272
  const timeline = this.buildNodeTimeline(
264
273
  nodeId,
265
274
  entry,
@@ -269,7 +278,10 @@ export class HealthTracker {
269
278
  bucketCount,
270
279
  gaps,
271
280
  );
272
- if (timeline) nodes.push(timeline);
281
+ if (!timeline) continue;
282
+ // Hide remote nodes that were offline for the entire requested range
283
+ if (nodeId !== this.nodeId && timeline.uptimeRatio === 0) continue;
284
+ nodes.push(timeline);
273
285
  }
274
286
 
275
287
  return {
@@ -345,19 +357,36 @@ export class HealthTracker {
345
357
  startTs: number,
346
358
  endTs: number,
347
359
  ): Array<[number, number]> {
348
- const intervals: Array<[number, number]> = [];
349
-
350
360
  if (nodeId === this.nodeId) {
351
361
  // Self: start/stop events define uptime
352
- // But we're looking at all nodes' data, so check if this nodeId
353
- // has start/stop events (each node writes its own start/stop)
354
362
  return this.buildSelfIntervals(events, startTs, endTs);
355
363
  }
356
364
 
357
- // For remote nodes: gather peer_online/peer_offline events from all observer nodes
358
- // We have the CRDT doc with all nodes' events merged
359
- // Look through ALL nodes' events for peer_online/peer_offline referencing this nodeId
360
- return this.buildPeerIntervals(nodeId, startTs, endTs);
365
+ // For remote nodes: use BOTH self-reported start/stop intervals AND
366
+ // peer_online/peer_offline observations, then merge for best accuracy.
367
+ // Self-reported intervals are the primary signal (the node knows when
368
+ // it was running); peer observations supplement for relay peers or
369
+ // when CRDT sync hasn't propagated the remote node's own events.
370
+ const selfIntervals = this.buildSelfIntervals(events, startTs, endTs);
371
+ const peerIntervals = this.buildPeerIntervals(nodeId, startTs, endTs);
372
+ return this.mergeIntervals([...selfIntervals, ...peerIntervals]);
373
+ }
374
+
375
+ /** Merge overlapping intervals into a sorted, non-overlapping set. */
376
+ private mergeIntervals(intervals: Array<[number, number]>): Array<[number, number]> {
377
+ if (intervals.length <= 1) return intervals;
378
+ intervals.sort((a, b) => a[0] - b[0]);
379
+ const merged: Array<[number, number]> = [intervals[0]!];
380
+ for (let i = 1; i < intervals.length; i++) {
381
+ const prev = merged[merged.length - 1]!;
382
+ const cur = intervals[i]!;
383
+ if (cur[0] <= prev[1]) {
384
+ prev[1] = Math.max(prev[1], cur[1]);
385
+ } else {
386
+ merged.push(cur);
387
+ }
388
+ }
389
+ return merged;
361
390
  }
362
391
 
363
392
  private buildSelfIntervals(