htmlgraph 0.25.0__py3-none-any.whl → 0.26.1__py3-none-any.whl

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.
Files changed (32) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/main.py +193 -45
  3. htmlgraph/api/templates/dashboard.html +11 -0
  4. htmlgraph/api/templates/partials/activity-feed.html +458 -8
  5. htmlgraph/dashboard.html +41 -0
  6. htmlgraph/db/schema.py +254 -4
  7. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  8. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  9. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  10. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  11. htmlgraph/hooks/concurrent_sessions.py +208 -0
  12. htmlgraph/hooks/context.py +57 -10
  13. htmlgraph/hooks/drift_handler.py +24 -20
  14. htmlgraph/hooks/event_tracker.py +204 -177
  15. htmlgraph/hooks/orchestrator.py +6 -4
  16. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  17. htmlgraph/hooks/pretooluse.py +3 -6
  18. htmlgraph/hooks/prompt_analyzer.py +14 -25
  19. htmlgraph/hooks/session_handler.py +123 -69
  20. htmlgraph/hooks/state_manager.py +7 -4
  21. htmlgraph/hooks/validator.py +15 -11
  22. htmlgraph/orchestration/headless_spawner.py +322 -15
  23. htmlgraph/orchestration/live_events.py +377 -0
  24. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/dashboard.html +41 -0
  25. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +32 -27
  27. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
  28. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  29. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  30. {htmlgraph-0.25.0.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  31. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
  32. {htmlgraph-0.25.0.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
@@ -3,10 +3,12 @@
3
3
  import json
4
4
  import os
5
5
  import subprocess
6
+ import time
6
7
  from dataclasses import dataclass
7
8
  from typing import TYPE_CHECKING
8
9
 
9
10
  if TYPE_CHECKING:
11
+ from htmlgraph.orchestration.live_events import LiveEventPublisher
10
12
  from htmlgraph.sdk import SDK
11
13
 
12
14
 
@@ -63,7 +65,92 @@ class HeadlessSpawner:
63
65
 
64
66
  def __init__(self) -> None:
65
67
  """Initialize spawner."""
66
- pass
68
+ self._live_publisher: LiveEventPublisher | None = None
69
+
70
+ def _get_live_publisher(self) -> "LiveEventPublisher | None":
71
+ """
72
+ Get LiveEventPublisher instance for real-time WebSocket streaming.
73
+
74
+ Returns None if publisher unavailable (optional dependency).
75
+ """
76
+ if self._live_publisher is None:
77
+ try:
78
+ from htmlgraph.orchestration.live_events import LiveEventPublisher
79
+
80
+ self._live_publisher = LiveEventPublisher()
81
+ except Exception:
82
+ # Live events are optional
83
+ pass
84
+ return self._live_publisher
85
+
86
+ def _publish_live_event(
87
+ self,
88
+ event_type: str,
89
+ spawner_type: str,
90
+ **kwargs: str | int | float | bool | None,
91
+ ) -> None:
92
+ """
93
+ Publish a live event for WebSocket streaming.
94
+
95
+ Silently fails if publisher unavailable (optional feature).
96
+ """
97
+ publisher = self._get_live_publisher()
98
+ if publisher is None:
99
+ return
100
+
101
+ parent_event_id = os.getenv("HTMLGRAPH_PARENT_EVENT")
102
+
103
+ try:
104
+ if event_type == "spawner_start":
105
+ publisher.spawner_start(
106
+ spawner_type=spawner_type,
107
+ prompt=str(kwargs.get("prompt", "")),
108
+ parent_event_id=parent_event_id,
109
+ model=str(kwargs.get("model", "")) if kwargs.get("model") else None,
110
+ )
111
+ elif event_type == "spawner_phase":
112
+ progress_val = kwargs.get("progress")
113
+ publisher.spawner_phase(
114
+ spawner_type=spawner_type,
115
+ phase=str(kwargs.get("phase", "executing")),
116
+ progress=int(progress_val) if progress_val is not None else None,
117
+ details=str(kwargs.get("details", ""))
118
+ if kwargs.get("details")
119
+ else None,
120
+ parent_event_id=parent_event_id,
121
+ )
122
+ elif event_type == "spawner_complete":
123
+ duration_val = kwargs.get("duration")
124
+ tokens_val = kwargs.get("tokens")
125
+ publisher.spawner_complete(
126
+ spawner_type=spawner_type,
127
+ success=bool(kwargs.get("success", False)),
128
+ duration_seconds=float(duration_val)
129
+ if duration_val is not None
130
+ else None,
131
+ response_preview=str(kwargs.get("response", ""))[:200]
132
+ if kwargs.get("response")
133
+ else None,
134
+ tokens_used=int(tokens_val) if tokens_val is not None else None,
135
+ error=str(kwargs.get("error", "")) if kwargs.get("error") else None,
136
+ parent_event_id=parent_event_id,
137
+ )
138
+ elif event_type == "spawner_tool_use":
139
+ publisher.spawner_tool_use(
140
+ spawner_type=spawner_type,
141
+ tool_name=str(kwargs.get("tool_name", "unknown")),
142
+ parent_event_id=parent_event_id,
143
+ )
144
+ elif event_type == "spawner_message":
145
+ publisher.spawner_message(
146
+ spawner_type=spawner_type,
147
+ message=str(kwargs.get("message", "")),
148
+ role=str(kwargs.get("role", "assistant")),
149
+ parent_event_id=parent_event_id,
150
+ )
151
+ except Exception:
152
+ # Live events should never break spawner execution
153
+ pass
67
154
 
68
155
  def _get_sdk(self) -> "SDK | None":
69
156
  """
@@ -397,6 +484,15 @@ class HeadlessSpawner:
397
484
  if track_in_htmlgraph:
398
485
  sdk = self._get_sdk()
399
486
 
487
+ # Publish live event: spawner starting
488
+ self._publish_live_event(
489
+ "spawner_start",
490
+ "gemini",
491
+ prompt=prompt,
492
+ model=model,
493
+ )
494
+ start_time = time.time()
495
+
400
496
  try:
401
497
  # Build command based on tested pattern from spike spk-4029eef3
402
498
  cmd = ["gemini", "-p", prompt, "--output-format", output_format]
@@ -425,6 +521,14 @@ class HeadlessSpawner:
425
521
  # Tracking failure should not break execution
426
522
  pass
427
523
 
524
+ # Publish live event: executing
525
+ self._publish_live_event(
526
+ "spawner_phase",
527
+ "gemini",
528
+ phase="executing",
529
+ details="Running Gemini CLI",
530
+ )
531
+
428
532
  # Execute with timeout and stderr redirection
429
533
  # Note: Cannot use capture_output with stderr parameter
430
534
  result = subprocess.run(
@@ -435,8 +539,24 @@ class HeadlessSpawner:
435
539
  timeout=timeout,
436
540
  )
437
541
 
542
+ # Publish live event: processing response
543
+ self._publish_live_event(
544
+ "spawner_phase",
545
+ "gemini",
546
+ phase="processing",
547
+ details="Parsing Gemini response",
548
+ )
549
+
438
550
  # Check for command execution errors
439
551
  if result.returncode != 0:
552
+ duration = time.time() - start_time
553
+ self._publish_live_event(
554
+ "spawner_complete",
555
+ "gemini",
556
+ success=False,
557
+ duration=duration,
558
+ error=f"CLI failed with exit code {result.returncode}",
559
+ )
440
560
  return AIResult(
441
561
  success=False,
442
562
  response="",
@@ -455,16 +575,20 @@ class HeadlessSpawner:
455
575
  # Only use stream-json parsing if we got valid events
456
576
  if tracked_events:
457
577
  # For stream-json, we need to extract response differently
458
- # Look for the last message or result event
578
+ # Collect all assistant message content, then check result
459
579
  response_text = ""
460
580
  for event in tracked_events:
461
- if event.get("type") == "result":
462
- response_text = event.get("response", "")
463
- break
464
- elif event.get("type") == "message":
465
- content = event.get("content", "")
466
- if content:
467
- response_text = content
581
+ if event.get("type") == "message":
582
+ # Only collect assistant messages
583
+ if event.get("role") == "assistant":
584
+ content = event.get("content", "")
585
+ if content:
586
+ response_text += content
587
+ elif event.get("type") == "result":
588
+ # Result event may have response field (override if present)
589
+ if "response" in event and event["response"]:
590
+ response_text = event["response"]
591
+ # Don't break - we've already collected messages
468
592
 
469
593
  # Token usage from stats in result event
470
594
  tokens = None
@@ -481,6 +605,16 @@ class HeadlessSpawner:
481
605
  tokens = total_tokens if total_tokens > 0 else None
482
606
  break
483
607
 
608
+ # Publish live event: complete
609
+ duration = time.time() - start_time
610
+ self._publish_live_event(
611
+ "spawner_complete",
612
+ "gemini",
613
+ success=True,
614
+ duration=duration,
615
+ response=response_text,
616
+ tokens=tokens,
617
+ )
484
618
  return AIResult(
485
619
  success=True,
486
620
  response=response_text,
@@ -498,6 +632,14 @@ class HeadlessSpawner:
498
632
  try:
499
633
  output = json.loads(result.stdout)
500
634
  except json.JSONDecodeError as e:
635
+ duration = time.time() - start_time
636
+ self._publish_live_event(
637
+ "spawner_complete",
638
+ "gemini",
639
+ success=False,
640
+ duration=duration,
641
+ error=f"Failed to parse JSON: {e}",
642
+ )
501
643
  return AIResult(
502
644
  success=False,
503
645
  response="",
@@ -521,6 +663,16 @@ class HeadlessSpawner:
521
663
  total_tokens += model_tokens
522
664
  tokens = total_tokens if total_tokens > 0 else None
523
665
 
666
+ # Publish live event: complete
667
+ duration = time.time() - start_time
668
+ self._publish_live_event(
669
+ "spawner_complete",
670
+ "gemini",
671
+ success=True,
672
+ duration=duration,
673
+ response=response_text,
674
+ tokens=tokens,
675
+ )
524
676
  return AIResult(
525
677
  success=True,
526
678
  response=response_text,
@@ -531,6 +683,14 @@ class HeadlessSpawner:
531
683
  )
532
684
 
533
685
  except subprocess.TimeoutExpired as e:
686
+ duration = time.time() - start_time
687
+ self._publish_live_event(
688
+ "spawner_complete",
689
+ "gemini",
690
+ success=False,
691
+ duration=duration,
692
+ error=f"Timed out after {timeout} seconds",
693
+ )
534
694
  return AIResult(
535
695
  success=False,
536
696
  response="",
@@ -545,6 +705,14 @@ class HeadlessSpawner:
545
705
  tracked_events=tracked_events,
546
706
  )
547
707
  except FileNotFoundError:
708
+ duration = time.time() - start_time
709
+ self._publish_live_event(
710
+ "spawner_complete",
711
+ "gemini",
712
+ success=False,
713
+ duration=duration,
714
+ error="CLI not found",
715
+ )
548
716
  return AIResult(
549
717
  success=False,
550
718
  response="",
@@ -554,6 +722,14 @@ class HeadlessSpawner:
554
722
  tracked_events=tracked_events,
555
723
  )
556
724
  except Exception as e:
725
+ duration = time.time() - start_time
726
+ self._publish_live_event(
727
+ "spawner_complete",
728
+ "gemini",
729
+ success=False,
730
+ duration=duration,
731
+ error=str(e),
732
+ )
557
733
  return AIResult(
558
734
  success=False,
559
735
  response="",
@@ -608,6 +784,15 @@ class HeadlessSpawner:
608
784
  if track_in_htmlgraph and output_json:
609
785
  sdk = self._get_sdk()
610
786
 
787
+ # Publish live event: spawner starting
788
+ self._publish_live_event(
789
+ "spawner_start",
790
+ "codex",
791
+ prompt=prompt,
792
+ model=model,
793
+ )
794
+ start_time = time.time()
795
+
611
796
  cmd = ["codex", "exec"]
612
797
 
613
798
  if output_json:
@@ -674,6 +859,14 @@ class HeadlessSpawner:
674
859
  pass
675
860
 
676
861
  try:
862
+ # Publish live event: executing
863
+ self._publish_live_event(
864
+ "spawner_phase",
865
+ "codex",
866
+ phase="executing",
867
+ details="Running Codex CLI",
868
+ )
869
+
677
870
  result = subprocess.run(
678
871
  cmd,
679
872
  stdout=subprocess.PIPE,
@@ -682,13 +875,31 @@ class HeadlessSpawner:
682
875
  timeout=timeout,
683
876
  )
684
877
 
878
+ # Publish live event: processing
879
+ self._publish_live_event(
880
+ "spawner_phase",
881
+ "codex",
882
+ phase="processing",
883
+ details="Parsing Codex response",
884
+ )
885
+
685
886
  if not output_json:
686
887
  # Plain text mode - return as-is
888
+ duration = time.time() - start_time
889
+ success = result.returncode == 0
890
+ self._publish_live_event(
891
+ "spawner_complete",
892
+ "codex",
893
+ success=success,
894
+ duration=duration,
895
+ response=result.stdout.strip()[:200] if success else None,
896
+ error="Command failed" if not success else None,
897
+ )
687
898
  return AIResult(
688
- success=result.returncode == 0,
899
+ success=success,
689
900
  response=result.stdout.strip(),
690
901
  tokens_used=None,
691
- error=None if result.returncode == 0 else "Command failed",
902
+ error=None if success else "Command failed",
692
903
  raw_output=result.stdout,
693
904
  tracked_events=tracked_events,
694
905
  )
@@ -735,11 +946,23 @@ class HeadlessSpawner:
735
946
  # Sum all token types
736
947
  tokens = sum(usage.values())
737
948
 
949
+ # Publish live event: complete
950
+ duration = time.time() - start_time
951
+ success = result.returncode == 0
952
+ self._publish_live_event(
953
+ "spawner_complete",
954
+ "codex",
955
+ success=success,
956
+ duration=duration,
957
+ response=response[:200] if response else None,
958
+ tokens=tokens,
959
+ error="Command failed" if not success else None,
960
+ )
738
961
  return AIResult(
739
- success=result.returncode == 0,
962
+ success=success,
740
963
  response=response or "",
741
964
  tokens_used=tokens,
742
- error=None if result.returncode == 0 else "Command failed",
965
+ error=None if success else "Command failed",
743
966
  raw_output={
744
967
  "events": events,
745
968
  "parse_errors": parse_errors if parse_errors else None,
@@ -748,6 +971,14 @@ class HeadlessSpawner:
748
971
  )
749
972
 
750
973
  except FileNotFoundError:
974
+ duration = time.time() - start_time
975
+ self._publish_live_event(
976
+ "spawner_complete",
977
+ "codex",
978
+ success=False,
979
+ duration=duration,
980
+ error="CLI not found",
981
+ )
751
982
  return AIResult(
752
983
  success=False,
753
984
  response="",
@@ -757,6 +988,14 @@ class HeadlessSpawner:
757
988
  tracked_events=tracked_events,
758
989
  )
759
990
  except subprocess.TimeoutExpired as e:
991
+ duration = time.time() - start_time
992
+ self._publish_live_event(
993
+ "spawner_complete",
994
+ "codex",
995
+ success=False,
996
+ duration=duration,
997
+ error=f"Timed out after {timeout} seconds",
998
+ )
760
999
  return AIResult(
761
1000
  success=False,
762
1001
  response="",
@@ -771,6 +1010,14 @@ class HeadlessSpawner:
771
1010
  tracked_events=tracked_events,
772
1011
  )
773
1012
  except Exception as e:
1013
+ duration = time.time() - start_time
1014
+ self._publish_live_event(
1015
+ "spawner_complete",
1016
+ "codex",
1017
+ success=False,
1018
+ duration=duration,
1019
+ error=str(e),
1020
+ )
774
1021
  return AIResult(
775
1022
  success=False,
776
1023
  response="",
@@ -809,6 +1056,14 @@ class HeadlessSpawner:
809
1056
  if track_in_htmlgraph:
810
1057
  sdk = self._get_sdk()
811
1058
 
1059
+ # Publish live event: spawner starting
1060
+ self._publish_live_event(
1061
+ "spawner_start",
1062
+ "copilot",
1063
+ prompt=prompt,
1064
+ )
1065
+ start_time = time.time()
1066
+
812
1067
  cmd = ["copilot", "-p", prompt]
813
1068
 
814
1069
  # Add allow all tools flag
@@ -838,6 +1093,14 @@ class HeadlessSpawner:
838
1093
  pass
839
1094
 
840
1095
  try:
1096
+ # Publish live event: executing
1097
+ self._publish_live_event(
1098
+ "spawner_phase",
1099
+ "copilot",
1100
+ phase="executing",
1101
+ details="Running Copilot CLI",
1102
+ )
1103
+
841
1104
  result = subprocess.run(
842
1105
  cmd,
843
1106
  capture_output=True,
@@ -845,6 +1108,14 @@ class HeadlessSpawner:
845
1108
  timeout=timeout,
846
1109
  )
847
1110
 
1111
+ # Publish live event: processing
1112
+ self._publish_live_event(
1113
+ "spawner_phase",
1114
+ "copilot",
1115
+ phase="processing",
1116
+ details="Parsing Copilot response",
1117
+ )
1118
+
848
1119
  # Parse output: response is before stats block
849
1120
  lines = result.stdout.split("\n")
850
1121
 
@@ -874,16 +1145,36 @@ class HeadlessSpawner:
874
1145
  prompt, response, sdk
875
1146
  )
876
1147
 
1148
+ # Publish live event: complete
1149
+ duration = time.time() - start_time
1150
+ success = result.returncode == 0
1151
+ self._publish_live_event(
1152
+ "spawner_complete",
1153
+ "copilot",
1154
+ success=success,
1155
+ duration=duration,
1156
+ response=response[:200] if response else None,
1157
+ tokens=tokens,
1158
+ error=result.stderr if not success else None,
1159
+ )
877
1160
  return AIResult(
878
- success=result.returncode == 0,
1161
+ success=success,
879
1162
  response=response,
880
1163
  tokens_used=tokens,
881
- error=None if result.returncode == 0 else result.stderr,
1164
+ error=None if success else result.stderr,
882
1165
  raw_output=result.stdout,
883
1166
  tracked_events=tracked_events,
884
1167
  )
885
1168
 
886
1169
  except FileNotFoundError:
1170
+ duration = time.time() - start_time
1171
+ self._publish_live_event(
1172
+ "spawner_complete",
1173
+ "copilot",
1174
+ success=False,
1175
+ duration=duration,
1176
+ error="CLI not found",
1177
+ )
887
1178
  return AIResult(
888
1179
  success=False,
889
1180
  response="",
@@ -893,6 +1184,14 @@ class HeadlessSpawner:
893
1184
  tracked_events=tracked_events,
894
1185
  )
895
1186
  except subprocess.TimeoutExpired as e:
1187
+ duration = time.time() - start_time
1188
+ self._publish_live_event(
1189
+ "spawner_complete",
1190
+ "copilot",
1191
+ success=False,
1192
+ duration=duration,
1193
+ error=f"Timed out after {timeout} seconds",
1194
+ )
896
1195
  return AIResult(
897
1196
  success=False,
898
1197
  response="",
@@ -907,6 +1206,14 @@ class HeadlessSpawner:
907
1206
  tracked_events=tracked_events,
908
1207
  )
909
1208
  except Exception as e:
1209
+ duration = time.time() - start_time
1210
+ self._publish_live_event(
1211
+ "spawner_complete",
1212
+ "copilot",
1213
+ success=False,
1214
+ duration=duration,
1215
+ error=str(e),
1216
+ )
910
1217
  return AIResult(
911
1218
  success=False,
912
1219
  response="",