/ core / graph / live_render.py
live_render.py
  1  """
  2  Live Graph Renderer for Sovereign OS
  3  
  4  Generates and updates Mermaid graphs in real-time based on
  5  GRAPH-STATE.md and thread activity.
  6  
  7  The output is a Mermaid-compatible markdown file that Obsidian
  8  can render as a live updating graph.
  9  """
 10  
 11  import re
 12  import logging
 13  from pathlib import Path
 14  from dataclasses import dataclass, field
 15  from typing import Dict, List, Set, Optional, Tuple
 16  from datetime import datetime
 17  
 18  logger = logging.getLogger(__name__)
 19  
 20  
 21  @dataclass
 22  class GraphNode:
 23      """A node in the knowledge graph."""
 24      
 25      id: str
 26      label: str
 27      node_type: str  # axiom, protocol, thread, concept, artifact
 28      connections: Set[str] = field(default_factory=set)
 29      weight: float = 1.0
 30      created: Optional[datetime] = None
 31      
 32      @property
 33      def style(self) -> str:
 34          """Get Mermaid style based on node type."""
 35          styles = {
 36              "axiom": "fill:#FF6B6B",      # Red
 37              "protocol": "fill:#4ECDC4",    # Teal
 38              "thread": "fill:#95E1D3",      # Green
 39              "concept": "fill:#F38181",     # Pink
 40              "artifact": "fill:#FCE38A",    # Yellow
 41          }
 42          return styles.get(self.node_type, "fill:#DDD")
 43  
 44  
 45  @dataclass
 46  class GraphEdge:
 47      """An edge in the knowledge graph."""
 48      
 49      source: str
 50      target: str
 51      edge_type: str  # depends_on, enables, blocks, resonates_with
 52      weight: float = 1.0
 53      
 54      @property
 55      def style(self) -> str:
 56          """Get Mermaid edge style."""
 57          if self.edge_type == "blocks":
 58              return "stroke:#FF0000,stroke-width:2px"
 59          elif self.edge_type == "resonates_with":
 60              return "stroke:#00FF00,stroke-width:2px,stroke-dasharray: 5 5"
 61          elif self.edge_type == "depends_on":
 62              return "stroke:#0066FF,stroke-width:2px"
 63          return ""
 64  
 65  
 66  class LiveGraphRenderer:
 67      """
 68      Renders knowledge graphs as Mermaid markdown.
 69      
 70      Watches GRAPH-STATE.md and produces LIVE-GRAPH.md with
 71      real-time updates.
 72      """
 73      
 74      def __init__(self, sessions_dir: Path):
 75          self.sessions_dir = Path(sessions_dir)
 76          self._nodes: Dict[str, GraphNode] = {}
 77          self._edges: List[GraphEdge] = []
 78      
 79      def load_from_graph_state(self) -> None:
 80          """Load graph from GRAPH-STATE.md."""
 81          graph_state_path = self.sessions_dir / "GRAPH-STATE.md"
 82          
 83          if not graph_state_path.exists():
 84              logger.warning(f"GRAPH-STATE.md not found at {graph_state_path}")
 85              return
 86          
 87          content = graph_state_path.read_text(encoding="utf-8")
 88          self._parse_graph_state(content)
 89      
 90      def _parse_graph_state(self, content: str) -> None:
 91          """Parse GRAPH-STATE.md and extract nodes/edges."""
 92          # Extract nodes from tables
 93          self._extract_nodes_from_tables(content)
 94          
 95          # Extract edges from dependency map
 96          self._extract_edges_from_content(content)
 97      
 98      def _extract_nodes_from_tables(self, content: str) -> None:
 99          """Extract nodes from markdown tables in GRAPH-STATE."""
100          # Pattern for table rows
101          table_row = re.compile(r"\|\s*`([^`]+)`\s*\|\s*\[\[([^\]]+)\]\]")
102          
103          for match in table_row.finditer(content):
104              node_id = match.group(1)
105              label = match.group(2)
106              
107              # Determine type from ID prefix
108              if node_id.startswith("axiom"):
109                  node_type = "axiom"
110              elif node_id.startswith("proto"):
111                  node_type = "protocol"
112              elif node_id.startswith("thread"):
113                  node_type = "thread"
114              elif node_id.startswith("ui"):
115                  node_type = "artifact"
116              else:
117                  node_type = "concept"
118              
119              self._nodes[node_id] = GraphNode(
120                  id=node_id,
121                  label=label,
122                  node_type=node_type,
123                  created=datetime.now()
124              )
125      
126      def _extract_edges_from_content(self, content: str) -> None:
127          """Extract edges from content patterns."""
128          # Look for dependency patterns like "proto-017 → proto-018"
129          arrow_pattern = re.compile(r"`([^`]+)`\s*[→\-]+>\s*`([^`]+)`")
130          
131          for match in arrow_pattern.finditer(content):
132              source = match.group(1)
133              target = match.group(2)
134              
135              self._edges.append(GraphEdge(
136                  source=source,
137                  target=target,
138                  edge_type="depends_on"
139              ))
140              
141              # Update node connections
142              if source in self._nodes:
143                  self._nodes[source].connections.add(target)
144      
145      def add_node(self, node: GraphNode) -> None:
146          """Add or update a node."""
147          self._nodes[node.id] = node
148      
149      def add_edge(self, edge: GraphEdge) -> None:
150          """Add an edge."""
151          self._edges.append(edge)
152          
153          # Update node connections
154          if edge.source in self._nodes:
155              self._nodes[edge.source].connections.add(edge.target)
156      
157      def add_resonance_edge(self, thread1: str, thread2: str, pattern: str) -> None:
158          """Add a resonance edge between threads."""
159          self._edges.append(GraphEdge(
160              source=f"thread/{thread1}",
161              target=f"thread/{thread2}",
162              edge_type="resonates_with",
163              weight=0.8
164          ))
165      
166      def render_mermaid(self) -> str:
167          """Render the graph as Mermaid markdown."""
168          lines = ["```mermaid", "graph TD"]
169          
170          # Group nodes by type
171          by_type: Dict[str, List[GraphNode]] = {}
172          for node in self._nodes.values():
173              if node.node_type not in by_type:
174                  by_type[node.node_type] = []
175              by_type[node.node_type].append(node)
176          
177          # Render subgraphs by type
178          type_labels = {
179              "axiom": "AXIOMS",
180              "protocol": "PROTOCOLS",
181              "thread": "THREADS",
182              "concept": "CONCEPTS",
183              "artifact": "ARTIFACTS"
184          }
185          
186          for node_type, nodes in by_type.items():
187              if nodes:
188                  label = type_labels.get(node_type, node_type.upper())
189                  lines.append(f"    subgraph {label}")
190                  for node in nodes:
191                      safe_id = self._safe_mermaid_id(node.id)
192                      safe_label = node.label.replace('"', "'")
193                      lines.append(f'        {safe_id}["{safe_label}"]')
194                  lines.append("    end")
195          
196          lines.append("")
197          
198          # Render edges
199          for edge in self._edges:
200              source_id = self._safe_mermaid_id(edge.source)
201              target_id = self._safe_mermaid_id(edge.target)
202              
203              if edge.edge_type == "resonates_with":
204                  lines.append(f"    {source_id} -.-|resonates| {target_id}")
205              elif edge.edge_type == "blocks":
206                  lines.append(f"    {source_id} -->|blocks| {target_id}")
207              else:
208                  lines.append(f"    {source_id} --> {target_id}")
209          
210          lines.append("")
211          
212          # Add styles
213          lines.append("    %% Styling")
214          for node in self._nodes.values():
215              safe_id = self._safe_mermaid_id(node.id)
216              lines.append(f"    style {safe_id} {node.style}")
217          
218          lines.append("```")
219          
220          return "\n".join(lines)
221      
222      def _safe_mermaid_id(self, node_id: str) -> str:
223          """Convert node ID to safe Mermaid identifier."""
224          # Replace characters that Mermaid doesn't like
225          safe = re.sub(r"[^a-zA-Z0-9_]", "_", node_id)
226          return safe
227      
228      def render_full_markdown(self) -> str:
229          """Render complete LIVE-GRAPH.md file."""
230          now = datetime.now()
231          mermaid = self.render_mermaid()
232          
233          # Build node registry table
234          node_rows = []
235          for node in sorted(self._nodes.values(), key=lambda n: n.id):
236              node_rows.append(
237                  f"| {node.id} | {node.node_type} | {len(node.connections)} |"
238              )
239          node_table = "\n".join(node_rows) if node_rows else "| (no nodes) | - | - |"
240          
241          # Build edge count
242          edge_count = len(self._edges)
243          
244          return f"""# Live Graph - Real-Time Visualization
245  
246  *Auto-generated by Mission Control*
247  
248  **Updated:** {now.strftime('%Y-%m-%d %H:%M')}
249  **Nodes:** {len(self._nodes)}
250  **Edges:** {edge_count}
251  
252  ---
253  
254  ## Live Mermaid Graph
255  
256  {mermaid}
257  
258  ---
259  
260  ## Node Registry
261  
262  | ID | Type | Connections |
263  |----|------|-------------|
264  {node_table}
265  
266  ---
267  
268  ## How This Updates
269  
270  This file is regenerated by Mission Control when:
271  - New nodes are created in any thread
272  - New edges (dependencies, resonances) are detected
273  - FO checkpoints update thread state
274  
275  Open in Obsidian to see live graph rendering.
276  
277  ---
278  
279  *Live Graph | Auto-generated by Mission Control*
280  """
281      
282      def write(self, output_path: Optional[Path] = None) -> Path:
283          """Write the rendered graph to file."""
284          if output_path is None:
285              output_path = self.sessions_dir / "LIVE-GRAPH.md"
286          
287          content = self.render_full_markdown()
288          output_path.write_text(content, encoding="utf-8")
289          
290          logger.info(f"Live graph written to {output_path}")
291          return output_path
292  
293  
294  # CLI for testing
295  if __name__ == "__main__":
296      import sys
297      
298      sessions_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("./sessions")
299      
300      renderer = LiveGraphRenderer(sessions_dir)
301      renderer.load_from_graph_state()
302      
303      # Add some test nodes
304      renderer.add_node(GraphNode(
305          id="test-node-1",
306          label="Test Node 1",
307          node_type="concept"
308      ))
309      
310      output = renderer.write()
311      print(f"Graph written to {output}")