/ scripts / daily_graph_synthesis.py
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()