personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

replace knowledge graph home with Pulse landing page

Move knowledge graph from apps/home/ to apps/graph/ and replace
with Pulse — a server-rendered "what's happening now" page showing
vital signs, today's narrative, calendar/activities, action items,
and entity activity. Add bridge state cache for observe timestamps.
Root redirect unchanged — url_for("app:home.index") auto-resolves.

+2279 -915
+4
apps/graph/app.json
··· 1 + { 2 + "icon": "\ud83d\udd78\ufe0f", 3 + "label": "Graph" 4 + }
+343
apps/graph/routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Graph app - knowledge graph visualization.""" 5 + 6 + from __future__ import annotations 7 + 8 + import sqlite3 9 + from datetime import date, timedelta 10 + from typing import Any 11 + 12 + from flask import Blueprint, jsonify, render_template, request 13 + 14 + from think.indexer.journal import ( 15 + get_entity_intelligence, 16 + get_entity_strength, 17 + get_journal_index, 18 + get_principal_entity_names, 19 + is_noise_entity, 20 + ) 21 + 22 + graph_bp = Blueprint( 23 + "app:graph", 24 + __name__, 25 + url_prefix="/app/graph", 26 + ) 27 + 28 + 29 + @graph_bp.route("/") 30 + def index(): 31 + return render_template("app.html") 32 + 33 + 34 + @graph_bp.route("/api/graph") 35 + def api_graph(): 36 + """Return nodes + edges for the knowledge graph visualization. 37 + 38 + Query params: 39 + facet: filter by facet name 40 + since: YYYYMMDD start date 41 + types: comma-separated entity types to include 42 + min_strength: minimum score threshold 43 + limit: max nodes (default 100) 44 + """ 45 + facet = request.args.get("facet") or None 46 + since = request.args.get("since") or None 47 + types_param = request.args.get("types") or None 48 + min_strength = request.args.get("min_strength", type=float, default=0.0) 49 + limit = request.args.get("limit", type=int, default=100) 50 + 51 + # Default to 90 days if no since provided 52 + if not since: 53 + since = (date.today() - timedelta(days=90)).strftime("%Y%m%d") 54 + 55 + # Get ranked entities 56 + ranked = get_entity_strength(facet=facet, since=since, limit=limit * 3) 57 + 58 + # Look up entity type and is_principal from the identity table 59 + conn, _ = get_journal_index() 60 + try: 61 + entity_meta = _load_entity_metadata(conn) 62 + 63 + # Build nodes, applying filters 64 + type_filter = None 65 + if types_param: 66 + type_filter = {t.strip().lower() for t in types_param.split(",")} 67 + 68 + nodes = [] 69 + node_names: set[str] = set() 70 + for r in ranked: 71 + if r["score"] < min_strength: 72 + continue 73 + 74 + entity_name = r["entity_name"] 75 + entity_id = r.get("entity_id") or "" 76 + meta = entity_meta.get(entity_id, {}) 77 + entity_type = (meta.get("type") or "unknown").lower() 78 + 79 + if type_filter and entity_type not in type_filter: 80 + continue 81 + 82 + is_principal = meta.get("is_principal", False) 83 + nodes.append( 84 + { 85 + "id": entity_id or entity_name, 86 + "name": meta.get("name") or entity_name, 87 + "type": entity_type, 88 + "score": r["score"], 89 + "kg_edge_count": r["kg_edge_count"], 90 + "co_occurrence": r["co_occurrence"], 91 + "appearance": r["appearance"], 92 + "recency": r["recency"], 93 + "facet_breadth": r["facet_breadth"], 94 + "observation_depth": r["observation_depth"], 95 + "is_principal": is_principal, 96 + } 97 + ) 98 + node_names.add(entity_name) 99 + if entity_id: 100 + node_names.add(entity_id) 101 + 102 + if len(nodes) >= limit: 103 + break 104 + 105 + # Build edges 106 + explicit_edges = _get_explicit_edges(conn, node_names, facet, since) 107 + co_occurrence_edges = _get_co_occurrence_edges( 108 + conn, node_names, explicit_edges, facet, since 109 + ) 110 + 111 + # Map edge entity names to node IDs 112 + name_to_id = _build_name_to_node_id(conn, {n["id"] for n in nodes}) 113 + edges = [] 114 + for e in explicit_edges + co_occurrence_edges: 115 + from_id = name_to_id.get(e["from_name"], e["from_name"]) 116 + to_id = name_to_id.get(e["to_name"], e["to_name"]) 117 + # Only include edges where both endpoints are visible nodes 118 + node_ids = {n["id"] for n in nodes} 119 + if from_id in node_ids and to_id in node_ids: 120 + e["from"] = from_id 121 + e["to"] = to_id 122 + edges.append(e) 123 + 124 + # Stats 125 + total_entities = conn.execute( 126 + "SELECT COUNT(DISTINCT entity_id) FROM entities WHERE source='identity'" 127 + ).fetchone()[0] 128 + total_signals = conn.execute("SELECT COUNT(*) FROM entity_signals").fetchone()[ 129 + 0 130 + ] 131 + 132 + finally: 133 + conn.close() 134 + 135 + # Count edges by type 136 + explicit_edge_count = sum(1 for e in edges if e.get("edge_type") == "explicit") 137 + co_occurrence_edge_count = sum( 138 + 1 for e in edges if e.get("edge_type") == "co_occurrence" 139 + ) 140 + 141 + return jsonify( 142 + { 143 + "nodes": nodes, 144 + "edges": edges, 145 + "stats": { 146 + "total_entities": total_entities, 147 + "total_signals": total_signals, 148 + "explicit_edge_count": explicit_edge_count, 149 + "co_occurrence_edge_count": co_occurrence_edge_count, 150 + }, 151 + } 152 + ) 153 + 154 + 155 + @graph_bp.route("/api/entity/<path:name>") 156 + def api_entity(name: str): 157 + """Return full entity intelligence for the inspect panel.""" 158 + facet = request.args.get("facet") or None 159 + result = get_entity_intelligence(name, facet=facet) 160 + if result is None: 161 + return jsonify({"error": "Entity not found"}), 404 162 + return jsonify(result) 163 + 164 + 165 + def _load_entity_metadata(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: 166 + """Load identity metadata (type, name, is_principal) for all entities.""" 167 + rows = conn.execute( 168 + "SELECT entity_id, name, type, is_principal FROM entities WHERE source='identity'" 169 + ).fetchall() 170 + return { 171 + r[0]: {"name": r[1], "type": r[2] or "unknown", "is_principal": bool(r[3])} 172 + for r in rows 173 + } 174 + 175 + 176 + def _get_explicit_edges( 177 + conn: sqlite3.Connection, 178 + node_names: set[str], 179 + facet: str | None, 180 + since: str | None, 181 + ) -> list[dict[str, Any]]: 182 + """Query kg_edge signals for explicit relationship edges.""" 183 + if not node_names: 184 + return [] 185 + 186 + placeholders = ",".join("?" for _ in node_names) 187 + where_parts = [ 188 + "signal_type='kg_edge'", 189 + f"entity_name IN ({placeholders})", 190 + ] 191 + params: list[Any] = list(node_names) 192 + 193 + if facet: 194 + where_parts.append("facet=?") 195 + params.append(facet.lower()) 196 + if since: 197 + where_parts.append("day>=?") 198 + params.append(since) 199 + 200 + where = " AND ".join(where_parts) 201 + rows = conn.execute( 202 + f""" 203 + SELECT entity_name, target_name, relationship_type, COUNT(*) as freq 204 + FROM entity_signals 205 + WHERE {where} 206 + AND target_name IS NOT NULL AND target_name != '' 207 + GROUP BY entity_name, target_name, relationship_type 208 + """, 209 + params, 210 + ).fetchall() 211 + 212 + return [ 213 + { 214 + "from_name": r[0], 215 + "to_name": r[1], 216 + "relationship_type": r[2] or "", 217 + "frequency": r[3], 218 + "edge_type": "explicit", 219 + } 220 + for r in rows 221 + if not is_noise_entity(r[0]) and not is_noise_entity(r[1] or "") 222 + ] 223 + 224 + 225 + def _get_co_occurrence_edges( 226 + conn: sqlite3.Connection, 227 + node_names: set[str], 228 + explicit_edges: list[dict[str, Any]], 229 + facet: str | None, 230 + since: str | None, 231 + ) -> list[dict[str, Any]]: 232 + """Find co-occurrence edges: entity pairs sharing paths, minus explicit edges.""" 233 + if not node_names: 234 + return [] 235 + 236 + # Build set of already-explicit pairs 237 + explicit_pairs: set[tuple[str, str]] = set() 238 + for e in explicit_edges: 239 + explicit_pairs.add((e["from_name"], e["to_name"])) 240 + explicit_pairs.add((e["to_name"], e["from_name"])) 241 + 242 + # Exclude principal entities — they co-occur with everyone by definition 243 + principal_names = get_principal_entity_names(conn) 244 + 245 + placeholders = ",".join("?" for _ in node_names) 246 + where_parts = [ 247 + f"s1.entity_name IN ({placeholders})", 248 + f"s2.entity_name IN ({placeholders})", 249 + "s1.entity_name < s2.entity_name", 250 + ] 251 + params: list[Any] = list(node_names) + list(node_names) 252 + 253 + if principal_names: 254 + ph = ",".join("?" for _ in principal_names) 255 + where_parts.append(f"s1.entity_name NOT IN ({ph})") 256 + params.extend(principal_names) 257 + where_parts.append(f"s2.entity_name NOT IN ({ph})") 258 + params.extend(principal_names) 259 + if facet: 260 + where_parts.append("s1.facet=?") 261 + params.append(facet.lower()) 262 + if since: 263 + where_parts.append("s1.day>=?") 264 + params.append(since) 265 + 266 + where = " AND ".join(where_parts) 267 + rows = conn.execute( 268 + f""" 269 + SELECT s1.entity_name, s2.entity_name, COUNT(DISTINCT s1.path) as freq 270 + FROM entity_signals s1 271 + JOIN entity_signals s2 272 + ON s1.path = s2.path 273 + AND s1.entity_name != s2.entity_name 274 + WHERE {where} 275 + GROUP BY s1.entity_name, s2.entity_name 276 + HAVING freq >= 2 277 + """, 278 + params, 279 + ).fetchall() 280 + 281 + edges = [] 282 + for r in rows: 283 + if (r[0], r[1]) in explicit_pairs: 284 + continue 285 + if is_noise_entity(r[0]) or is_noise_entity(r[1]): 286 + continue 287 + edges.append( 288 + { 289 + "from_name": r[0], 290 + "to_name": r[1], 291 + "frequency": r[2], 292 + "edge_type": "co_occurrence", 293 + } 294 + ) 295 + 296 + return edges 297 + 298 + 299 + def _build_name_to_node_id( 300 + conn: sqlite3.Connection, 301 + node_ids: set[str], 302 + ) -> dict[str, str]: 303 + """Map signal entity_names to node IDs (entity_ids) for edge matching. 304 + 305 + Uses shared name resolution (build_name_resolution_map) for consistent 306 + matching — the same name resolves the same way everywhere. 307 + """ 308 + import json as _json 309 + 310 + from think.entities.matching import build_name_resolution_map 311 + 312 + rows = conn.execute( 313 + "SELECT entity_id, name, aka FROM entities WHERE source='identity'" 314 + ).fetchall() 315 + 316 + # Build entity dicts filtered to entities in node_ids 317 + entity_dicts: list[dict[str, Any]] = [] 318 + for entity_id, name, aka_str in rows: 319 + if entity_id not in node_ids: 320 + continue 321 + d: dict[str, Any] = {"id": entity_id, "name": name, "aka": []} 322 + if aka_str: 323 + try: 324 + aka_list = _json.loads(aka_str) 325 + if isinstance(aka_list, list): 326 + d["aka"] = aka_list 327 + except (ValueError, TypeError): 328 + pass 329 + entity_dicts.append(d) 330 + 331 + # Collect all names that may appear as edge endpoints 332 + entity_name_rows = conn.execute( 333 + "SELECT DISTINCT entity_name FROM entity_signals" 334 + ).fetchall() 335 + target_name_rows = conn.execute( 336 + "SELECT DISTINCT target_name FROM entity_signals " 337 + "WHERE target_name IS NOT NULL AND target_name != ''" 338 + ).fetchall() 339 + all_names = list( 340 + {r[0] for r in entity_name_rows} | {r[0] for r in target_name_rows} 341 + ) 342 + 343 + return build_name_resolution_map(all_names, entity_dicts)
+690
apps/graph/workspace.html
··· 1 + <div class="workspace-content" style="display:flex;flex-direction:column;height:calc(100vh - var(--facet-bar-height) - var(--app-bar-height) - 24px);overflow:hidden;"> 2 + <!-- Filter controls --> 3 + <div class="graph-controls" id="graph-controls" style="display:none;"> 4 + <div class="graph-controls-row"> 5 + <div class="graph-type-filters"> 6 + <button class="graph-type-btn active" data-type="person" style="--btn-color:#3b82f6">Person</button> 7 + <button class="graph-type-btn active" data-type="company" style="--btn-color:#22c55e">Company</button> 8 + <button class="graph-type-btn active" data-type="project" style="--btn-color:#f59e0b">Project</button> 9 + <button class="graph-type-btn active" data-type="tool" style="--btn-color:#6b7280">Tool</button> 10 + </div> 11 + <div class="graph-time-filters"> 12 + <button class="graph-time-btn" data-days="7">7d</button> 13 + <button class="graph-time-btn" data-days="30">30d</button> 14 + <button class="graph-time-btn active" data-days="90">90d</button> 15 + <button class="graph-time-btn" data-days="">All</button> 16 + </div> 17 + <div class="graph-strength-filter"> 18 + <label for="min-strength">Min strength</label> 19 + <input type="range" id="min-strength" min="0" max="500" value="0" step="5"> 20 + <span id="min-strength-val">0</span> 21 + </div> 22 + <div class="graph-stats" id="graph-stats"></div> 23 + </div> 24 + </div> 25 + 26 + <!-- Graph container --> 27 + <div id="graph-container" style="flex:1;position:relative;min-height:0;"> 28 + <div class="graph-loading" id="graph-loading">Loading knowledge graph...</div> 29 + <div class="graph-empty" id="graph-empty" style="display:none;"> 30 + <div class="graph-empty-icon">🕸️</div> 31 + <h2>Your knowledge graph builds itself from daily use</h2> 32 + <p>As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p> 33 + </div> 34 + <div id="graph-canvas" style="width:100%;height:100%;display:none;"></div> 35 + </div> 36 + 37 + <!-- Entity detail panel (slide-in from right) --> 38 + <div class="graph-detail-panel" id="graph-detail-panel"> 39 + <div class="graph-detail-header"> 40 + <h2 id="detail-name"></h2> 41 + <button class="graph-detail-close" id="detail-close">&times;</button> 42 + </div> 43 + <div class="graph-detail-body" id="detail-body"></div> 44 + </div> 45 + </div> 46 + 47 + <style> 48 + /* Controls bar */ 49 + .graph-controls { 50 + padding: 0.5rem 0.75rem; 51 + border-bottom: 1px solid #e5e7eb; 52 + background: #fafafa; 53 + flex-shrink: 0; 54 + } 55 + .graph-controls-row { 56 + display: flex; 57 + align-items: center; 58 + gap: 1rem; 59 + flex-wrap: wrap; 60 + } 61 + .graph-type-filters, .graph-time-filters { 62 + display: flex; 63 + gap: 0.25rem; 64 + } 65 + .graph-type-btn, .graph-time-btn { 66 + padding: 0.25rem 0.6rem; 67 + border: 1px solid #d1d5db; 68 + border-radius: 4px; 69 + background: white; 70 + font-size: 0.8rem; 71 + cursor: pointer; 72 + transition: all 0.15s; 73 + color: #374151; 74 + } 75 + .graph-type-btn.active { 76 + background: var(--btn-color, #3b82f6); 77 + color: white; 78 + border-color: var(--btn-color, #3b82f6); 79 + } 80 + .graph-time-btn.active { 81 + background: #374151; 82 + color: white; 83 + border-color: #374151; 84 + } 85 + .graph-type-btn:hover, .graph-time-btn:hover { 86 + opacity: 0.85; 87 + } 88 + .graph-strength-filter { 89 + display: flex; 90 + align-items: center; 91 + gap: 0.4rem; 92 + font-size: 0.8rem; 93 + color: #6b7280; 94 + } 95 + .graph-strength-filter input[type=range] { 96 + width: 80px; 97 + height: 4px; 98 + } 99 + .graph-stats { 100 + margin-left: auto; 101 + font-size: 0.8rem; 102 + color: #6b7280; 103 + } 104 + 105 + /* Loading / empty states */ 106 + .graph-loading { 107 + text-align: center; 108 + padding: 4em; 109 + color: #666; 110 + } 111 + .graph-empty { 112 + text-align: center; 113 + padding: 4em 2em; 114 + max-width: 450px; 115 + margin: 2em auto; 116 + } 117 + .graph-empty-icon { 118 + font-size: 4em; 119 + margin-bottom: 0.25em; 120 + } 121 + .graph-empty h2 { 122 + margin: 0 0 0.5em 0; 123 + font-size: 1.3em; 124 + font-weight: 600; 125 + color: #333; 126 + } 127 + .graph-empty p { 128 + margin: 0; 129 + color: #666; 130 + line-height: 1.5; 131 + } 132 + 133 + /* Detail panel */ 134 + .graph-detail-panel { 135 + position: absolute; 136 + top: 0; 137 + right: 0; 138 + width: 340px; 139 + max-width: 90vw; 140 + height: 100%; 141 + background: white; 142 + box-shadow: -2px 0 12px rgba(0,0,0,0.12); 143 + z-index: 20; 144 + transform: translateX(100%); 145 + transition: transform 0.2s ease; 146 + display: flex; 147 + flex-direction: column; 148 + overflow: hidden; 149 + } 150 + .graph-detail-panel.open { 151 + transform: translateX(0); 152 + } 153 + .graph-detail-header { 154 + display: flex; 155 + align-items: center; 156 + justify-content: space-between; 157 + padding: 0.75rem 1rem; 158 + border-bottom: 1px solid #e5e7eb; 159 + flex-shrink: 0; 160 + } 161 + .graph-detail-header h2 { 162 + margin: 0; 163 + font-size: 1.1rem; 164 + font-weight: 600; 165 + color: #111827; 166 + overflow: hidden; 167 + text-overflow: ellipsis; 168 + white-space: nowrap; 169 + } 170 + .graph-detail-close { 171 + background: none; 172 + border: none; 173 + font-size: 1.5rem; 174 + color: #6b7280; 175 + cursor: pointer; 176 + padding: 0 0.25rem; 177 + line-height: 1; 178 + } 179 + .graph-detail-close:hover { 180 + color: #111827; 181 + } 182 + .graph-detail-body { 183 + flex: 1; 184 + overflow-y: auto; 185 + padding: 0.75rem 1rem; 186 + font-size: 0.9rem; 187 + color: #374151; 188 + } 189 + .detail-section { 190 + margin-bottom: 1rem; 191 + } 192 + .detail-section-title { 193 + font-size: 0.7rem; 194 + font-weight: 600; 195 + text-transform: uppercase; 196 + letter-spacing: 0.05em; 197 + color: #6b7280; 198 + margin: 0 0 0.4rem 0; 199 + } 200 + .detail-type-badge { 201 + display: inline-block; 202 + padding: 0.15rem 0.5rem; 203 + border-radius: 4px; 204 + font-size: 0.75rem; 205 + font-weight: 500; 206 + color: white; 207 + margin-bottom: 0.5rem; 208 + } 209 + .detail-principal-badge { 210 + display: inline-block; 211 + padding: 0.15rem 0.5rem; 212 + border-radius: 4px; 213 + font-size: 0.75rem; 214 + font-weight: 500; 215 + background: #f59e0b; 216 + color: white; 217 + margin-left: 0.4rem; 218 + } 219 + .detail-description { 220 + color: #4b5563; 221 + line-height: 1.5; 222 + margin-bottom: 0.75rem; 223 + } 224 + .detail-score-grid { 225 + display: grid; 226 + grid-template-columns: 1fr 1fr; 227 + gap: 0.3rem; 228 + } 229 + .detail-score-item { 230 + display: flex; 231 + justify-content: space-between; 232 + font-size: 0.82rem; 233 + } 234 + .detail-score-label { 235 + color: #6b7280; 236 + } 237 + .detail-score-value { 238 + font-weight: 600; 239 + color: #111827; 240 + } 241 + .detail-connected-list { 242 + list-style: none; 243 + padding: 0; 244 + margin: 0; 245 + } 246 + .detail-connected-item { 247 + padding: 0.2rem 0; 248 + border-bottom: 1px solid #f3f4f6; 249 + display: flex; 250 + justify-content: space-between; 251 + font-size: 0.82rem; 252 + } 253 + .detail-connected-item:last-child { border-bottom: none; } 254 + .detail-connected-name { 255 + cursor: pointer; 256 + color: #2563eb; 257 + } 258 + .detail-connected-name:hover { text-decoration: underline; } 259 + .detail-connected-rel { 260 + color: #9ca3af; 261 + font-size: 0.75rem; 262 + } 263 + .detail-activity-item { 264 + padding: 0.2rem 0; 265 + border-bottom: 1px solid #f3f4f6; 266 + font-size: 0.82rem; 267 + } 268 + .detail-activity-item:last-child { border-bottom: none; } 269 + .detail-activity-day { 270 + color: #6b7280; 271 + font-weight: 600; 272 + margin-right: 0.4rem; 273 + } 274 + .detail-entity-link { 275 + display: inline-block; 276 + margin-top: 0.5rem; 277 + color: #2563eb; 278 + text-decoration: none; 279 + font-size: 0.85rem; 280 + } 281 + .detail-entity-link:hover { text-decoration: underline; } 282 + </style> 283 + 284 + <script src="{{ vendor_lib('vis-network') }}"></script> 285 + 286 + <script> 287 + (function() { 288 + // --- State --- 289 + let network = null; 290 + let graphData = null; 291 + let activeTypes = new Set(['person', 'company', 'project', 'tool']); 292 + let timeDays = 90; 293 + let minStrength = 0; 294 + let detailOpen = false; 295 + 296 + // --- Color maps --- 297 + const TYPE_COLORS = { 298 + person: '#3b82f6', 299 + company: '#22c55e', 300 + project: '#f59e0b', 301 + tool: '#6b7280', 302 + unknown: '#a1a1aa', 303 + }; 304 + 305 + const EDGE_REL_COLORS = { 306 + 'works-on': '#8b5cf6', 307 + 'works-at': '#06b6d4', 308 + 'discusses-with': '#ec4899', 309 + 'collaborates-with': '#10b981', 310 + 'manages': '#f97316', 311 + 'reports-to': '#f97316', 312 + 'member-of': '#6366f1', 313 + 'uses': '#64748b', 314 + }; 315 + 316 + // --- Helpers --- 317 + function sinceFromDays(days) { 318 + if (!days) return ''; 319 + const d = new Date(); 320 + d.setDate(d.getDate() - days); 321 + return d.toISOString().slice(0,10).replace(/-/g,''); 322 + } 323 + 324 + function escapeHtml(text) { 325 + const div = document.createElement('div'); 326 + div.textContent = text || ''; 327 + return div.innerHTML; 328 + } 329 + 330 + function formatDay(d) { 331 + if (!d || d.length < 8) return d || ''; 332 + return d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8); 333 + } 334 + 335 + // --- Data fetch --- 336 + async function fetchGraph() { 337 + const params = new URLSearchParams(); 338 + const facet = window.selectedFacet; 339 + if (facet) params.set('facet', facet); 340 + const since = sinceFromDays(timeDays); 341 + if (since) params.set('since', since); 342 + const types = Array.from(activeTypes).join(','); 343 + if (types) params.set('types', types); 344 + if (minStrength > 0) params.set('min_strength', minStrength); 345 + params.set('limit', '100'); 346 + 347 + const resp = await fetch('/app/graph/api/graph?' + params.toString()); 348 + if (!resp.ok) throw new Error('Failed to fetch graph'); 349 + return resp.json(); 350 + } 351 + 352 + async function fetchEntity(name) { 353 + const params = new URLSearchParams(); 354 + const facet = window.selectedFacet; 355 + if (facet) params.set('facet', facet); 356 + const resp = await fetch('/app/graph/api/entity/' + encodeURIComponent(name) + '?' + params.toString()); 357 + if (!resp.ok) return null; 358 + return resp.json(); 359 + } 360 + 361 + // --- Graph rendering --- 362 + function buildVisData(data) { 363 + const maxScore = Math.max(...data.nodes.map(n => n.score), 1); 364 + const scaleFactor = 30 / maxScore; 365 + 366 + const nodes = data.nodes.map(n => { 367 + const size = n.is_principal 368 + ? Math.max(30, 10 + n.score * scaleFactor) 369 + : Math.max(10, Math.min(40, 10 + n.score * scaleFactor)); 370 + const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; 371 + return { 372 + id: n.id, 373 + label: n.name, 374 + size: size, 375 + color: { 376 + background: color, 377 + border: n.is_principal ? '#f59e0b' : color, 378 + highlight: { background: color, border: n.is_principal ? '#f59e0b' : '#111827' }, 379 + hover: { background: color, border: n.is_principal ? '#f59e0b' : '#374151' }, 380 + }, 381 + borderWidth: n.is_principal ? 3 : 1.5, 382 + font: { size: Math.max(14, Math.min(22, 10 + size * 0.3)), color: '#374151' }, 383 + title: n.name + ' (' + n.type + ') — score: ' + n.score.toFixed(1), 384 + _data: n, 385 + }; 386 + }); 387 + 388 + const edges = data.edges.map((e, i) => { 389 + if (e.edge_type === 'explicit') { 390 + const relColor = EDGE_REL_COLORS[e.relationship_type] || '#9ca3af'; 391 + return { 392 + id: 'e' + i, 393 + from: e.from, 394 + to: e.to, 395 + width: 1 + Math.min(4, e.frequency * 0.5), 396 + color: { color: relColor, opacity: 0.7, highlight: relColor, hover: relColor }, 397 + arrows: { to: { enabled: true, scaleFactor: 0.5 } }, 398 + smooth: { type: 'curvedCW', roundness: 0.15 }, 399 + title: (e.relationship_type || 'related') + ' (' + e.frequency + ')', 400 + }; 401 + } else { 402 + return { 403 + id: 'e' + i, 404 + from: e.from, 405 + to: e.to, 406 + width: 1 + Math.min(3, e.frequency * 0.3), 407 + color: { color: '#d1d5db', opacity: 0.5, highlight: '#9ca3af', hover: '#9ca3af' }, 408 + dashes: [4, 4], 409 + smooth: { type: 'curvedCW', roundness: 0.1 }, 410 + title: 'co-occurrence (' + e.frequency + ')', 411 + }; 412 + } 413 + }); 414 + 415 + return { 416 + nodes: new vis.DataSet(nodes), 417 + edges: new vis.DataSet(edges), 418 + }; 419 + } 420 + 421 + function renderGraph(data) { 422 + graphData = data; 423 + const container = document.getElementById('graph-canvas'); 424 + const loading = document.getElementById('graph-loading'); 425 + const empty = document.getElementById('graph-empty'); 426 + const controls = document.getElementById('graph-controls'); 427 + 428 + loading.style.display = 'none'; 429 + 430 + if (!data.nodes || data.nodes.length === 0) { 431 + container.style.display = 'none'; 432 + empty.style.display = 'block'; 433 + controls.style.display = 'none'; 434 + return; 435 + } 436 + 437 + empty.style.display = 'none'; 438 + container.style.display = 'block'; 439 + controls.style.display = 'block'; 440 + 441 + const visData = buildVisData(data); 442 + 443 + const options = { 444 + physics: { 445 + solver: 'forceAtlas2Based', 446 + forceAtlas2Based: { 447 + gravitationalConstant: -200, 448 + centralGravity: 0.005, 449 + springLength: 230, 450 + springConstant: 0.015, 451 + damping: 0.4, 452 + avoidOverlap: 0.8, 453 + }, 454 + stabilization: { iterations: 1000 }, 455 + }, 456 + nodes: { 457 + shape: 'dot', 458 + scaling: { min: 10, max: 40 }, 459 + borderWidth: 1.5, 460 + shadow: { enabled: true, size: 4, x: 1, y: 1, color: 'rgba(0,0,0,0.1)' }, 461 + }, 462 + edges: { 463 + smooth: { enabled: true, type: 'curvedCW', roundness: 0.15 }, 464 + }, 465 + interaction: { 466 + hover: true, 467 + tooltipDelay: 100, 468 + hideEdgesOnDrag: true, 469 + hideEdgesOnZoom: true, 470 + }, 471 + layout: { 472 + improvedLayout: true, 473 + }, 474 + }; 475 + 476 + if (network) { 477 + network.setData(visData); 478 + } else { 479 + network = new vis.Network(container, visData, options); 480 + 481 + // Fit graph to container after stabilization 482 + network.on('stabilizationIterationsDone', function() { 483 + network.fit({ animation: { duration: 300 } }); 484 + }); 485 + 486 + // Click node → inspect 487 + network.on('click', function(params) { 488 + if (params.nodes.length > 0) { 489 + const nodeId = params.nodes[0]; 490 + const nodeData = visData.nodes.get(nodeId); 491 + if (nodeData && nodeData._data) { 492 + showDetail(nodeData._data); 493 + } 494 + } else { 495 + // Click canvas → dismiss 496 + closeDetail(); 497 + } 498 + }); 499 + } 500 + 501 + // Update stats 502 + updateStats(data); 503 + } 504 + 505 + function updateStats(data) { 506 + const el = document.getElementById('graph-stats'); 507 + el.textContent = data.nodes.length + ' nodes, ' + data.edges.length + ' edges'; 508 + } 509 + 510 + // --- Detail panel --- 511 + function showDetail(nodeData) { 512 + const panel = document.getElementById('graph-detail-panel'); 513 + const nameEl = document.getElementById('detail-name'); 514 + const bodyEl = document.getElementById('detail-body'); 515 + 516 + nameEl.textContent = nodeData.name; 517 + bodyEl.innerHTML = '<div style="text-align:center;padding:2em;color:#999;">Loading...</div>'; 518 + panel.classList.add('open'); 519 + detailOpen = true; 520 + 521 + fetchEntity(nodeData.id).then(intel => { 522 + if (!intel || intel.error) { 523 + bodyEl.innerHTML = '<div style="padding:1em;color:#999;">Could not load entity details.</div>'; 524 + return; 525 + } 526 + renderDetail(intel, nodeData); 527 + }); 528 + } 529 + 530 + function renderDetail(intel, nodeData) { 531 + const bodyEl = document.getElementById('detail-body'); 532 + const identity = intel.identity || {}; 533 + const strength = intel.strength || {}; 534 + const typeColor = TYPE_COLORS[identity.type?.toLowerCase()] || TYPE_COLORS.unknown; 535 + 536 + let html = ''; 537 + 538 + // Type badge 539 + html += '<div>'; 540 + html += '<span class="detail-type-badge" style="background:' + typeColor + '">' + escapeHtml(identity.type || nodeData.type) + '</span>'; 541 + if (identity.is_principal) { 542 + html += '<span class="detail-principal-badge">You</span>'; 543 + } 544 + html += '</div>'; 545 + 546 + // Description 547 + if (identity.description) { 548 + html += '<div class="detail-description">' + escapeHtml(identity.description) + '</div>'; 549 + } 550 + 551 + // Strength score 552 + html += '<div class="detail-section">'; 553 + html += '<div class="detail-section-title">Strength Score</div>'; 554 + html += '<div style="font-size:1.3em;font-weight:700;color:#111827;margin-bottom:0.3rem;">' + (strength.score || 0).toFixed(1) + '</div>'; 555 + html += '<div class="detail-score-grid">'; 556 + html += scoreItem('Co-occurrence', strength.co_occurrence); 557 + html += scoreItem('Appearances', strength.appearance); 558 + html += scoreItem('Recency', strength.recency?.toFixed(2)); 559 + html += scoreItem('Facet breadth', strength.facet_breadth); 560 + html += scoreItem('Observation depth', strength.observation_depth); 561 + html += '</div></div>'; 562 + 563 + // Connected entities (from network field) 564 + const networkEntities = intel.network || {}; 565 + const connectedNames = Object.keys(networkEntities).sort((a, b) => networkEntities[b] - networkEntities[a]).slice(0, 15); 566 + if (connectedNames.length > 0) { 567 + html += '<div class="detail-section">'; 568 + html += '<div class="detail-section-title">Connected Entities</div>'; 569 + html += '<ul class="detail-connected-list">'; 570 + for (const name of connectedNames) { 571 + html += '<li class="detail-connected-item">'; 572 + html += '<span class="detail-connected-name" data-entity="' + escapeHtml(name) + '">' + escapeHtml(name) + '</span>'; 573 + html += '<span class="detail-connected-rel">' + networkEntities[name] + ' shared</span>'; 574 + html += '</li>'; 575 + } 576 + html += '</ul></div>'; 577 + } 578 + 579 + // Recent activity 580 + const activity = (intel.activity || []).slice(0, 10); 581 + if (activity.length > 0) { 582 + html += '<div class="detail-section">'; 583 + html += '<div class="detail-section-title">Recent Activity</div>'; 584 + for (const a of activity) { 585 + html += '<div class="detail-activity-item">'; 586 + html += '<span class="detail-activity-day">' + formatDay(a.day) + '</span>'; 587 + const label = a.event_title || a.signal_type || ''; 588 + html += escapeHtml(label); 589 + if (a.target_name) html += ' → ' + escapeHtml(a.target_name); 590 + html += '</div>'; 591 + } 592 + html += '</div>'; 593 + } 594 + 595 + // Link to entities app 596 + html += '<a class="detail-entity-link" href="/app/entities#' + encodeURIComponent(identity.entity_id || nodeData.id) + '">View full intelligence →</a>'; 597 + 598 + bodyEl.innerHTML = html; 599 + 600 + // Click connected entity names 601 + bodyEl.querySelectorAll('.detail-connected-name').forEach(el => { 602 + el.addEventListener('click', () => { 603 + const eName = el.dataset.entity; 604 + // Try to find this entity in the current graph 605 + if (graphData) { 606 + const matchNode = graphData.nodes.find(n => n.name === eName || n.id === eName); 607 + if (matchNode) { 608 + showDetail(matchNode); 609 + if (network) network.selectNodes([matchNode.id]); 610 + return; 611 + } 612 + } 613 + // Fallback: just fetch directly 614 + showDetail({ id: eName, name: eName, type: 'unknown' }); 615 + }); 616 + }); 617 + } 618 + 619 + function scoreItem(label, value) { 620 + return '<div class="detail-score-item"><span class="detail-score-label">' + label + '</span><span class="detail-score-value">' + (value ?? 0) + '</span></div>'; 621 + } 622 + 623 + function closeDetail() { 624 + document.getElementById('graph-detail-panel').classList.remove('open'); 625 + detailOpen = false; 626 + if (network) network.unselectAll(); 627 + } 628 + 629 + // --- Filter handlers --- 630 + document.querySelectorAll('.graph-type-btn').forEach(btn => { 631 + btn.addEventListener('click', () => { 632 + const t = btn.dataset.type; 633 + if (btn.classList.contains('active')) { 634 + btn.classList.remove('active'); 635 + activeTypes.delete(t); 636 + } else { 637 + btn.classList.add('active'); 638 + activeTypes.add(t); 639 + } 640 + reload(); 641 + }); 642 + }); 643 + 644 + document.querySelectorAll('.graph-time-btn').forEach(btn => { 645 + btn.addEventListener('click', () => { 646 + document.querySelectorAll('.graph-time-btn').forEach(b => b.classList.remove('active')); 647 + btn.classList.add('active'); 648 + timeDays = btn.dataset.days ? parseInt(btn.dataset.days) : 0; 649 + reload(); 650 + }); 651 + }); 652 + 653 + const strengthSlider = document.getElementById('min-strength'); 654 + const strengthVal = document.getElementById('min-strength-val'); 655 + let strengthTimeout = null; 656 + strengthSlider.addEventListener('input', () => { 657 + strengthVal.textContent = strengthSlider.value; 658 + }); 659 + strengthSlider.addEventListener('change', () => { 660 + minStrength = parseInt(strengthSlider.value); 661 + reload(); 662 + }); 663 + 664 + document.getElementById('detail-close').addEventListener('click', closeDetail); 665 + 666 + // --- Facet awareness --- 667 + window.addEventListener('facet.switch', () => { 668 + reload(); 669 + }); 670 + 671 + // --- Load / reload --- 672 + let loadCount = 0; 673 + async function reload() { 674 + const thisLoad = ++loadCount; 675 + try { 676 + const data = await fetchGraph(); 677 + if (thisLoad !== loadCount) return; // stale 678 + renderGraph(data); 679 + } catch (err) { 680 + console.error('Graph load failed:', err); 681 + if (thisLoad === loadCount) { 682 + document.getElementById('graph-loading').textContent = 'Failed to load graph'; 683 + } 684 + } 685 + } 686 + 687 + // Initial load 688 + reload(); 689 + })(); 690 + </script>
+3 -2
apps/home/app.json
··· 1 1 { 2 - "icon": "\ud83d\udd78\ufe0f", 3 - "label": "Graph" 2 + "icon": "🏠", 3 + "label": "Home", 4 + "facets": {"disabled": true} 4 5 }
+193 -291
apps/home/routes.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Home app - knowledge graph visualization.""" 4 + """Home app - Pulse landing page.""" 5 5 6 6 from __future__ import annotations 7 7 8 - import sqlite3 9 - from datetime import date, timedelta 8 + import json 9 + from datetime import datetime, timedelta 10 + from pathlib import Path 10 11 from typing import Any 11 12 12 - from flask import Blueprint, jsonify, render_template, request 13 + from flask import Blueprint, jsonify, render_template 13 14 14 - from think.indexer.journal import ( 15 - get_entity_intelligence, 16 - get_entity_strength, 17 - get_journal_index, 18 - get_principal_entity_names, 19 - is_noise_entity, 20 - ) 15 + from think.awareness import get_current 16 + from think.facets import get_facets 17 + from think.indexer.journal import get_journal_index 18 + from think.utils import get_journal 19 + 20 + from convey.apps import _resolve_attention 21 + from convey.bridge import get_cached_state 21 22 22 23 home_bp = Blueprint( 23 24 "app:home", ··· 26 27 ) 27 28 28 29 29 - @home_bp.route("/") 30 - def index(): 31 - return render_template("app.html") 30 + def _today() -> str: 31 + return datetime.now().strftime("%Y%m%d") 32 32 33 33 34 - @home_bp.route("/api/graph") 35 - def api_graph(): 36 - """Return nodes + edges for the knowledge graph visualization. 37 - 38 - Query params: 39 - facet: filter by facet name 40 - since: YYYYMMDD start date 41 - types: comma-separated entity types to include 42 - min_strength: minimum score threshold 43 - limit: max nodes (default 100) 44 - """ 45 - facet = request.args.get("facet") or None 46 - since = request.args.get("since") or None 47 - types_param = request.args.get("types") or None 48 - min_strength = request.args.get("min_strength", type=float, default=0.0) 49 - limit = request.args.get("limit", type=int, default=100) 50 - 51 - # Default to 90 days if no since provided 52 - if not since: 53 - since = (date.today() - timedelta(days=90)).strftime("%Y%m%d") 54 - 55 - # Get ranked entities 56 - ranked = get_entity_strength(facet=facet, since=since, limit=limit * 3) 57 - 58 - # Look up entity type and is_principal from the identity table 59 - conn, _ = get_journal_index() 34 + def _load_flow_md(today: str) -> tuple[str | None, float | None]: 35 + """Load today's flow.md content and mtime. Returns (content, mtime) or (None, None).""" 60 36 try: 61 - entity_meta = _load_entity_metadata(conn) 62 - 63 - # Build nodes, applying filters 64 - type_filter = None 65 - if types_param: 66 - type_filter = {t.strip().lower() for t in types_param.split(",")} 67 - 68 - nodes = [] 69 - node_names: set[str] = set() 70 - for r in ranked: 71 - if r["score"] < min_strength: 72 - continue 73 - 74 - entity_name = r["entity_name"] 75 - entity_id = r.get("entity_id") or "" 76 - meta = entity_meta.get(entity_id, {}) 77 - entity_type = (meta.get("type") or "unknown").lower() 78 - 79 - if type_filter and entity_type not in type_filter: 80 - continue 81 - 82 - is_principal = meta.get("is_principal", False) 83 - nodes.append( 84 - { 85 - "id": entity_id or entity_name, 86 - "name": meta.get("name") or entity_name, 87 - "type": entity_type, 88 - "score": r["score"], 89 - "kg_edge_count": r["kg_edge_count"], 90 - "co_occurrence": r["co_occurrence"], 91 - "appearance": r["appearance"], 92 - "recency": r["recency"], 93 - "facet_breadth": r["facet_breadth"], 94 - "observation_depth": r["observation_depth"], 95 - "is_principal": is_principal, 96 - } 97 - ) 98 - node_names.add(entity_name) 99 - if entity_id: 100 - node_names.add(entity_id) 37 + journal = Path(get_journal()) 38 + flow_path = journal / today / "agents" / "flow.md" 39 + if flow_path.exists(): 40 + return flow_path.read_text(), flow_path.stat().st_mtime 41 + except Exception: 42 + pass 43 + return None, None 101 44 102 - if len(nodes) >= limit: 103 - break 104 45 105 - # Build edges 106 - explicit_edges = _get_explicit_edges(conn, node_names, facet, since) 107 - co_occurrence_edges = _get_co_occurrence_edges( 108 - conn, node_names, explicit_edges, facet, since 109 - ) 110 - 111 - # Map edge entity names to node IDs 112 - name_to_id = _build_name_to_node_id(conn, {n["id"] for n in nodes}) 113 - edges = [] 114 - for e in explicit_edges + co_occurrence_edges: 115 - from_id = name_to_id.get(e["from_name"], e["from_name"]) 116 - to_id = name_to_id.get(e["to_name"], e["to_name"]) 117 - # Only include edges where both endpoints are visible nodes 118 - node_ids = {n["id"] for n in nodes} 119 - if from_id in node_ids and to_id in node_ids: 120 - e["from"] = from_id 121 - e["to"] = to_id 122 - edges.append(e) 46 + def _load_stats(today: str) -> dict[str, Any]: 47 + """Load per-day stats.json. Returns empty dict if missing.""" 48 + try: 49 + journal = Path(get_journal()) 50 + stats_path = journal / today / "stats.json" 51 + if stats_path.exists(): 52 + return json.loads(stats_path.read_text()) 53 + except Exception: 54 + pass 55 + return {} 123 56 124 - # Stats 125 - total_entities = conn.execute( 126 - "SELECT COUNT(DISTINCT entity_id) FROM entities WHERE source='identity'" 127 - ).fetchone()[0] 128 - total_signals = conn.execute("SELECT COUNT(*) FROM entity_signals").fetchone()[ 129 - 0 130 - ] 131 57 132 - finally: 133 - conn.close() 58 + def _collect_todos(today: str) -> list[dict[str, Any]]: 59 + """Collect pending todos across all facets.""" 60 + from apps.todos.todo import get_todos 134 61 135 - # Count edges by type 136 - explicit_edge_count = sum(1 for e in edges if e.get("edge_type") == "explicit") 137 - co_occurrence_edge_count = sum( 138 - 1 for e in edges if e.get("edge_type") == "co_occurrence" 139 - ) 62 + todos = [] 63 + try: 64 + facets = get_facets() 65 + except Exception: 66 + return [] 140 67 141 - return jsonify( 142 - { 143 - "nodes": nodes, 144 - "edges": edges, 145 - "stats": { 146 - "total_entities": total_entities, 147 - "total_signals": total_signals, 148 - "explicit_edge_count": explicit_edge_count, 149 - "co_occurrence_edge_count": co_occurrence_edge_count, 150 - }, 151 - } 152 - ) 68 + for facet_name in facets: 69 + facet_todos = get_todos(today, facet_name) 70 + if facet_todos is None: 71 + continue 72 + for todo in facet_todos: 73 + if not todo.get("completed") and not todo.get("cancelled"): 74 + todo["facet"] = facet_name 75 + todos.append(todo) 76 + return todos 153 77 154 78 155 - @home_bp.route("/api/entity/<path:name>") 156 - def api_entity(name: str): 157 - """Return full entity intelligence for the inspect panel.""" 158 - facet = request.args.get("facet") or None 159 - result = get_entity_intelligence(name, facet=facet) 160 - if result is None: 161 - return jsonify({"error": "Entity not found"}), 404 162 - return jsonify(result) 79 + def _collect_events(today: str) -> list[dict[str, Any]]: 80 + """Collect calendar events across all facets.""" 81 + from think.indexer.journal import get_events 163 82 83 + try: 84 + return get_events(today) 85 + except Exception: 86 + return [] 164 87 165 - def _load_entity_metadata(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: 166 - """Load identity metadata (type, name, is_principal) for all entities.""" 167 - rows = conn.execute( 168 - "SELECT entity_id, name, type, is_principal FROM entities WHERE source='identity'" 169 - ).fetchall() 170 - return { 171 - r[0]: {"name": r[1], "type": r[2] or "unknown", "is_principal": bool(r[3])} 172 - for r in rows 173 - } 174 88 89 + def _collect_activities(today: str) -> list[dict[str, Any]]: 90 + """Collect recent activities across all facets, last ~4 hours.""" 91 + from think.activities import load_activity_records 175 92 176 - def _get_explicit_edges( 177 - conn: sqlite3.Connection, 178 - node_names: set[str], 179 - facet: str | None, 180 - since: str | None, 181 - ) -> list[dict[str, Any]]: 182 - """Query kg_edge signals for explicit relationship edges.""" 183 - if not node_names: 93 + activities = [] 94 + try: 95 + facets = get_facets() 96 + except Exception: 184 97 return [] 185 98 186 - placeholders = ",".join("?" for _ in node_names) 187 - where_parts = [ 188 - "signal_type='kg_edge'", 189 - f"entity_name IN ({placeholders})", 190 - ] 191 - params: list[Any] = list(node_names) 192 - 193 - if facet: 194 - where_parts.append("facet=?") 195 - params.append(facet.lower()) 196 - if since: 197 - where_parts.append("day>=?") 198 - params.append(since) 199 - 200 - where = " AND ".join(where_parts) 201 - rows = conn.execute( 202 - f""" 203 - SELECT entity_name, target_name, relationship_type, COUNT(*) as freq 204 - FROM entity_signals 205 - WHERE {where} 206 - AND target_name IS NOT NULL AND target_name != '' 207 - GROUP BY entity_name, target_name, relationship_type 208 - """, 209 - params, 210 - ).fetchall() 99 + now = datetime.now() 100 + cutoff_ts = (now - timedelta(hours=4)).timestamp() * 1000 # ms 211 101 212 - return [ 213 - { 214 - "from_name": r[0], 215 - "to_name": r[1], 216 - "relationship_type": r[2] or "", 217 - "frequency": r[3], 218 - "edge_type": "explicit", 219 - } 220 - for r in rows 221 - if not is_noise_entity(r[0]) and not is_noise_entity(r[1] or "") 222 - ] 102 + for facet_name in facets: 103 + records = load_activity_records(facet_name, today) 104 + for record in records: 105 + created_at = record.get("created_at", 0) 106 + if created_at < cutoff_ts: 107 + continue 108 + # Convert ms timestamp to HH:MM for display 109 + try: 110 + dt = datetime.fromtimestamp(created_at / 1000) 111 + record["display_time"] = dt.strftime("%H:%M") 112 + except (OSError, ValueError): 113 + record["display_time"] = "" 114 + record["facet"] = facet_name 115 + activities.append(record) 223 116 117 + activities.sort(key=lambda a: a.get("created_at", 0), reverse=True) 118 + return activities 224 119 225 - def _get_co_occurrence_edges( 226 - conn: sqlite3.Connection, 227 - node_names: set[str], 228 - explicit_edges: list[dict[str, Any]], 229 - facet: str | None, 230 - since: str | None, 231 - ) -> list[dict[str, Any]]: 232 - """Find co-occurrence edges: entity pairs sharing paths, minus explicit edges.""" 233 - if not node_names: 234 - return [] 235 120 236 - # Build set of already-explicit pairs 237 - explicit_pairs: set[tuple[str, str]] = set() 238 - for e in explicit_edges: 239 - explicit_pairs.add((e["from_name"], e["to_name"])) 240 - explicit_pairs.add((e["to_name"], e["from_name"])) 121 + def _collect_entities_today(today: str) -> list[dict[str, Any]]: 122 + """Get today's entities from entity_signals table.""" 123 + try: 124 + conn, _ = get_journal_index() 125 + try: 126 + rows = conn.execute( 127 + """SELECT entity_name, COUNT(*) as signal_count, 128 + GROUP_CONCAT(DISTINCT signal_type) as types 129 + FROM entity_signals 130 + WHERE day = ? 131 + GROUP BY entity_name 132 + ORDER BY signal_count DESC 133 + LIMIT 8""", 134 + (today,), 135 + ).fetchall() 241 136 242 - # Exclude principal entities — they co-occur with everyone by definition 243 - principal_names = get_principal_entity_names(conn) 137 + entity_meta = {} 138 + meta_rows = conn.execute( 139 + "SELECT entity_id, name, type FROM entities WHERE source='identity'" 140 + ).fetchall() 141 + for row in meta_rows: 142 + entity_meta[row[0]] = {"name": row[1], "type": row[2] or "unknown"} 143 + entity_meta[row[1].lower()] = { 144 + "name": row[1], 145 + "type": row[2] or "unknown", 146 + } 244 147 245 - placeholders = ",".join("?" for _ in node_names) 246 - where_parts = [ 247 - f"s1.entity_name IN ({placeholders})", 248 - f"s2.entity_name IN ({placeholders})", 249 - "s1.entity_name < s2.entity_name", 250 - ] 251 - params: list[Any] = list(node_names) + list(node_names) 148 + entities = [] 149 + for row in rows: 150 + name = row[0] 151 + meta = entity_meta.get(name, entity_meta.get(name.lower(), {})) 152 + entities.append( 153 + { 154 + "name": meta.get("name", name), 155 + "signal_count": row[1], 156 + "types": row[2] or "", 157 + "entity_type": meta.get("type", "unknown"), 158 + } 159 + ) 160 + return entities 161 + finally: 162 + conn.close() 163 + except Exception: 164 + return [] 252 165 253 - if principal_names: 254 - ph = ",".join("?" for _ in principal_names) 255 - where_parts.append(f"s1.entity_name NOT IN ({ph})") 256 - params.extend(principal_names) 257 - where_parts.append(f"s2.entity_name NOT IN ({ph})") 258 - params.extend(principal_names) 259 - if facet: 260 - where_parts.append("s1.facet=?") 261 - params.append(facet.lower()) 262 - if since: 263 - where_parts.append("s1.day>=?") 264 - params.append(since) 265 166 266 - where = " AND ".join(where_parts) 267 - rows = conn.execute( 268 - f""" 269 - SELECT s1.entity_name, s2.entity_name, COUNT(DISTINCT s1.path) as freq 270 - FROM entity_signals s1 271 - JOIN entity_signals s2 272 - ON s1.path = s2.path 273 - AND s1.entity_name != s2.entity_name 274 - WHERE {where} 275 - GROUP BY s1.entity_name, s2.entity_name 276 - HAVING freq >= 2 277 - """, 278 - params, 279 - ).fetchall() 167 + def _build_pulse_context() -> dict[str, Any]: 168 + """Build the full Pulse page context.""" 169 + today = _today() 170 + now = datetime.now() 280 171 281 - edges = [] 282 - for r in rows: 283 - if (r[0], r[1]) in explicit_pairs: 284 - continue 285 - if is_noise_entity(r[0]) or is_noise_entity(r[1]): 286 - continue 287 - edges.append( 288 - { 289 - "from_name": r[0], 290 - "to_name": r[1], 291 - "frequency": r[2], 292 - "edge_type": "co_occurrence", 293 - } 294 - ) 172 + awareness = get_current() 173 + capture_status = awareness.get("capture", {}).get("status", "unknown") 174 + cached = get_cached_state() 175 + last_observe_ts = cached.get("last_observe_ts") 176 + attention = _resolve_attention(awareness) 295 177 296 - return edges 178 + stats_data = _load_stats(today) 179 + stats = stats_data.get("stats", {}) 180 + segment_count = stats.get("transcript_segments", 0) 181 + duration_seconds = stats.get("transcript_duration", 0) 182 + duration_minutes = round(duration_seconds / 60) if duration_seconds else 0 183 + facet_data = stats_data.get("facet_data", {}) 297 184 185 + flow_content, flow_mtime = _load_flow_md(today) 186 + flow_updated_at = None 187 + if flow_mtime: 188 + flow_updated_at = datetime.fromtimestamp(flow_mtime).strftime("%H:%M") 298 189 299 - def _build_name_to_node_id( 300 - conn: sqlite3.Connection, 301 - node_ids: set[str], 302 - ) -> dict[str, str]: 303 - """Map signal entity_names to node IDs (entity_ids) for edge matching. 190 + events = _collect_events(today) 191 + activities = _collect_activities(today) 192 + todos = _collect_todos(today) 193 + entities = _collect_entities_today(today) 304 194 305 - Uses shared name resolution (build_name_resolution_map) for consistent 306 - matching — the same name resolves the same way everywhere. 307 - """ 308 - import json as _json 195 + last_observe_relative = None 196 + if last_observe_ts: 197 + try: 198 + delta = now - datetime.fromtimestamp(last_observe_ts) 199 + if delta.total_seconds() < 60: 200 + last_observe_relative = "just now" 201 + elif delta.total_seconds() < 3600: 202 + mins = int(delta.total_seconds() / 60) 203 + last_observe_relative = f"{mins}m ago" 204 + else: 205 + hours = int(delta.total_seconds() / 3600) 206 + last_observe_relative = f"{hours}h ago" 207 + except Exception: 208 + pass 309 209 310 - from think.entities.matching import build_name_resolution_map 210 + return { 211 + "today": today, 212 + "now": now, 213 + "capture_status": capture_status, 214 + "last_observe_relative": last_observe_relative, 215 + "attention": attention, 216 + "segment_count": segment_count, 217 + "duration_minutes": duration_minutes, 218 + "facet_data": facet_data, 219 + "flow_content": flow_content, 220 + "flow_updated_at": flow_updated_at, 221 + "events": events, 222 + "activities": activities, 223 + "todos": todos, 224 + "entities": entities, 225 + } 311 226 312 - rows = conn.execute( 313 - "SELECT entity_id, name, aka FROM entities WHERE source='identity'" 314 - ).fetchall() 315 227 316 - # Build entity dicts filtered to entities in node_ids 317 - entity_dicts: list[dict[str, Any]] = [] 318 - for entity_id, name, aka_str in rows: 319 - if entity_id not in node_ids: 320 - continue 321 - d: dict[str, Any] = {"id": entity_id, "name": name, "aka": []} 322 - if aka_str: 323 - try: 324 - aka_list = _json.loads(aka_str) 325 - if isinstance(aka_list, list): 326 - d["aka"] = aka_list 327 - except (ValueError, TypeError): 328 - pass 329 - entity_dicts.append(d) 228 + @home_bp.route("/") 229 + def index(): 230 + ctx = _build_pulse_context() 231 + return render_template("app.html", **ctx) 330 232 331 - # Collect all names that may appear as edge endpoints 332 - entity_name_rows = conn.execute( 333 - "SELECT DISTINCT entity_name FROM entity_signals" 334 - ).fetchall() 335 - target_name_rows = conn.execute( 336 - "SELECT DISTINCT target_name FROM entity_signals " 337 - "WHERE target_name IS NOT NULL AND target_name != ''" 338 - ).fetchall() 339 - all_names = list( 340 - {r[0] for r in entity_name_rows} | {r[0] for r in target_name_rows} 341 - ) 342 233 343 - return build_name_resolution_map(all_names, entity_dicts) 234 + @home_bp.route("/api/pulse") 235 + def api_pulse(): 236 + """Aggregated JSON for client-side refresh after WebSocket events.""" 237 + ctx = _build_pulse_context() 238 + attention = ctx.get("attention") 239 + if attention: 240 + ctx["attention"] = { 241 + "placeholder_text": attention.placeholder_text, 242 + "context_lines": attention.context_lines, 243 + } 244 + ctx["now"] = ctx["now"].isoformat() 245 + return jsonify(ctx)
+392 -615
apps/home/workspace.html
··· 1 - <div class="workspace-content" style="display:flex;flex-direction:column;height:calc(100vh - var(--facet-bar-height) - var(--app-bar-height) - 24px);overflow:hidden;"> 2 - <!-- Filter controls --> 3 - <div class="graph-controls" id="graph-controls" style="display:none;"> 4 - <div class="graph-controls-row"> 5 - <div class="graph-type-filters"> 6 - <button class="graph-type-btn active" data-type="person" style="--btn-color:#3b82f6">Person</button> 7 - <button class="graph-type-btn active" data-type="company" style="--btn-color:#22c55e">Company</button> 8 - <button class="graph-type-btn active" data-type="project" style="--btn-color:#f59e0b">Project</button> 9 - <button class="graph-type-btn active" data-type="tool" style="--btn-color:#6b7280">Tool</button> 10 - </div> 11 - <div class="graph-time-filters"> 12 - <button class="graph-time-btn" data-days="7">7d</button> 13 - <button class="graph-time-btn" data-days="30">30d</button> 14 - <button class="graph-time-btn active" data-days="90">90d</button> 15 - <button class="graph-time-btn" data-days="">All</button> 16 - </div> 17 - <div class="graph-strength-filter"> 18 - <label for="min-strength">Min strength</label> 19 - <input type="range" id="min-strength" min="0" max="500" value="0" step="5"> 20 - <span id="min-strength-val">0</span> 21 - </div> 22 - <div class="graph-stats" id="graph-stats"></div> 23 - </div> 24 - </div> 25 - 26 - <!-- Graph container --> 27 - <div id="graph-container" style="flex:1;position:relative;min-height:0;"> 28 - <div class="graph-loading" id="graph-loading">Loading knowledge graph...</div> 29 - <div class="graph-empty" id="graph-empty" style="display:none;"> 30 - <div class="graph-empty-icon">🕸️</div> 31 - <h2>Your knowledge graph builds itself from daily use</h2> 32 - <p>As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p> 33 - </div> 34 - <div id="graph-canvas" style="width:100%;height:100%;display:none;"></div> 35 - </div> 36 - 37 - <!-- Entity detail panel (slide-in from right) --> 38 - <div class="graph-detail-panel" id="graph-detail-panel"> 39 - <div class="graph-detail-header"> 40 - <h2 id="detail-name"></h2> 41 - <button class="graph-detail-close" id="detail-close">&times;</button> 42 - </div> 43 - <div class="graph-detail-body" id="detail-body"></div> 44 - </div> 45 - </div> 46 - 47 1 <style> 48 - /* Controls bar */ 49 - .graph-controls { 50 - padding: 0.5rem 0.75rem; 51 - border-bottom: 1px solid #e5e7eb; 52 - background: #fafafa; 53 - flex-shrink: 0; 2 + /* Pulse Landing Page */ 3 + .pulse-dashboard { 4 + padding: 1.5rem 2rem; 5 + max-width: 900px; 6 + margin: 0 auto; 7 + display: flex; 8 + flex-direction: column; 9 + gap: 1.5rem; 54 10 } 55 - .graph-controls-row { 11 + 12 + /* Vital Signs */ 13 + .pulse-vitals { 56 14 display: flex; 57 15 align-items: center; 58 - gap: 1rem; 16 + gap: 1.5rem; 17 + padding: 1rem 1.25rem; 18 + background: #f8fafc; 19 + border-radius: 10px; 20 + border: 1px solid #e2e8f0; 59 21 flex-wrap: wrap; 60 22 } 61 - .graph-type-filters, .graph-time-filters { 23 + 24 + .pulse-vitals-item { 62 25 display: flex; 63 - gap: 0.25rem; 26 + align-items: center; 27 + gap: 0.4rem; 28 + font-size: 0.85rem; 29 + color: #475569; 64 30 } 65 - .graph-type-btn, .graph-time-btn { 66 - padding: 0.25rem 0.6rem; 67 - border: 1px solid #d1d5db; 68 - border-radius: 4px; 69 - background: white; 70 - font-size: 0.8rem; 71 - cursor: pointer; 72 - transition: all 0.15s; 73 - color: #374151; 31 + 32 + .pulse-vitals-dot { 33 + width: 8px; 34 + height: 8px; 35 + border-radius: 50%; 36 + flex-shrink: 0; 74 37 } 75 - .graph-type-btn.active { 76 - background: var(--btn-color, #3b82f6); 77 - color: white; 78 - border-color: var(--btn-color, #3b82f6); 38 + 39 + .pulse-vitals-dot.ok { background: #4ade80; } 40 + .pulse-vitals-dot.stale { background: #fbbf24; } 41 + .pulse-vitals-dot.offline { background: #f87171; } 42 + .pulse-vitals-dot.unknown { background: #9ca3af; } 43 + 44 + .pulse-vitals-sep { 45 + width: 1px; 46 + height: 1rem; 47 + background: #cbd5e1; 79 48 } 80 - .graph-time-btn.active { 81 - background: #374151; 82 - color: white; 83 - border-color: #374151; 84 - } 85 - .graph-type-btn:hover, .graph-time-btn:hover { 86 - opacity: 0.85; 87 - } 88 - .graph-strength-filter { 89 - display: flex; 90 - align-items: center; 91 - gap: 0.4rem; 49 + 50 + .pulse-vitals a { 51 + color: #6366f1; 52 + text-decoration: none; 92 53 font-size: 0.8rem; 93 - color: #6b7280; 94 54 } 95 - .graph-strength-filter input[type=range] { 96 - width: 80px; 97 - height: 4px; 55 + 56 + .pulse-vitals a:hover { text-decoration: underline; } 57 + 58 + .pulse-vitals-attention { 59 + color: #dc2626; 60 + font-weight: 500; 98 61 } 99 - .graph-stats { 100 - margin-left: auto; 101 - font-size: 0.8rem; 102 - color: #6b7280; 62 + 63 + /* Section Headers */ 64 + .pulse-section-header { 65 + font-size: 0.75rem; 66 + font-weight: 600; 67 + text-transform: uppercase; 68 + letter-spacing: 0.5px; 69 + color: #94a3b8; 70 + margin-bottom: 0.5rem; 103 71 } 104 72 105 - /* Loading / empty states */ 106 - .graph-loading { 107 - text-align: center; 108 - padding: 4em; 109 - color: #666; 73 + /* Narrative */ 74 + .pulse-narrative { 75 + padding: 1.25rem; 76 + background: #fff; 77 + border-radius: 10px; 78 + border: 1px solid #e2e8f0; 110 79 } 111 - .graph-empty { 112 - text-align: center; 113 - padding: 4em 2em; 114 - max-width: 450px; 115 - margin: 2em auto; 80 + 81 + .pulse-narrative-content { 82 + font-size: 0.9rem; 83 + line-height: 1.6; 84 + color: #334155; 116 85 } 117 - .graph-empty-icon { 118 - font-size: 4em; 119 - margin-bottom: 0.25em; 86 + 87 + .pulse-narrative-content h1 { font-size: 1.1rem; margin: 0 0 0.75rem; color: #1e293b; } 88 + .pulse-narrative-content h2 { font-size: 1rem; margin: 0.75rem 0 0.5rem; color: #1e293b; } 89 + .pulse-narrative-content p { margin: 0 0 0.5rem; } 90 + 91 + .pulse-narrative-meta { 92 + margin-top: 0.75rem; 93 + font-size: 0.75rem; 94 + color: #94a3b8; 120 95 } 121 - .graph-empty h2 { 122 - margin: 0 0 0.5em 0; 123 - font-size: 1.3em; 124 - font-weight: 600; 125 - color: #333; 96 + 97 + .pulse-narrative-empty { 98 + color: #94a3b8; 99 + font-style: italic; 100 + font-size: 0.85rem; 126 101 } 127 - .graph-empty p { 128 - margin: 0; 129 - color: #666; 130 - line-height: 1.5; 102 + 103 + /* Today Section */ 104 + .pulse-today { 105 + padding: 1.25rem; 106 + background: #fff; 107 + border-radius: 10px; 108 + border: 1px solid #e2e8f0; 131 109 } 132 110 133 - /* Detail panel */ 134 - .graph-detail-panel { 135 - position: absolute; 136 - top: 0; 137 - right: 0; 138 - width: 340px; 139 - max-width: 90vw; 140 - height: 100%; 141 - background: white; 142 - box-shadow: -2px 0 12px rgba(0,0,0,0.12); 143 - z-index: 20; 144 - transform: translateX(100%); 145 - transition: transform 0.2s ease; 111 + .pulse-events { 146 112 display: flex; 147 113 flex-direction: column; 148 - overflow: hidden; 114 + gap: 0.5rem; 115 + margin-bottom: 1rem; 149 116 } 150 - .graph-detail-panel.open { 151 - transform: translateX(0); 152 - } 153 - .graph-detail-header { 117 + 118 + .pulse-event { 154 119 display: flex; 155 - align-items: center; 156 - justify-content: space-between; 157 - padding: 0.75rem 1rem; 158 - border-bottom: 1px solid #e5e7eb; 159 - flex-shrink: 0; 120 + align-items: baseline; 121 + gap: 0.75rem; 122 + font-size: 0.85rem; 123 + color: #334155; 160 124 } 161 - .graph-detail-header h2 { 162 - margin: 0; 163 - font-size: 1.1rem; 164 - font-weight: 600; 165 - color: #111827; 166 - overflow: hidden; 167 - text-overflow: ellipsis; 168 - white-space: nowrap; 125 + 126 + .pulse-event-time { 127 + font-size: 0.8rem; 128 + color: #64748b; 129 + min-width: 50px; 130 + font-variant-numeric: tabular-nums; 169 131 } 170 - .graph-detail-close { 171 - background: none; 172 - border: none; 173 - font-size: 1.5rem; 174 - color: #6b7280; 175 - cursor: pointer; 176 - padding: 0 0.25rem; 177 - line-height: 1; 132 + 133 + .pulse-event-title { flex: 1; } 134 + .pulse-event.past { opacity: 0.6; } 135 + .pulse-event.past .pulse-event-title::before { content: "✓ "; color: #4ade80; } 136 + 137 + .pulse-activities-label { 138 + font-size: 0.75rem; 139 + color: #94a3b8; 140 + text-transform: uppercase; 141 + letter-spacing: 0.5px; 142 + margin-bottom: 0.4rem; 178 143 } 179 - .graph-detail-close:hover { 180 - color: #111827; 144 + 145 + .pulse-activities { 146 + display: flex; 147 + flex-direction: column; 148 + gap: 0.35rem; 181 149 } 182 - .graph-detail-body { 183 - flex: 1; 184 - overflow-y: auto; 185 - padding: 0.75rem 1rem; 186 - font-size: 0.9rem; 187 - color: #374151; 150 + 151 + .pulse-activity { 152 + font-size: 0.8rem; 153 + color: #64748b; 154 + display: flex; 155 + gap: 0.5rem; 188 156 } 189 - .detail-section { 190 - margin-bottom: 1rem; 157 + 158 + .pulse-activity-time { 159 + min-width: 40px; 160 + font-variant-numeric: tabular-nums; 161 + color: #94a3b8; 191 162 } 192 - .detail-section-title { 193 - font-size: 0.7rem; 194 - font-weight: 600; 195 - text-transform: uppercase; 196 - letter-spacing: 0.05em; 197 - color: #6b7280; 198 - margin: 0 0 0.4rem 0; 163 + 164 + .pulse-facet-dist { 165 + display: flex; 166 + gap: 0.75rem; 167 + margin-top: 0.75rem; 168 + flex-wrap: wrap; 199 169 } 200 - .detail-type-badge { 201 - display: inline-block; 202 - padding: 0.15rem 0.5rem; 203 - border-radius: 4px; 170 + 171 + .pulse-facet-chip { 204 172 font-size: 0.75rem; 205 - font-weight: 500; 206 - color: white; 207 - margin-bottom: 0.5rem; 208 - } 209 - .detail-principal-badge { 210 - display: inline-block; 211 - padding: 0.15rem 0.5rem; 173 + padding: 0.2rem 0.5rem; 174 + background: #f1f5f9; 212 175 border-radius: 4px; 213 - font-size: 0.75rem; 214 - font-weight: 500; 215 - background: #f59e0b; 216 - color: white; 217 - margin-left: 0.4rem; 218 - } 219 - .detail-description { 220 - color: #4b5563; 221 - line-height: 1.5; 222 - margin-bottom: 0.75rem; 176 + color: #475569; 223 177 } 224 - .detail-score-grid { 225 - display: grid; 226 - grid-template-columns: 1fr 1fr; 227 - gap: 0.3rem; 178 + 179 + /* Needs You */ 180 + .pulse-needs { 181 + padding: 1.25rem; 182 + background: #fff; 183 + border-radius: 10px; 184 + border: 1px solid #e2e8f0; 228 185 } 229 - .detail-score-item { 186 + 187 + .pulse-needs-list { 230 188 display: flex; 231 - justify-content: space-between; 232 - font-size: 0.82rem; 233 - } 234 - .detail-score-label { 235 - color: #6b7280; 236 - } 237 - .detail-score-value { 238 - font-weight: 600; 239 - color: #111827; 189 + flex-direction: column; 190 + gap: 0.4rem; 240 191 } 241 - .detail-connected-list { 242 - list-style: none; 243 - padding: 0; 244 - margin: 0; 245 - } 246 - .detail-connected-item { 247 - padding: 0.2rem 0; 248 - border-bottom: 1px solid #f3f4f6; 192 + 193 + .pulse-needs-item { 194 + font-size: 0.85rem; 195 + color: #334155; 249 196 display: flex; 250 - justify-content: space-between; 251 - font-size: 0.82rem; 197 + align-items: baseline; 198 + gap: 0.5rem; 252 199 } 253 - .detail-connected-item:last-child { border-bottom: none; } 254 - .detail-connected-name { 255 - cursor: pointer; 256 - color: #2563eb; 257 - } 258 - .detail-connected-name:hover { text-decoration: underline; } 259 - .detail-connected-rel { 260 - color: #9ca3af; 261 - font-size: 0.75rem; 200 + 201 + .pulse-needs-item::before { 202 + content: "○"; 203 + color: #cbd5e1; 204 + flex-shrink: 0; 262 205 } 263 - .detail-activity-item { 264 - padding: 0.2rem 0; 265 - border-bottom: 1px solid #f3f4f6; 266 - font-size: 0.82rem; 206 + 207 + .pulse-needs-attention { 208 + color: #dc2626; 209 + font-weight: 500; 267 210 } 268 - .detail-activity-item:last-child { border-bottom: none; } 269 - .detail-activity-day { 270 - color: #6b7280; 271 - font-weight: 600; 272 - margin-right: 0.4rem; 211 + 212 + .pulse-needs-attention::before { 213 + content: "⚠"; 214 + color: #dc2626; 273 215 } 274 - .detail-entity-link { 275 - display: inline-block; 216 + 217 + .pulse-needs-more { 218 + font-size: 0.8rem; 219 + color: #6366f1; 276 220 margin-top: 0.5rem; 277 - color: #2563eb; 221 + } 222 + 223 + .pulse-needs-more a { 224 + color: inherit; 278 225 text-decoration: none; 279 - font-size: 0.85rem; 280 226 } 281 - .detail-entity-link:hover { text-decoration: underline; } 282 - </style> 283 227 284 - <script src="{{ vendor_lib('vis-network') }}"></script> 228 + .pulse-needs-more a:hover { text-decoration: underline; } 285 229 286 - <script> 287 - (function() { 288 - // --- State --- 289 - let network = null; 290 - let graphData = null; 291 - let activeTypes = new Set(['person', 'company', 'project', 'tool']); 292 - let timeDays = 90; 293 - let minStrength = 0; 294 - let detailOpen = false; 230 + /* Network Pulse */ 231 + .pulse-network { 232 + padding: 1.25rem; 233 + background: #fff; 234 + border-radius: 10px; 235 + border: 1px solid #e2e8f0; 236 + } 295 237 296 - // --- Color maps --- 297 - const TYPE_COLORS = { 298 - person: '#3b82f6', 299 - company: '#22c55e', 300 - project: '#f59e0b', 301 - tool: '#6b7280', 302 - unknown: '#a1a1aa', 303 - }; 238 + .pulse-entities { 239 + display: flex; 240 + flex-wrap: wrap; 241 + gap: 0.5rem; 242 + } 304 243 305 - const EDGE_REL_COLORS = { 306 - 'works-on': '#8b5cf6', 307 - 'works-at': '#06b6d4', 308 - 'discusses-with': '#ec4899', 309 - 'collaborates-with': '#10b981', 310 - 'manages': '#f97316', 311 - 'reports-to': '#f97316', 312 - 'member-of': '#6366f1', 313 - 'uses': '#64748b', 314 - }; 244 + .pulse-entity { 245 + display: flex; 246 + align-items: center; 247 + gap: 0.35rem; 248 + padding: 0.3rem 0.6rem; 249 + background: #f8fafc; 250 + border-radius: 6px; 251 + border: 1px solid #e2e8f0; 252 + font-size: 0.8rem; 253 + color: #334155; 254 + } 315 255 316 - // --- Helpers --- 317 - function sinceFromDays(days) { 318 - if (!days) return ''; 319 - const d = new Date(); 320 - d.setDate(d.getDate() - days); 321 - return d.toISOString().slice(0,10).replace(/-/g,''); 322 - } 256 + .pulse-entity-type { 257 + font-size: 0.65rem; 258 + padding: 0.1rem 0.3rem; 259 + border-radius: 3px; 260 + text-transform: uppercase; 261 + font-weight: 600; 262 + letter-spacing: 0.3px; 263 + } 323 264 324 - function escapeHtml(text) { 325 - const div = document.createElement('div'); 326 - div.textContent = text || ''; 327 - return div.innerHTML; 328 - } 265 + .pulse-entity-type.person { background: #dbeafe; color: #1d4ed8; } 266 + .pulse-entity-type.company { background: #dcfce7; color: #15803d; } 267 + .pulse-entity-type.project { background: #fef3c7; color: #b45309; } 268 + .pulse-entity-type.tool { background: #f3f4f6; color: #4b5563; } 329 269 330 - function formatDay(d) { 331 - if (!d || d.length < 8) return d || ''; 332 - return d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8); 333 - } 270 + .pulse-network-link { 271 + margin-top: 0.75rem; 272 + font-size: 0.8rem; 273 + } 334 274 335 - // --- Data fetch --- 336 - async function fetchGraph() { 337 - const params = new URLSearchParams(); 338 - const facet = window.selectedFacet; 339 - if (facet) params.set('facet', facet); 340 - const since = sinceFromDays(timeDays); 341 - if (since) params.set('since', since); 342 - const types = Array.from(activeTypes).join(','); 343 - if (types) params.set('types', types); 344 - if (minStrength > 0) params.set('min_strength', minStrength); 345 - params.set('limit', '100'); 346 - 347 - const resp = await fetch('/app/home/api/graph?' + params.toString()); 348 - if (!resp.ok) throw new Error('Failed to fetch graph'); 349 - return resp.json(); 350 - } 275 + .pulse-network-link a { 276 + color: #6366f1; 277 + text-decoration: none; 278 + } 351 279 352 - async function fetchEntity(name) { 353 - const params = new URLSearchParams(); 354 - const facet = window.selectedFacet; 355 - if (facet) params.set('facet', facet); 356 - const resp = await fetch('/app/home/api/entity/' + encodeURIComponent(name) + '?' + params.toString()); 357 - if (!resp.ok) return null; 358 - return resp.json(); 359 - } 360 - 361 - // --- Graph rendering --- 362 - function buildVisData(data) { 363 - const maxScore = Math.max(...data.nodes.map(n => n.score), 1); 364 - const scaleFactor = 30 / maxScore; 280 + .pulse-network-link a:hover { text-decoration: underline; } 281 + </style> 365 282 366 - const nodes = data.nodes.map(n => { 367 - const size = n.is_principal 368 - ? Math.max(30, 10 + n.score * scaleFactor) 369 - : Math.max(10, Math.min(40, 10 + n.score * scaleFactor)); 370 - const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; 371 - return { 372 - id: n.id, 373 - label: n.name, 374 - size: size, 375 - color: { 376 - background: color, 377 - border: n.is_principal ? '#f59e0b' : color, 378 - highlight: { background: color, border: n.is_principal ? '#f59e0b' : '#111827' }, 379 - hover: { background: color, border: n.is_principal ? '#f59e0b' : '#374151' }, 380 - }, 381 - borderWidth: n.is_principal ? 3 : 1.5, 382 - font: { size: Math.max(14, Math.min(22, 10 + size * 0.3)), color: '#374151' }, 383 - title: n.name + ' (' + n.type + ') — score: ' + n.score.toFixed(1), 384 - _data: n, 385 - }; 386 - }); 387 - 388 - const edges = data.edges.map((e, i) => { 389 - if (e.edge_type === 'explicit') { 390 - const relColor = EDGE_REL_COLORS[e.relationship_type] || '#9ca3af'; 391 - return { 392 - id: 'e' + i, 393 - from: e.from, 394 - to: e.to, 395 - width: 1 + Math.min(4, e.frequency * 0.5), 396 - color: { color: relColor, opacity: 0.7, highlight: relColor, hover: relColor }, 397 - arrows: { to: { enabled: true, scaleFactor: 0.5 } }, 398 - smooth: { type: 'curvedCW', roundness: 0.15 }, 399 - title: (e.relationship_type || 'related') + ' (' + e.frequency + ')', 400 - }; 401 - } else { 402 - return { 403 - id: 'e' + i, 404 - from: e.from, 405 - to: e.to, 406 - width: 1 + Math.min(3, e.frequency * 0.3), 407 - color: { color: '#d1d5db', opacity: 0.5, highlight: '#9ca3af', hover: '#9ca3af' }, 408 - dashes: [4, 4], 409 - smooth: { type: 'curvedCW', roundness: 0.1 }, 410 - title: 'co-occurrence (' + e.frequency + ')', 411 - }; 412 - } 413 - }); 283 + <div class="pulse-dashboard"> 414 284 415 - return { 416 - nodes: new vis.DataSet(nodes), 417 - edges: new vis.DataSet(edges), 418 - }; 419 - } 285 + <!-- Vital Signs --> 286 + <div class="pulse-vitals" id="pulse-vitals"> 287 + {% set dot_class = 'ok' if capture_status == 'ok' else ('stale' if capture_status == 'stale' else ('offline' if capture_status == 'offline' else 'unknown')) %} 288 + <div class="pulse-vitals-item"> 289 + <span class="pulse-vitals-dot {{ dot_class }}"></span> 290 + <span>Capture {{ capture_status }}</span> 291 + </div> 292 + {% if segment_count > 0 %} 293 + <div class="pulse-vitals-sep"></div> 294 + <div class="pulse-vitals-item"> 295 + {{ segment_count }} segment{{ 's' if segment_count != 1 else '' }}{% if duration_minutes > 0 %} · {{ duration_minutes }}m{% endif %} 296 + </div> 297 + {% endif %} 298 + {% if last_observe_relative %} 299 + <div class="pulse-vitals-sep"></div> 300 + <div class="pulse-vitals-item"> 301 + Last observation {{ last_observe_relative }} 302 + </div> 303 + {% endif %} 304 + {% if attention %} 305 + <div class="pulse-vitals-sep"></div> 306 + <div class="pulse-vitals-item pulse-vitals-attention"> 307 + {{ attention.placeholder_text }} 308 + </div> 309 + {% endif %} 310 + <div style="margin-left:auto"> 311 + <a href="/app/health">Health →</a> 312 + </div> 313 + </div> 420 314 421 - function renderGraph(data) { 422 - graphData = data; 423 - const container = document.getElementById('graph-canvas'); 424 - const loading = document.getElementById('graph-loading'); 425 - const empty = document.getElementById('graph-empty'); 426 - const controls = document.getElementById('graph-controls'); 315 + <!-- Narrative --> 316 + {% if flow_content is not none %} 317 + <div class="pulse-narrative" id="pulse-narrative"> 318 + <div class="pulse-section-header">Today's Flow</div> 319 + <div class="pulse-narrative-content" id="pulse-narrative-content"></div> 320 + {% if flow_updated_at %} 321 + <div class="pulse-narrative-meta">Updated at {{ flow_updated_at }}</div> 322 + {% endif %} 323 + </div> 324 + {% elif segment_count > 0 %} 325 + <div class="pulse-narrative" id="pulse-narrative"> 326 + <div class="pulse-section-header">Today's Flow</div> 327 + <div class="pulse-narrative-empty">Analysis will be available after the next processing cycle.</div> 328 + </div> 329 + {% endif %} 427 330 428 - loading.style.display = 'none'; 331 + <!-- Today --> 332 + {% if events or activities %} 333 + <div class="pulse-today" id="pulse-today"> 334 + <div class="pulse-section-header">Today</div> 335 + {% if events %} 336 + <div class="pulse-events"> 337 + {% for event in events %} 338 + {% set is_past = event.get('occurred', false) or (event.get('end', '') and event['end'] < now.strftime('%H:%M:%S')) %} 339 + <div class="pulse-event{% if is_past %} past{% endif %}"> 340 + <span class="pulse-event-time">{{ event.get('start', '')[:5] }}</span> 341 + <span class="pulse-event-title">{{ event.get('title', 'Untitled') }}</span> 342 + </div> 343 + {% endfor %} 344 + </div> 345 + {% endif %} 346 + {% if activities %} 347 + <div class="pulse-activities-label">Recent Activity</div> 348 + <div class="pulse-activities"> 349 + {% for act in activities[:6] %} 350 + <div class="pulse-activity"> 351 + <span class="pulse-activity-time">{{ act.get('display_time', '') }}</span> 352 + <span>{{ act.get('description', act.get('activity', '')) }}</span> 353 + </div> 354 + {% endfor %} 355 + </div> 356 + {% endif %} 357 + {% if facet_data %} 358 + <div class="pulse-facet-dist"> 359 + {% for fname, fdata in facet_data.items() %} 360 + <span class="pulse-facet-chip">{{ fname }} · {{ fdata.get('minutes', 0)|int }}m</span> 361 + {% endfor %} 362 + </div> 363 + {% endif %} 364 + </div> 365 + {% endif %} 429 366 430 - if (!data.nodes || data.nodes.length === 0) { 431 - container.style.display = 'none'; 432 - empty.style.display = 'block'; 433 - controls.style.display = 'none'; 434 - return; 435 - } 367 + <!-- Needs You --> 368 + {% if attention or todos %} 369 + <div class="pulse-needs" id="pulse-needs"> 370 + <div class="pulse-section-header">Needs You</div> 371 + <div class="pulse-needs-list"> 372 + {% if attention %} 373 + <div class="pulse-needs-item pulse-needs-attention">{{ attention.placeholder_text }}</div> 374 + {% endif %} 375 + {% for todo in todos[:7] %} 376 + <div class="pulse-needs-item">{{ todo.get('text', '') }}{% if todo.get('facet') %} <span style="color:#94a3b8;font-size:0.75rem">({{ todo['facet'] }})</span>{% endif %}</div> 377 + {% endfor %} 378 + </div> 379 + {% if todos|length > 7 %} 380 + <div class="pulse-needs-more"><a href="/app/todos">and {{ todos|length - 7 }} more →</a></div> 381 + {% endif %} 382 + </div> 383 + {% endif %} 436 384 437 - empty.style.display = 'none'; 438 - container.style.display = 'block'; 439 - controls.style.display = 'block'; 385 + <!-- Network Pulse --> 386 + {% if entities %} 387 + <div class="pulse-network" id="pulse-network"> 388 + <div class="pulse-section-header">Network Pulse</div> 389 + <div class="pulse-entities"> 390 + {% for ent in entities %} 391 + <div class="pulse-entity"> 392 + <span class="pulse-entity-type {{ ent.get('entity_type', 'unknown') }}">{{ ent.get('entity_type', '?')[:3] }}</span> 393 + <span>{{ ent.get('name', '') }}</span> 394 + </div> 395 + {% endfor %} 396 + </div> 397 + <div class="pulse-network-link"><a href="/app/graph">View full graph →</a></div> 398 + </div> 399 + {% endif %} 440 400 441 - const visData = buildVisData(data); 401 + </div> 442 402 443 - const options = { 444 - physics: { 445 - solver: 'forceAtlas2Based', 446 - forceAtlas2Based: { 447 - gravitationalConstant: -200, 448 - centralGravity: 0.005, 449 - springLength: 230, 450 - springConstant: 0.015, 451 - damping: 0.4, 452 - avoidOverlap: 0.8, 453 - }, 454 - stabilization: { iterations: 1000 }, 455 - }, 456 - nodes: { 457 - shape: 'dot', 458 - scaling: { min: 10, max: 40 }, 459 - borderWidth: 1.5, 460 - shadow: { enabled: true, size: 4, x: 1, y: 1, color: 'rgba(0,0,0,0.1)' }, 461 - }, 462 - edges: { 463 - smooth: { enabled: true, type: 'curvedCW', roundness: 0.15 }, 464 - }, 465 - interaction: { 466 - hover: true, 467 - tooltipDelay: 100, 468 - hideEdgesOnDrag: true, 469 - hideEdgesOnZoom: true, 470 - }, 471 - layout: { 472 - improvedLayout: true, 473 - }, 474 - }; 475 - 476 - if (network) { 477 - network.setData(visData); 478 - } else { 479 - network = new vis.Network(container, visData, options); 480 - 481 - // Fit graph to container after stabilization 482 - network.on('stabilizationIterationsDone', function() { 483 - network.fit({ animation: { duration: 300 } }); 484 - }); 485 - 486 - // Click node → inspect 487 - network.on('click', function(params) { 488 - if (params.nodes.length > 0) { 489 - const nodeId = params.nodes[0]; 490 - const nodeData = visData.nodes.get(nodeId); 491 - if (nodeData && nodeData._data) { 492 - showDetail(nodeData._data); 493 - } 494 - } else { 495 - // Click canvas → dismiss 496 - closeDetail(); 497 - } 498 - }); 499 - } 500 - 501 - // Update stats 502 - updateStats(data); 403 + <script src="{{ vendor_lib('marked') }}"></script> 404 + <script> 405 + (function() { 406 + // Render flow.md content with marked 407 + const flowRaw = {{ (flow_content or '')|tojson|safe }}; 408 + if (flowRaw) { 409 + const el = document.getElementById('pulse-narrative-content'); 410 + if (el) el.innerHTML = marked.parse(flowRaw, {breaks: true, gfm: true}); 503 411 } 504 412 505 - function updateStats(data) { 506 - const el = document.getElementById('graph-stats'); 507 - el.textContent = data.nodes.length + ' nodes, ' + data.edges.length + ' edges'; 508 - } 413 + // WebSocket live updates 414 + if (window.appEvents) { 415 + // Vital signs: supervisor.status, observe.observed, observe.status 416 + window.appEvents.listen('supervisor', function(msg) { 417 + if (msg.event === 'status') refreshVitals(); 418 + }); 419 + window.appEvents.listen('observe', function(msg) { 420 + if (msg.event === 'observed' || msg.event === 'status') refreshVitals(); 421 + }); 509 422 510 - // --- Detail panel --- 511 - function showDetail(nodeData) { 512 - const panel = document.getElementById('graph-detail-panel'); 513 - const nameEl = document.getElementById('detail-name'); 514 - const bodyEl = document.getElementById('detail-body'); 515 - 516 - nameEl.textContent = nodeData.name; 517 - bodyEl.innerHTML = '<div style="text-align:center;padding:2em;color:#999;">Loading...</div>'; 518 - panel.classList.add('open'); 519 - detailOpen = true; 520 - 521 - fetchEntity(nodeData.id).then(intel => { 522 - if (!intel || intel.error) { 523 - bodyEl.innerHTML = '<div style="padding:1em;color:#999;">Could not load entity details.</div>'; 524 - return; 525 - } 526 - renderDetail(intel, nodeData); 423 + // Narrative: cortex.finish where name=flow 424 + window.appEvents.listen('cortex', function(msg) { 425 + if (msg.event === 'finish' && msg.name === 'flow') refreshNarrative(); 426 + if (msg.event === 'error') refreshVitals(); 527 427 }); 528 428 } 529 429 530 - function renderDetail(intel, nodeData) { 531 - const bodyEl = document.getElementById('detail-body'); 532 - const identity = intel.identity || {}; 533 - const strength = intel.strength || {}; 534 - const typeColor = TYPE_COLORS[identity.type?.toLowerCase()] || TYPE_COLORS.unknown; 535 - 536 - let html = ''; 537 - 538 - // Type badge 539 - html += '<div>'; 540 - html += '<span class="detail-type-badge" style="background:' + typeColor + '">' + escapeHtml(identity.type || nodeData.type) + '</span>'; 541 - if (identity.is_principal) { 542 - html += '<span class="detail-principal-badge">You</span>'; 543 - } 544 - html += '</div>'; 545 - 546 - // Description 547 - if (identity.description) { 548 - html += '<div class="detail-description">' + escapeHtml(identity.description) + '</div>'; 549 - } 550 - 551 - // Strength score 552 - html += '<div class="detail-section">'; 553 - html += '<div class="detail-section-title">Strength Score</div>'; 554 - html += '<div style="font-size:1.3em;font-weight:700;color:#111827;margin-bottom:0.3rem;">' + (strength.score || 0).toFixed(1) + '</div>'; 555 - html += '<div class="detail-score-grid">'; 556 - html += scoreItem('Co-occurrence', strength.co_occurrence); 557 - html += scoreItem('Appearances', strength.appearance); 558 - html += scoreItem('Recency', strength.recency?.toFixed(2)); 559 - html += scoreItem('Facet breadth', strength.facet_breadth); 560 - html += scoreItem('Observation depth', strength.observation_depth); 561 - html += '</div></div>'; 562 - 563 - // Connected entities (from network field) 564 - const networkEntities = intel.network || {}; 565 - const connectedNames = Object.keys(networkEntities).sort((a, b) => networkEntities[b] - networkEntities[a]).slice(0, 15); 566 - if (connectedNames.length > 0) { 567 - html += '<div class="detail-section">'; 568 - html += '<div class="detail-section-title">Connected Entities</div>'; 569 - html += '<ul class="detail-connected-list">'; 570 - for (const name of connectedNames) { 571 - html += '<li class="detail-connected-item">'; 572 - html += '<span class="detail-connected-name" data-entity="' + escapeHtml(name) + '">' + escapeHtml(name) + '</span>'; 573 - html += '<span class="detail-connected-rel">' + networkEntities[name] + ' shared</span>'; 574 - html += '</li>'; 575 - } 576 - html += '</ul></div>'; 577 - } 578 - 579 - // Recent activity 580 - const activity = (intel.activity || []).slice(0, 10); 581 - if (activity.length > 0) { 582 - html += '<div class="detail-section">'; 583 - html += '<div class="detail-section-title">Recent Activity</div>'; 584 - for (const a of activity) { 585 - html += '<div class="detail-activity-item">'; 586 - html += '<span class="detail-activity-day">' + formatDay(a.day) + '</span>'; 587 - const label = a.event_title || a.signal_type || ''; 588 - html += escapeHtml(label); 589 - if (a.target_name) html += ' → ' + escapeHtml(a.target_name); 590 - html += '</div>'; 591 - } 592 - html += '</div>'; 593 - } 594 - 595 - // Link to entities app 596 - html += '<a class="detail-entity-link" href="/app/entities#' + encodeURIComponent(identity.entity_id || nodeData.id) + '">View full intelligence →</a>'; 597 - 598 - bodyEl.innerHTML = html; 599 - 600 - // Click connected entity names 601 - bodyEl.querySelectorAll('.detail-connected-name').forEach(el => { 602 - el.addEventListener('click', () => { 603 - const eName = el.dataset.entity; 604 - // Try to find this entity in the current graph 605 - if (graphData) { 606 - const matchNode = graphData.nodes.find(n => n.name === eName || n.id === eName); 607 - if (matchNode) { 608 - showDetail(matchNode); 609 - if (network) network.selectNodes([matchNode.id]); 610 - return; 611 - } 430 + function refreshVitals() { 431 + fetch('/app/home/api/pulse') 432 + .then(r => r.json()) 433 + .then(data => { 434 + const el = document.getElementById('pulse-vitals'); 435 + if (!el) return; 436 + let dotClass = data.capture_status === 'ok' ? 'ok' : (data.capture_status === 'stale' ? 'stale' : (data.capture_status === 'offline' ? 'offline' : 'unknown')); 437 + let html = '<div class="pulse-vitals-item"><span class="pulse-vitals-dot ' + dotClass + '"></span><span>Capture ' + data.capture_status + '</span></div>'; 438 + if (data.segment_count > 0) { 439 + html += '<div class="pulse-vitals-sep"></div><div class="pulse-vitals-item">' + data.segment_count + ' segment' + (data.segment_count !== 1 ? 's' : ''); 440 + if (data.duration_minutes > 0) html += ' · ' + data.duration_minutes + 'm'; 441 + html += '</div>'; 442 + } 443 + if (data.last_observe_relative) { 444 + html += '<div class="pulse-vitals-sep"></div><div class="pulse-vitals-item">Last observation ' + data.last_observe_relative + '</div>'; 445 + } 446 + if (data.attention) { 447 + html += '<div class="pulse-vitals-sep"></div><div class="pulse-vitals-item pulse-vitals-attention">' + data.attention.placeholder_text + '</div>'; 612 448 } 613 - // Fallback: just fetch directly 614 - showDetail({ id: eName, name: eName, type: 'unknown' }); 615 - }); 616 - }); 449 + html += '<div style="margin-left:auto"><a href="/app/health">Health →</a></div>'; 450 + el.innerHTML = html; 451 + }) 452 + .catch(function() {}); 617 453 } 618 454 619 - function scoreItem(label, value) { 620 - return '<div class="detail-score-item"><span class="detail-score-label">' + label + '</span><span class="detail-score-value">' + (value ?? 0) + '</span></div>'; 455 + function refreshNarrative() { 456 + fetch('/app/home/api/pulse') 457 + .then(r => r.json()) 458 + .then(data => { 459 + const el = document.getElementById('pulse-narrative-content'); 460 + if (el && data.flow_content) { 461 + el.innerHTML = marked.parse(data.flow_content, {breaks: true, gfm: true}); 462 + } 463 + }) 464 + .catch(function() {}); 621 465 } 622 - 623 - function closeDetail() { 624 - document.getElementById('graph-detail-panel').classList.remove('open'); 625 - detailOpen = false; 626 - if (network) network.unselectAll(); 627 - } 628 - 629 - // --- Filter handlers --- 630 - document.querySelectorAll('.graph-type-btn').forEach(btn => { 631 - btn.addEventListener('click', () => { 632 - const t = btn.dataset.type; 633 - if (btn.classList.contains('active')) { 634 - btn.classList.remove('active'); 635 - activeTypes.delete(t); 636 - } else { 637 - btn.classList.add('active'); 638 - activeTypes.add(t); 639 - } 640 - reload(); 641 - }); 642 - }); 643 - 644 - document.querySelectorAll('.graph-time-btn').forEach(btn => { 645 - btn.addEventListener('click', () => { 646 - document.querySelectorAll('.graph-time-btn').forEach(b => b.classList.remove('active')); 647 - btn.classList.add('active'); 648 - timeDays = btn.dataset.days ? parseInt(btn.dataset.days) : 0; 649 - reload(); 650 - }); 651 - }); 652 - 653 - const strengthSlider = document.getElementById('min-strength'); 654 - const strengthVal = document.getElementById('min-strength-val'); 655 - let strengthTimeout = null; 656 - strengthSlider.addEventListener('input', () => { 657 - strengthVal.textContent = strengthSlider.value; 658 - }); 659 - strengthSlider.addEventListener('change', () => { 660 - minStrength = parseInt(strengthSlider.value); 661 - reload(); 662 - }); 663 - 664 - document.getElementById('detail-close').addEventListener('click', closeDetail); 665 - 666 - // --- Facet awareness --- 667 - window.addEventListener('facet.switch', () => { 668 - reload(); 669 - }); 670 - 671 - // --- Load / reload --- 672 - let loadCount = 0; 673 - async function reload() { 674 - const thisLoad = ++loadCount; 675 - try { 676 - const data = await fetchGraph(); 677 - if (thisLoad !== loadCount) return; // stale 678 - renderGraph(data); 679 - } catch (err) { 680 - console.error('Graph load failed:', err); 681 - if (thisLoad === loadCount) { 682 - document.getElementById('graph-loading').textContent = 'Failed to load graph'; 683 - } 684 - } 685 - } 686 - 687 - // Initial load 688 - reload(); 689 466 })(); 690 467 </script>
+18
convey/bridge.py
··· 13 13 import logging 14 14 import os 15 15 import threading 16 + import time 16 17 from typing import Any, Dict, List, Optional 17 18 18 19 from flask_sock import Sock ··· 27 28 _WATCH_LOCK = threading.Lock() 28 29 _CALLOSUM_CONNECTION: Optional[CallosumConnection] = None 29 30 _WEBSOCKET_CLIENTS: List[object] = [] 31 + _STATE_CACHE: Dict[str, Any] = { 32 + "supervisor_status": None, 33 + "last_observe_ts": None, 34 + } 30 35 31 36 32 37 def _ensure_journal_env() -> None: ··· 47 52 48 53 def _broadcast_callosum_event(message: Dict[str, Any]) -> None: 49 54 """Broadcast Callosum event to WebSocket clients and server-side handlers.""" 55 + # Update state cache 56 + tract = message.get("tract") 57 + event = message.get("event") 58 + if tract == "supervisor" and event == "status": 59 + _STATE_CACHE["supervisor_status"] = message 60 + if tract == "observe" and event in ("observed", "status"): 61 + _STATE_CACHE["last_observe_ts"] = time.time() 62 + 50 63 # Broadcast to WebSocket clients 51 64 try: 52 65 _broadcast_to_websockets(message) ··· 133 146 finally: 134 147 if ws in _WEBSOCKET_CLIENTS: 135 148 _WEBSOCKET_CLIENTS.remove(ws) 149 + 150 + 151 + def get_cached_state() -> Dict[str, Any]: 152 + """Return a copy of the bridge state cache.""" 153 + return dict(_STATE_CACHE)
+621
tests/baselines/api/graph/graph.json
··· 1 + { 2 + "edges": [ 3 + { 4 + "edge_type": "co_occurrence", 5 + "frequency": 2, 6 + "from": "benvolio_montague", 7 + "from_name": "Benvolio Montague", 8 + "to": "paris_duke", 9 + "to_name": "Paris Duke" 10 + }, 11 + { 12 + "edge_type": "co_occurrence", 13 + "frequency": 2, 14 + "from": "benvolio_montague", 15 + "from_name": "Benvolio Montague", 16 + "to": "prince_escalus", 17 + "to_name": "Prince Escalus" 18 + }, 19 + { 20 + "edge_type": "co_occurrence", 21 + "frequency": 2, 22 + "from": "benvolio_montague", 23 + "from_name": "Benvolio Montague", 24 + "to": "tybalt_capulet", 25 + "to_name": "Tybalt Capulet" 26 + }, 27 + { 28 + "edge_type": "co_occurrence", 29 + "frequency": 2, 30 + "from": "benvolio_montague", 31 + "from_name": "Benvolio Montague", 32 + "to": "verona_platform", 33 + "to_name": "Verona Platform" 34 + }, 35 + { 36 + "edge_type": "co_occurrence", 37 + "frequency": 2, 38 + "from": "friar_lawrence", 39 + "from_name": "Friar Lawrence", 40 + "to": "mercutio_escalus", 41 + "to_name": "Mercutio Escalus" 42 + }, 43 + { 44 + "edge_type": "co_occurrence", 45 + "frequency": 2, 46 + "from": "friar_lawrence", 47 + "from_name": "Friar Lawrence", 48 + "to": "nurse_angela", 49 + "to_name": "Nurse Angela" 50 + }, 51 + { 52 + "edge_type": "co_occurrence", 53 + "frequency": 2, 54 + "from": "friar_lawrence", 55 + "from_name": "Friar Lawrence", 56 + "to": "prince_escalus", 57 + "to_name": "Prince Escalus" 58 + }, 59 + { 60 + "edge_type": "co_occurrence", 61 + "frequency": 2, 62 + "from": "juliet_capulet", 63 + "from_name": "Juliet Capulet", 64 + "to": "montague_tech", 65 + "to_name": "Montague Tech" 66 + }, 67 + { 68 + "edge_type": "co_occurrence", 69 + "frequency": 2, 70 + "from": "juliet_capulet", 71 + "from_name": "Juliet Capulet", 72 + "to": "prince_escalus", 73 + "to_name": "Prince Escalus" 74 + }, 75 + { 76 + "edge_type": "co_occurrence", 77 + "frequency": 2, 78 + "from": "mercutio_escalus", 79 + "from_name": "Mercutio Escalus", 80 + "to": "montague_tech", 81 + "to_name": "Montague Tech" 82 + }, 83 + { 84 + "edge_type": "co_occurrence", 85 + "frequency": 2, 86 + "from": "mercutio_escalus", 87 + "from_name": "Mercutio Escalus", 88 + "to": "prince_escalus", 89 + "to_name": "Prince Escalus" 90 + }, 91 + { 92 + "edge_type": "co_occurrence", 93 + "frequency": 2, 94 + "from": "montague_tech", 95 + "from_name": "Montague Tech", 96 + "to": "tybalt_capulet", 97 + "to_name": "Tybalt Capulet" 98 + }, 99 + { 100 + "edge_type": "co_occurrence", 101 + "frequency": 2, 102 + "from": "prince_escalus", 103 + "from_name": "Prince Escalus", 104 + "to": "tybalt_capulet", 105 + "to_name": "Tybalt Capulet" 106 + }, 107 + { 108 + "edge_type": "co_occurrence", 109 + "frequency": 2, 110 + "from": "prince_escalus", 111 + "from_name": "Prince Escalus", 112 + "to": "verona_platform", 113 + "to_name": "Verona Platform" 114 + }, 115 + { 116 + "edge_type": "co_occurrence", 117 + "frequency": 3, 118 + "from": "benvolio_montague", 119 + "from_name": "Benvolio Montague", 120 + "to": "friar_lawrence", 121 + "to_name": "Friar Lawrence" 122 + }, 123 + { 124 + "edge_type": "co_occurrence", 125 + "frequency": 3, 126 + "from": "benvolio_montague", 127 + "from_name": "Benvolio Montague", 128 + "to": "nurse_angela", 129 + "to_name": "Nurse Angela" 130 + }, 131 + { 132 + "edge_type": "co_occurrence", 133 + "frequency": 3, 134 + "from": "juliet_capulet", 135 + "from_name": "Juliet Capulet", 136 + "to": "mercutio_escalus", 137 + "to_name": "Mercutio Escalus" 138 + }, 139 + { 140 + "edge_type": "co_occurrence", 141 + "frequency": 4, 142 + "from": "benvolio_montague", 143 + "from_name": "Benvolio Montague", 144 + "to": "juliet_capulet", 145 + "to_name": "Juliet Capulet" 146 + }, 147 + { 148 + "edge_type": "co_occurrence", 149 + "frequency": 4, 150 + "from": "benvolio_montague", 151 + "from_name": "Benvolio Montague", 152 + "to": "mercutio_escalus", 153 + "to_name": "Mercutio Escalus" 154 + }, 155 + { 156 + "edge_type": "co_occurrence", 157 + "frequency": 4, 158 + "from": "paris_duke", 159 + "from_name": "Paris Duke", 160 + "to": "tybalt_capulet", 161 + "to_name": "Tybalt Capulet" 162 + }, 163 + { 164 + "edge_type": "co_occurrence", 165 + "frequency": 5, 166 + "from": "friar_lawrence", 167 + "from_name": "Friar Lawrence", 168 + "to": "paris_duke", 169 + "to_name": "Paris Duke" 170 + }, 171 + { 172 + "edge_type": "co_occurrence", 173 + "frequency": 5, 174 + "from": "friar_lawrence", 175 + "from_name": "Friar Lawrence", 176 + "to": "tybalt_capulet", 177 + "to_name": "Tybalt Capulet" 178 + }, 179 + { 180 + "edge_type": "co_occurrence", 181 + "frequency": 5, 182 + "from": "juliet_capulet", 183 + "from_name": "Juliet Capulet", 184 + "to": "paris_duke", 185 + "to_name": "Paris Duke" 186 + }, 187 + { 188 + "edge_type": "co_occurrence", 189 + "frequency": 5, 190 + "from": "mercutio_escalus", 191 + "from_name": "Mercutio Escalus", 192 + "to": "tybalt_capulet", 193 + "to_name": "Tybalt Capulet" 194 + }, 195 + { 196 + "edge_type": "co_occurrence", 197 + "frequency": 6, 198 + "from": "juliet_capulet", 199 + "from_name": "Juliet Capulet", 200 + "to": "tybalt_capulet", 201 + "to_name": "Tybalt Capulet" 202 + }, 203 + { 204 + "edge_type": "co_occurrence", 205 + "frequency": 7, 206 + "from": "friar_lawrence", 207 + "from_name": "Friar Lawrence", 208 + "to": "juliet_capulet", 209 + "to_name": "Juliet Capulet" 210 + }, 211 + { 212 + "edge_type": "explicit", 213 + "frequency": 1, 214 + "from": "benvolio_montague", 215 + "from_name": "Benvolio Montague", 216 + "relationship_type": "suspicious-of", 217 + "to": "romeo_montague", 218 + "to_name": "Romeo Montague" 219 + }, 220 + { 221 + "edge_type": "explicit", 222 + "frequency": 1, 223 + "from": "friar_lawrence", 224 + "from_name": "Friar Lawrence", 225 + "relationship_type": "advocates-for", 226 + "to": "verona_platform", 227 + "to_name": "Verona Platform" 228 + }, 229 + { 230 + "edge_type": "explicit", 231 + "frequency": 1, 232 + "from": "friar_lawrence", 233 + "from_name": "Friar Lawrence", 234 + "relationship_type": "endorses", 235 + "to": "verona_platform", 236 + "to_name": "Verona Platform" 237 + }, 238 + { 239 + "edge_type": "explicit", 240 + "frequency": 1, 241 + "from": "juliet_capulet", 242 + "from_name": "Juliet Capulet", 243 + "relationship_type": "co-leads", 244 + "to": "verona_platform", 245 + "to_name": "Verona Platform" 246 + }, 247 + { 248 + "edge_type": "explicit", 249 + "frequency": 1, 250 + "from": "mercutio_escalus", 251 + "from_name": "Mercutio Escalus", 252 + "relationship_type": "covers-for", 253 + "to": "romeo_montague", 254 + "to_name": "Romeo Montague" 255 + }, 256 + { 257 + "edge_type": "explicit", 258 + "frequency": 1, 259 + "from": "mercutio_escalus", 260 + "from_name": "Mercutio Escalus", 261 + "relationship_type": "security-lead", 262 + "to": "verona_platform", 263 + "to_name": "Verona Platform" 264 + }, 265 + { 266 + "edge_type": "explicit", 267 + "frequency": 1, 268 + "from": "montague_tech", 269 + "from_name": "Montague Tech", 270 + "relationship_type": "competes-with", 271 + "to": "capulet_industries", 272 + "to_name": "Capulet Industries" 273 + }, 274 + { 275 + "edge_type": "explicit", 276 + "frequency": 1, 277 + "from": "paris_duke", 278 + "from_name": "Paris Duke", 279 + "relationship_type": "competed-with", 280 + "to": "verona_platform", 281 + "to_name": "Verona Platform" 282 + }, 283 + { 284 + "edge_type": "explicit", 285 + "frequency": 1, 286 + "from": "paris_duke", 287 + "from_name": "Paris Duke", 288 + "relationship_type": "competes-with", 289 + "to": "verona_platform", 290 + "to_name": "Verona Platform" 291 + }, 292 + { 293 + "edge_type": "explicit", 294 + "frequency": 1, 295 + "from": "prince_escalus", 296 + "from_name": "Prince Escalus", 297 + "relationship_type": "evaluates", 298 + "to": "montague_tech", 299 + "to_name": "Montague Tech" 300 + }, 301 + { 302 + "edge_type": "explicit", 303 + "frequency": 1, 304 + "from": "romeo_montague", 305 + "from_name": "Romeo Montague", 306 + "relationship_type": "co-leads", 307 + "to": "verona_platform", 308 + "to_name": "Verona Platform" 309 + }, 310 + { 311 + "edge_type": "explicit", 312 + "frequency": 1, 313 + "from": "romeo_montague", 314 + "from_name": "Romeo Montague", 315 + "relationship_type": "collaborates-with", 316 + "to": "juliet_capulet", 317 + "to_name": "Juliet Capulet" 318 + }, 319 + { 320 + "edge_type": "explicit", 321 + "frequency": 1, 322 + "from": "romeo_montague", 323 + "from_name": "Romeo Montague", 324 + "relationship_type": "collaborates-with", 325 + "to": "mercutio_escalus", 326 + "to_name": "Mercutio Escalus" 327 + }, 328 + { 329 + "edge_type": "explicit", 330 + "frequency": 1, 331 + "from": "romeo_montague", 332 + "from_name": "Romeo Montague", 333 + "relationship_type": "mentors", 334 + "to": "balthasar_davi", 335 + "to_name": "Balthasar Davi" 336 + }, 337 + { 338 + "edge_type": "explicit", 339 + "frequency": 1, 340 + "from": "romeo_montague", 341 + "from_name": "Romeo Montague", 342 + "relationship_type": "met-at-conference", 343 + "to": "juliet_capulet", 344 + "to_name": "Juliet Capulet" 345 + }, 346 + { 347 + "edge_type": "explicit", 348 + "frequency": 1, 349 + "from": "schema_bridge", 350 + "from_name": "Schema Bridge", 351 + "relationship_type": "integrates-with", 352 + "to": "mesh_routing", 353 + "to_name": "Mesh Routing" 354 + }, 355 + { 356 + "edge_type": "explicit", 357 + "frequency": 1, 358 + "from": "tybalt_capulet", 359 + "from_name": "Tybalt Capulet", 360 + "relationship_type": "hostile-to", 361 + "to": "romeo_montague", 362 + "to_name": "Romeo Montague" 363 + }, 364 + { 365 + "edge_type": "explicit", 366 + "frequency": 1, 367 + "from": "tybalt_capulet", 368 + "from_name": "Tybalt Capulet", 369 + "relationship_type": "opposes", 370 + "to": "verona_platform", 371 + "to_name": "Verona Platform" 372 + }, 373 + { 374 + "edge_type": "explicit", 375 + "frequency": 1, 376 + "from": "tybalt_capulet", 377 + "from_name": "Tybalt Capulet", 378 + "relationship_type": "reconciled-with", 379 + "to": "romeo_montague", 380 + "to_name": "Romeo Montague" 381 + }, 382 + { 383 + "edge_type": "explicit", 384 + "frequency": 2, 385 + "from": "nurse_angela", 386 + "from_name": "Nurse Angela", 387 + "relationship_type": "supports", 388 + "to": "juliet_capulet", 389 + "to_name": "Juliet Capulet" 390 + } 391 + ], 392 + "nodes": [ 393 + { 394 + "appearance": 1, 395 + "co_occurrence": 13, 396 + "facet_breadth": 1, 397 + "id": "balthasar_davi", 398 + "is_principal": false, 399 + "kg_edge_count": 1, 400 + "name": "Balthasar Davi", 401 + "observation_depth": 2, 402 + "recency": 0.691, 403 + "score": 64.1, 404 + "type": "person" 405 + }, 406 + { 407 + "appearance": 1, 408 + "co_occurrence": 13, 409 + "facet_breadth": 1, 410 + "id": "mesh_routing", 411 + "is_principal": false, 412 + "kg_edge_count": 1, 413 + "name": "Mesh Routing", 414 + "observation_depth": 3, 415 + "recency": 0.691, 416 + "score": 66.1, 417 + "type": "project" 418 + }, 419 + { 420 + "appearance": 1, 421 + "co_occurrence": 13, 422 + "facet_breadth": 1, 423 + "id": "verona_ventures", 424 + "is_principal": false, 425 + "kg_edge_count": 0, 426 + "name": "Verona Ventures", 427 + "observation_depth": 2, 428 + "recency": 0.691, 429 + "score": 59.1, 430 + "type": "company" 431 + }, 432 + { 433 + "appearance": 1, 434 + "co_occurrence": 4, 435 + "facet_breadth": 1, 436 + "id": "capulet_industries", 437 + "is_principal": false, 438 + "kg_edge_count": 1, 439 + "name": "Capulet Industries", 440 + "observation_depth": 0, 441 + "recency": 0.6598, 442 + "score": 24.0, 443 + "type": "company" 444 + }, 445 + { 446 + "appearance": 11, 447 + "co_occurrence": 16, 448 + "facet_breadth": 2, 449 + "id": "mercutio_escalus", 450 + "is_principal": false, 451 + "kg_edge_count": 3, 452 + "name": "Mercutio Escalus", 453 + "observation_depth": 3, 454 + "recency": 0.7579, 455 + "score": 89.3, 456 + "type": "person" 457 + }, 458 + { 459 + "appearance": 12, 460 + "co_occurrence": 16, 461 + "facet_breadth": 3, 462 + "id": "tybalt_capulet", 463 + "is_principal": false, 464 + "kg_edge_count": 3, 465 + "name": "Tybalt Capulet", 466 + "observation_depth": 4, 467 + "recency": 0.7579, 468 + "score": 92.3, 469 + "type": "person" 470 + }, 471 + { 472 + "appearance": 16, 473 + "co_occurrence": 16, 474 + "facet_breadth": 3, 475 + "id": "juliet_capulet", 476 + "is_principal": false, 477 + "kg_edge_count": 4, 478 + "name": "Juliet Capulet", 479 + "observation_depth": 2, 480 + "recency": 0.7579, 481 + "score": 93.3, 482 + "type": "person" 483 + }, 484 + { 485 + "appearance": 2, 486 + "co_occurrence": 13, 487 + "facet_breadth": 1, 488 + "id": "schema_bridge", 489 + "is_principal": false, 490 + "kg_edge_count": 1, 491 + "name": "Schema Bridge", 492 + "observation_depth": 2, 493 + "recency": 0.691, 494 + "score": 64.1, 495 + "type": "project" 496 + }, 497 + { 498 + "appearance": 25, 499 + "co_occurrence": 0, 500 + "facet_breadth": 3, 501 + "id": "romeo_montague", 502 + "is_principal": true, 503 + "kg_edge_count": 9, 504 + "name": "Romeo Montague", 505 + "observation_depth": 2, 506 + "recency": 0.7579, 507 + "score": 54.3, 508 + "type": "person" 509 + }, 510 + { 511 + "appearance": 3, 512 + "co_occurrence": 13, 513 + "facet_breadth": 1, 514 + "id": "rosaline_prince", 515 + "is_principal": false, 516 + "kg_edge_count": 1, 517 + "name": "Rosaline Prince", 518 + "observation_depth": 2, 519 + "recency": 0.7579, 520 + "score": 64.3, 521 + "type": "person" 522 + }, 523 + { 524 + "appearance": 3, 525 + "co_occurrence": 14, 526 + "facet_breadth": 1, 527 + "id": "montague_tech", 528 + "is_principal": false, 529 + "kg_edge_count": 2, 530 + "name": "Montague Tech", 531 + "observation_depth": 3, 532 + "recency": 0.691, 533 + "score": 75.1, 534 + "type": "company" 535 + }, 536 + { 537 + "appearance": 3, 538 + "co_occurrence": 15, 539 + "facet_breadth": 1, 540 + "id": "prince_escalus", 541 + "is_principal": false, 542 + "kg_edge_count": 2, 543 + "name": "Prince Escalus", 544 + "observation_depth": 2, 545 + "recency": 0.7579, 546 + "score": 77.3, 547 + "type": "person" 548 + }, 549 + { 550 + "appearance": 3, 551 + "co_occurrence": 15, 552 + "facet_breadth": 2, 553 + "id": "verona_platform", 554 + "is_principal": false, 555 + "kg_edge_count": 8, 556 + "name": "Verona Platform", 557 + "observation_depth": 3, 558 + "recency": 0.7579, 559 + "score": 110.3, 560 + "type": "project" 561 + }, 562 + { 563 + "appearance": 5, 564 + "co_occurrence": 14, 565 + "facet_breadth": 2, 566 + "id": "nurse_angela", 567 + "is_principal": false, 568 + "kg_edge_count": 1, 569 + "name": "Nurse Angela", 570 + "observation_depth": 2, 571 + "recency": 0.7405, 572 + "score": 69.2, 573 + "type": "person" 574 + }, 575 + { 576 + "appearance": 7, 577 + "co_occurrence": 9, 578 + "facet_breadth": 3, 579 + "id": "paris_duke", 580 + "is_principal": false, 581 + "kg_edge_count": 2, 582 + "name": "Paris Duke", 583 + "observation_depth": 2, 584 + "recency": 0.7579, 585 + "score": 55.3, 586 + "type": "person" 587 + }, 588 + { 589 + "appearance": 9, 590 + "co_occurrence": 15, 591 + "facet_breadth": 2, 592 + "id": "benvolio_montague", 593 + "is_principal": false, 594 + "kg_edge_count": 1, 595 + "name": "Benvolio Montague", 596 + "observation_depth": 3, 597 + "recency": 0.7579, 598 + "score": 75.3, 599 + "type": "person" 600 + }, 601 + { 602 + "appearance": 9, 603 + "co_occurrence": 15, 604 + "facet_breadth": 3, 605 + "id": "friar_lawrence", 606 + "is_principal": false, 607 + "kg_edge_count": 2, 608 + "name": "Friar Lawrence", 609 + "observation_depth": 2, 610 + "recency": 0.7579, 611 + "score": 79.3, 612 + "type": "person" 613 + } 614 + ], 615 + "stats": { 616 + "co_occurrence_edge_count": 26, 617 + "explicit_edge_count": 20, 618 + "total_entities": 34, 619 + "total_signals": 124 620 + } 621 + }
+8
tests/verify_api.py
··· 366 366 "params": {}, 367 367 "status": 200, 368 368 }, 369 + # apps/graph/routes.py 370 + { 371 + "app": "graph", 372 + "name": "graph", 373 + "path": "/app/graph/api/graph", 374 + "params": {}, 375 + "status": 200, 376 + }, 369 377 ] 370 378 371 379
+7 -7
tests/verify_browser.py
··· 42 42 ], 43 43 }, 44 44 { 45 - "app": "home", 45 + "app": "graph", 46 46 "name": "smoke", 47 47 "steps": [ 48 - {"do": "navigate", "path": "/app/home/20260304"}, 48 + {"do": "navigate", "path": "/app/graph"}, 49 49 {"do": "wait", "ms": 1000}, 50 50 {"do": "screenshot"}, 51 51 ], ··· 173 173 ], 174 174 }, 175 175 { 176 - "app": "home", 177 - "name": "date-nav", 176 + "app": "graph", 177 + "name": "load", 178 178 "steps": [ 179 - {"do": "navigate", "path": "/app/home/20260305"}, 179 + {"do": "navigate", "path": "/app/graph"}, 180 180 {"do": "wait", "ms": 1000}, 181 181 {"do": "screenshot"}, 182 182 ], ··· 201 201 ], 202 202 }, 203 203 { 204 - "app": "home", 204 + "app": "graph", 205 205 "name": "facet-filter", 206 206 "steps": [ 207 207 {"do": "evaluate", "expression": "document.cookie='facet=montague;path=/'"}, 208 - {"do": "navigate", "path": "/app/home/20260304"}, 208 + {"do": "navigate", "path": "/app/graph"}, 209 209 {"do": "wait", "ms": 1200}, 210 210 {"do": "screenshot"}, 211 211 ],