daily_graph_synthesis.py
1 #!/usr/bin/env python3 2 """ 3 Sovereign OS - Daily Graph Synthesis 4 ===================================== 5 6 End-of-day synthesis of how your work interacted with the graph. 7 Uses Moses escalation: high-confidence auto-ships, low-confidence escalates to you. 8 9 This is what surfaces to your daily note in Obsidian. 10 11 Usage: 12 python3 scripts/daily_graph_synthesis.py # Generate today's synthesis 13 python3 scripts/daily_graph_synthesis.py --date 2026-01-15 # Specific date 14 python3 scripts/daily_graph_synthesis.py --obsidian # Open in Obsidian 15 16 Output: sessions/daily-notes/YYYY-MM-DD.md 17 """ 18 19 import json 20 import sys 21 import os 22 from pathlib import Path 23 from datetime import datetime, timedelta 24 from dataclasses import dataclass, field 25 from typing import List, Dict, Optional, Set, Tuple 26 from collections import defaultdict 27 28 # Paths 29 SOVEREIGN_OS = Path(__file__).parent.parent 30 SESSIONS_DIR = SOVEREIGN_OS / "sessions" 31 DAILY_NOTES_DIR = SESSIONS_DIR / "daily-notes" 32 GRAPH_DATA = Path.home() / ".sovereign" / "graph-data.json" 33 REPLAY_EXPORT = Path.home() / ".sovereign" / "replay-export.json" 34 SESSION_REPORT_STATE = Path.home() / ".sovereign" / "session-report-state.json" 35 DAILY_SYNTHESIS = SESSIONS_DIR / "DAILY-SYNTHESIS.md" 36 37 # Add core to path for shared modules 38 sys.path.insert(0, str(SOVEREIGN_OS / "core")) 39 from axioms import AXIOM_FIELDS 40 41 42 @dataclass 43 class GraphDelta: 44 """Changes to the graph today.""" 45 new_nodes: List[Dict] = field(default_factory=list) 46 new_edges: List[Dict] = field(default_factory=list) 47 strengthened_edges: List[Dict] = field(default_factory=list) 48 new_connections: List[Dict] = field(default_factory=list) # Nodes that became connected 49 50 51 @dataclass 52 class Insight: 53 """An insight that should surface.""" 54 content: str 55 confidence: float # 0-1 56 impact: float # 0-1 (how much it affects the graph) 57 axiom: Optional[str] = None 58 source: str = "" 59 action: str = "SHIP" # SHIP, FLAG, or ESCALATE 60 61 62 @dataclass 63 class DailySynthesis: 64 """The daily synthesis that surfaces to you.""" 65 date: str 66 67 # What happened 68 sessions_count: int = 0 69 insights_captured: int = 0 70 decisions_made: int = 0 71 72 # Graph changes 73 delta: GraphDelta = field(default_factory=GraphDelta) 74 75 # Moses escalation buckets 76 shipped: List[Insight] = field(default_factory=list) # Auto-added, FYI 77 flagged: List[Insight] = field(default_factory=list) # Worth attention 78 escalated: List[Insight] = field(default_factory=list) # Need your decision 79 80 # Patterns detected 81 gravity_wells: List[str] = field(default_factory=list) # Topics with high activity 82 principle_edges: List[str] = field(default_factory=list) # New edges on principles 83 orphans_connected: List[str] = field(default_factory=list) # Previously isolated now linked 84 85 86 class DailyGraphSynthesizer: 87 """ 88 Synthesizes how today's work interacted with the graph. 89 90 Uses Moses escalation: 91 - SHIP: High confidence + low impact → added silently, noted in synthesis 92 - FLAG: Mixed → worth your attention, explained 93 - ESCALATE: Low confidence + high impact → needs your judgment 94 """ 95 96 def __init__(self, date: str = None): 97 self.date = date or datetime.now().strftime("%Y-%m-%d") 98 self.graph = self._load_graph() 99 self.session_state = self._load_session_state() 100 101 def _load_graph(self) -> Dict: 102 """Load current graph.""" 103 graph = {"nodes": {}, "edges": []} 104 105 if GRAPH_DATA.exists(): 106 try: 107 with open(GRAPH_DATA) as f: 108 data = json.load(f) 109 for node in data.get("nodes", []): 110 graph["nodes"][node["id"]] = node 111 graph["edges"] = data.get("edges", []) 112 except: 113 pass 114 115 if REPLAY_EXPORT.exists(): 116 try: 117 with open(REPLAY_EXPORT) as f: 118 data = json.load(f) 119 for node in data.get("nodes", []): 120 if node["id"] not in graph["nodes"]: 121 graph["nodes"][node["id"]] = node 122 graph["edges"].extend(data.get("edges", [])) 123 except: 124 pass 125 126 return graph 127 128 def _load_session_state(self) -> Dict: 129 """Load session report state.""" 130 if SESSION_REPORT_STATE.exists(): 131 try: 132 with open(SESSION_REPORT_STATE) as f: 133 return json.load(f) 134 except: 135 pass 136 return {} 137 138 def synthesize(self) -> DailySynthesis: 139 """Generate today's synthesis.""" 140 synthesis = DailySynthesis(date=self.date) 141 142 # Count sessions and activity 143 synthesis.sessions_count = self._count_sessions_today() 144 synthesis.insights_captured = len(self.session_state.get("insights", [])) 145 synthesis.decisions_made = len(self.session_state.get("done_items", [])) 146 147 # Analyze graph changes 148 synthesis.delta = self._analyze_graph_changes() 149 150 # Extract and classify insights using Moses escalation 151 insights = self._extract_insights() 152 for insight in insights: 153 action = self._moses_classify(insight) 154 insight.action = action 155 156 if action == "SHIP": 157 synthesis.shipped.append(insight) 158 elif action == "FLAG": 159 synthesis.flagged.append(insight) 160 else: 161 synthesis.escalated.append(insight) 162 163 # Detect patterns 164 synthesis.gravity_wells = self._detect_gravity_wells() 165 synthesis.principle_edges = self._detect_principle_edges() 166 synthesis.orphans_connected = self._find_connected_orphans() 167 168 return synthesis 169 170 def _count_sessions_today(self) -> int: 171 """Count sessions from today.""" 172 # Check JSONL files modified today 173 today = datetime.now().date() 174 count = 0 175 176 claude_projects = Path.home() / ".claude" / "projects" 177 if claude_projects.exists(): 178 for jsonl in claude_projects.glob("**/*.jsonl"): 179 try: 180 mtime = datetime.fromtimestamp(jsonl.stat().st_mtime).date() 181 if mtime == today: 182 count += 1 183 except: 184 pass 185 186 return count 187 188 def _analyze_graph_changes(self) -> GraphDelta: 189 """Analyze what changed in the graph today.""" 190 delta = GraphDelta() 191 192 today = datetime.now().date() 193 194 # Find nodes added today 195 for node_id, node in self.graph["nodes"].items(): 196 created = node.get("created_at", "") 197 if created: 198 try: 199 node_date = datetime.fromisoformat(created.replace("Z", "")).date() 200 if node_date == today: 201 delta.new_nodes.append({ 202 "id": node_id, 203 "content": node.get("content", "")[:100], 204 "type": node.get("node_type", "insight"), 205 "axioms": node.get("axioms", []) 206 }) 207 except: 208 pass 209 210 # Find edges added today 211 for edge in self.graph["edges"]: 212 created = edge.get("created_at", "") 213 if created: 214 try: 215 edge_date = datetime.fromisoformat(created.replace("Z", "")).date() 216 if edge_date == today: 217 delta.new_edges.append({ 218 "source": edge.get("source_id"), 219 "target": edge.get("target_id"), 220 "type": edge.get("edge_type", "relates_to"), 221 "strength": edge.get("strength", 0.5) 222 }) 223 except: 224 pass 225 226 return delta 227 228 def _extract_insights(self) -> List[Insight]: 229 """Extract insights from today's work.""" 230 insights = [] 231 232 # From session report state 233 for item in self.session_state.get("insights", []): 234 insights.append(Insight( 235 content=item.get("description", ""), 236 confidence=0.7, # Default for user-tagged insights 237 impact=0.5, 238 axiom=item.get("principle"), 239 source="session_report" 240 )) 241 242 # From new graph nodes 243 for node in self.graph["nodes"].values(): 244 created = node.get("created_at", "") 245 if created and datetime.now().date().isoformat() in created: 246 # Infer confidence from node type 247 node_type = node.get("node_type", "insight") 248 confidence = { 249 "concept": 0.9, 250 "principle": 0.8, 251 "decision": 0.7, 252 "insight": 0.6, 253 "question": 0.5, 254 }.get(node_type, 0.5) 255 256 # Infer impact from importance 257 impact = node.get("importance", 0.5) 258 259 insights.append(Insight( 260 content=node.get("content", "")[:100], 261 confidence=confidence, 262 impact=impact, 263 axiom=node.get("axioms", [None])[0] if node.get("axioms") else None, 264 source="graph_node" 265 )) 266 267 return insights 268 269 def _moses_classify(self, insight: Insight) -> str: 270 """ 271 Moses escalation classification. 272 273 High confidence + low impact → SHIP (auto-handled) 274 Mixed → FLAG (worth attention) 275 Low confidence + high impact → ESCALATE (need judgment) 276 """ 277 if insight.confidence >= 0.7 and insight.impact <= 0.5: 278 return "SHIP" 279 elif insight.confidence <= 0.4 and insight.impact >= 0.7: 280 return "ESCALATE" 281 else: 282 return "FLAG" 283 284 def _detect_gravity_wells(self) -> List[str]: 285 """Detect topics with high activity today.""" 286 # Count edges per node 287 edge_counts = defaultdict(int) 288 289 today = datetime.now().date() 290 for edge in self.graph["edges"]: 291 created = edge.get("created_at", "") 292 if created and today.isoformat() in created: 293 edge_counts[edge.get("source_id", "")] += 1 294 edge_counts[edge.get("target_id", "")] += 1 295 296 # Top nodes by edge count 297 sorted_nodes = sorted(edge_counts.items(), key=lambda x: x[1], reverse=True) 298 299 wells = [] 300 for node_id, count in sorted_nodes[:5]: 301 if count >= 3: 302 node = self.graph["nodes"].get(node_id, {}) 303 wells.append(f"{node.get('content', node_id)[:50]} ({count} connections)") 304 305 return wells 306 307 def _detect_principle_edges(self) -> List[str]: 308 """Detect new edges that involve principles/axioms.""" 309 edges = [] 310 311 today = datetime.now().date() 312 for edge in self.graph["edges"]: 313 created = edge.get("created_at", "") 314 if created and today.isoformat() in created: 315 # Check if either endpoint has axiom tags 316 source = self.graph["nodes"].get(edge.get("source_id"), {}) 317 target = self.graph["nodes"].get(edge.get("target_id"), {}) 318 319 source_axioms = source.get("axioms", []) 320 target_axioms = target.get("axioms", []) 321 322 if source_axioms or target_axioms: 323 axioms = source_axioms + target_axioms 324 edges.append(f"{axioms[0]}: {source.get('content', '')[:30]} ↔ {target.get('content', '')[:30]}") 325 326 return edges[:5] 327 328 def _find_connected_orphans(self) -> List[str]: 329 """Find nodes that were orphans but got connected today.""" 330 # This would require tracking orphan state over time 331 # For now, return nodes with exactly 1 edge created today (newly connected) 332 connected = [] 333 334 edge_counts_today = defaultdict(int) 335 total_edge_counts = defaultdict(int) 336 337 today = datetime.now().date() 338 for edge in self.graph["edges"]: 339 source = edge.get("source_id", "") 340 target = edge.get("target_id", "") 341 342 total_edge_counts[source] += 1 343 total_edge_counts[target] += 1 344 345 created = edge.get("created_at", "") 346 if created and today.isoformat() in created: 347 edge_counts_today[source] += 1 348 edge_counts_today[target] += 1 349 350 # Nodes where all edges were created today (was orphan, now connected) 351 for node_id, today_count in edge_counts_today.items(): 352 if today_count == total_edge_counts.get(node_id, 0) and today_count >= 1: 353 node = self.graph["nodes"].get(node_id, {}) 354 if node: 355 connected.append(node.get("content", node_id)[:50]) 356 357 return connected[:5] 358 359 def format_synthesis(self, synthesis: DailySynthesis, graph_native: bool = True) -> str: 360 """ 361 Format synthesis as markdown for daily note. 362 363 If graph_native=True, uses Roam-style nested bullets with properties 364 so everything is a node in the Obsidian graph. 365 """ 366 lines = [] 367 368 lines.append(f"# Daily Graph Synthesis - {synthesis.date}") 369 lines.append("") 370 lines.append("*How today's work interacted with the knowledge graph*") 371 lines.append("") 372 373 # Summary as graph-native properties 374 lines.append("## Summary") 375 lines.append("") 376 if graph_native: 377 lines.append(f"- sessions:: {synthesis.sessions_count}") 378 lines.append(f"- insights_captured:: {synthesis.insights_captured}") 379 lines.append(f"- decisions_made:: {synthesis.decisions_made}") 380 lines.append(f"- new_nodes:: {len(synthesis.delta.new_nodes)}") 381 lines.append(f"- new_edges:: {len(synthesis.delta.new_edges)}") 382 lines.append(f"- total_nodes:: {len(self.graph['nodes'])}") 383 lines.append(f"- total_edges:: {len(self.graph['edges'])}") 384 else: 385 lines.append(f"- **Sessions:** {synthesis.sessions_count}") 386 lines.append(f"- **New nodes:** {len(synthesis.delta.new_nodes)}") 387 lines.append(f"- **New edges:** {len(synthesis.delta.new_edges)}") 388 lines.append("") 389 390 # ESCALATED - Need your attention (Moses) 391 if synthesis.escalated: 392 lines.append("## 🔴 ESCALATED") 393 lines.append("") 394 lines.append("*Low confidence + high impact → Need your judgment*") 395 lines.append("") 396 for insight in synthesis.escalated: 397 if graph_native: 398 # Create linkable node with properties 399 safe_name = insight.content[:50].replace("[", "").replace("]", "").replace("|", "-") 400 lines.append(f"- [[{safe_name}]]") 401 lines.append(f" - status:: ESCALATED") 402 lines.append(f" - confidence:: {insight.confidence:.2f}") 403 lines.append(f" - impact:: {insight.impact:.2f}") 404 if insight.axiom: 405 lines.append(f" - axiom:: [[{insight.axiom}]]") 406 lines.append(f" - source:: {insight.source}") 407 else: 408 lines.append(f"- **{insight.content}** [{insight.axiom or 'untagged'}]") 409 lines.append("") 410 411 # FLAGGED - Worth attention 412 if synthesis.flagged: 413 lines.append("## 🟡 FLAGGED") 414 lines.append("") 415 lines.append("*Mixed confidence/impact → Worth your attention*") 416 lines.append("") 417 for insight in synthesis.flagged[:10]: 418 if graph_native: 419 safe_name = insight.content[:50].replace("[", "").replace("]", "").replace("|", "-") 420 lines.append(f"- [[{safe_name}]]") 421 lines.append(f" - status:: FLAGGED") 422 if insight.axiom: 423 lines.append(f" - axiom:: [[{insight.axiom}]]") 424 else: 425 lines.append(f"- {insight.content}") 426 if len(synthesis.flagged) > 10: 427 lines.append(f"- *...and {len(synthesis.flagged) - 10} more*") 428 lines.append("") 429 430 # SHIPPED - Auto-handled, FYI 431 if synthesis.shipped: 432 lines.append("## 🟢 SHIPPED") 433 lines.append("") 434 lines.append(f"*{len(synthesis.shipped)} high-confidence insights auto-added*") 435 lines.append("") 436 for insight in synthesis.shipped[:5]: 437 if graph_native: 438 safe_name = insight.content[:50].replace("[", "").replace("]", "").replace("|", "-") 439 lines.append(f"- [[{safe_name}]]") 440 lines.append(f" - status:: SHIPPED") 441 if insight.axiom: 442 lines.append(f" - axiom:: [[{insight.axiom}]]") 443 else: 444 lines.append(f"- {insight.content[:60]}...") 445 if len(synthesis.shipped) > 5: 446 lines.append(f"- *...and {len(synthesis.shipped) - 5} more*") 447 lines.append("") 448 449 # Gravity wells as linked nodes 450 if synthesis.gravity_wells: 451 lines.append("## Gravity Wells") 452 lines.append("") 453 lines.append("*Topics with high activity today*") 454 lines.append("") 455 for well in synthesis.gravity_wells: 456 if graph_native: 457 # Extract the node name from "name (N connections)" 458 parts = well.rsplit(" (", 1) 459 name = parts[0][:40].replace("[", "").replace("]", "") 460 count = parts[1].rstrip(")") if len(parts) > 1 else "" 461 lines.append(f"- [[{name}]]") 462 if count: 463 lines.append(f" - connections:: {count.split()[0]}") 464 else: 465 lines.append(f"- {well}") 466 lines.append("") 467 468 # Principle edges as linked relationships 469 if synthesis.principle_edges: 470 lines.append("## Principle Edges") 471 lines.append("") 472 lines.append("*New axiom connections discovered*") 473 lines.append("") 474 for edge in synthesis.principle_edges: 475 if graph_native: 476 # Parse "A2: source ↔ target" 477 if ":" in edge: 478 axiom, rest = edge.split(":", 1) 479 axiom = axiom.strip() 480 lines.append(f"- [[{axiom}]] edge") 481 lines.append(f" - discovered:: {synthesis.date}") 482 lines.append(f" - connection:: {rest.strip()[:60]}") 483 else: 484 lines.append(f"- {edge}") 485 lines.append("") 486 487 # Connected orphans 488 if synthesis.orphans_connected: 489 lines.append("## Orphans Connected") 490 lines.append("") 491 lines.append("*Previously isolated, now linked*") 492 lines.append("") 493 for orphan in synthesis.orphans_connected: 494 if graph_native: 495 safe_name = orphan[:40].replace("[", "").replace("]", "") 496 lines.append(f"- [[{safe_name}]]") 497 lines.append(f" - connected_on:: {synthesis.date}") 498 else: 499 lines.append(f"- {orphan}") 500 lines.append("") 501 502 # Footer 503 lines.append("---") 504 lines.append("") 505 lines.append(f"date:: {synthesis.date}") 506 lines.append(f"type:: [[daily-graph-synthesis]]") 507 lines.append("") 508 lines.append("*Moses escalation: ESCALATE (need judgment) → FLAG (worth attention) → SHIP (auto-handled)*") 509 510 return "\n".join(lines) 511 512 def save_to_daily_note(self, synthesis: DailySynthesis): 513 """Save synthesis to daily note file.""" 514 DAILY_NOTES_DIR.mkdir(parents=True, exist_ok=True) 515 516 daily_note_path = DAILY_NOTES_DIR / f"{synthesis.date}.md" 517 content = self.format_synthesis(synthesis) 518 519 # If file exists, append graph synthesis section 520 if daily_note_path.exists(): 521 existing = daily_note_path.read_text() 522 if "## Daily Graph Synthesis" not in existing: 523 content = existing + "\n\n---\n\n" + content 524 else: 525 # Replace existing graph synthesis 526 parts = existing.split("# Daily Graph Synthesis") 527 content = parts[0].rstrip() + "\n\n" + content 528 529 daily_note_path.write_text(content) 530 return daily_note_path 531 532 533 def main(): 534 """Main entry point.""" 535 date = None 536 open_obsidian = False 537 538 if len(sys.argv) > 1: 539 if sys.argv[1] == "--date" and len(sys.argv) > 2: 540 date = sys.argv[2] 541 elif sys.argv[1] == "--obsidian": 542 open_obsidian = True 543 elif sys.argv[1] in ["--help", "-h"]: 544 print(__doc__) 545 return 546 547 synthesizer = DailyGraphSynthesizer(date) 548 synthesis = synthesizer.synthesize() 549 550 # Save to daily note 551 note_path = synthesizer.save_to_daily_note(synthesis) 552 553 # Print summary 554 print(f"Daily Graph Synthesis - {synthesis.date}") 555 print("=" * 50) 556 print(f"Sessions: {synthesis.sessions_count}") 557 print(f"New nodes: {len(synthesis.delta.new_nodes)}") 558 print(f"New edges: {len(synthesis.delta.new_edges)}") 559 print() 560 print(f"🔴 ESCALATED: {len(synthesis.escalated)}") 561 print(f"🟡 FLAGGED: {len(synthesis.flagged)}") 562 print(f"🟢 SHIPPED: {len(synthesis.shipped)}") 563 print() 564 print(f"Saved to: {note_path}") 565 566 if open_obsidian: 567 vault_name = "Sovereign_OS" 568 file_path = f"sessions/daily-notes/{synthesis.date}" 569 obsidian_url = f"obsidian://open?vault={vault_name}&file={file_path}" 570 os.system(f'open "{obsidian_url}"') 571 print(f"Opened in Obsidian") 572 573 574 if __name__ == "__main__": 575 main()