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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. htmlgraph/__init__.py +20 -1
  2. htmlgraph/agent_detection.py +26 -10
  3. htmlgraph/analytics/cross_session.py +4 -3
  4. htmlgraph/analytics/work_type.py +52 -16
  5. htmlgraph/analytics_index.py +51 -19
  6. htmlgraph/api/__init__.py +3 -0
  7. htmlgraph/api/main.py +2263 -0
  8. htmlgraph/api/static/htmx.min.js +1 -0
  9. htmlgraph/api/static/style-redesign.css +1344 -0
  10. htmlgraph/api/static/style.css +1079 -0
  11. htmlgraph/api/templates/dashboard-redesign.html +812 -0
  12. htmlgraph/api/templates/dashboard.html +794 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +1020 -0
  15. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  16. htmlgraph/api/templates/partials/agents.html +317 -0
  17. htmlgraph/api/templates/partials/event-traces.html +373 -0
  18. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  19. htmlgraph/api/templates/partials/features.html +509 -0
  20. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  21. htmlgraph/api/templates/partials/metrics.html +346 -0
  22. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  23. htmlgraph/api/templates/partials/orchestration.html +163 -0
  24. htmlgraph/api/templates/partials/spawners.html +375 -0
  25. htmlgraph/atomic_ops.py +560 -0
  26. htmlgraph/builders/base.py +55 -1
  27. htmlgraph/builders/bug.py +17 -2
  28. htmlgraph/builders/chore.py +17 -2
  29. htmlgraph/builders/epic.py +17 -2
  30. htmlgraph/builders/feature.py +25 -2
  31. htmlgraph/builders/phase.py +17 -2
  32. htmlgraph/builders/spike.py +27 -2
  33. htmlgraph/builders/track.py +14 -0
  34. htmlgraph/cigs/__init__.py +4 -0
  35. htmlgraph/cigs/reporter.py +818 -0
  36. htmlgraph/cli.py +1427 -401
  37. htmlgraph/cli_commands/__init__.py +1 -0
  38. htmlgraph/cli_commands/feature.py +195 -0
  39. htmlgraph/cli_framework.py +115 -0
  40. htmlgraph/collections/__init__.py +2 -0
  41. htmlgraph/collections/base.py +21 -0
  42. htmlgraph/collections/session.py +189 -0
  43. htmlgraph/collections/spike.py +7 -1
  44. htmlgraph/collections/task_delegation.py +236 -0
  45. htmlgraph/collections/traces.py +482 -0
  46. htmlgraph/config.py +113 -0
  47. htmlgraph/converter.py +41 -0
  48. htmlgraph/cost_analysis/__init__.py +5 -0
  49. htmlgraph/cost_analysis/analyzer.py +438 -0
  50. htmlgraph/dashboard.html +3356 -492
  51. htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
  52. htmlgraph/dashboard.html.bak +7181 -0
  53. htmlgraph/dashboard.html.bak2 +7231 -0
  54. htmlgraph/dashboard.html.bak3 +7232 -0
  55. htmlgraph/db/__init__.py +38 -0
  56. htmlgraph/db/queries.py +790 -0
  57. htmlgraph/db/schema.py +1584 -0
  58. htmlgraph/deploy.py +26 -27
  59. htmlgraph/docs/API_REFERENCE.md +841 -0
  60. htmlgraph/docs/HTTP_API.md +750 -0
  61. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  62. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
  63. htmlgraph/docs/README.md +533 -0
  64. htmlgraph/docs/version_check.py +3 -1
  65. htmlgraph/error_handler.py +544 -0
  66. htmlgraph/event_log.py +2 -0
  67. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  68. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  69. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  70. htmlgraph/hooks/__init__.py +8 -0
  71. htmlgraph/hooks/bootstrap.py +169 -0
  72. htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
  73. htmlgraph/hooks/concurrent_sessions.py +208 -0
  74. htmlgraph/hooks/context.py +318 -0
  75. htmlgraph/hooks/drift_handler.py +525 -0
  76. htmlgraph/hooks/event_tracker.py +496 -79
  77. htmlgraph/hooks/orchestrator.py +6 -4
  78. htmlgraph/hooks/orchestrator_reflector.py +4 -4
  79. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  80. htmlgraph/hooks/pretooluse.py +473 -6
  81. htmlgraph/hooks/prompt_analyzer.py +637 -0
  82. htmlgraph/hooks/session_handler.py +637 -0
  83. htmlgraph/hooks/state_manager.py +504 -0
  84. htmlgraph/hooks/subagent_stop.py +309 -0
  85. htmlgraph/hooks/task_enforcer.py +39 -0
  86. htmlgraph/hooks/validator.py +15 -11
  87. htmlgraph/models.py +111 -15
  88. htmlgraph/operations/fastapi_server.py +230 -0
  89. htmlgraph/orchestration/headless_spawner.py +344 -29
  90. htmlgraph/orchestration/live_events.py +377 -0
  91. htmlgraph/pydantic_models.py +476 -0
  92. htmlgraph/quality_gates.py +350 -0
  93. htmlgraph/repo_hash.py +511 -0
  94. htmlgraph/sdk.py +348 -10
  95. htmlgraph/server.py +194 -0
  96. htmlgraph/session_hooks.py +300 -0
  97. htmlgraph/session_manager.py +131 -1
  98. htmlgraph/session_registry.py +587 -0
  99. htmlgraph/session_state.py +436 -0
  100. htmlgraph/system_prompts.py +449 -0
  101. htmlgraph/templates/orchestration-view.html +350 -0
  102. htmlgraph/track_builder.py +19 -0
  103. htmlgraph/validation.py +115 -0
  104. htmlgraph-0.26.1.data/data/htmlgraph/dashboard.html +7458 -0
  105. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +91 -64
  106. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +112 -46
  107. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
  108. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  109. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  110. {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  111. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
  112. {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,637 @@
1
+ """
2
+ HtmlGraph Session Handler Module
3
+
4
+ Centralizes session lifecycle and tracking logic for hooks.
5
+ Provides unified functions for session initialization, tracking, and cleanup.
6
+
7
+ This module extracts common patterns from session-start.py and session-end.py
8
+ hooks to provide reusable session management operations.
9
+
10
+ Public API:
11
+ init_or_get_session(context: HookContext) -> Session | None
12
+ Get or create session from SessionManager
13
+
14
+ handle_session_start(context: HookContext, session: Session | None) -> dict
15
+ Initialize HtmlGraph tracking and build feature context
16
+
17
+ handle_session_end(context: HookContext) -> dict
18
+ Close session gracefully and record final metrics
19
+
20
+ record_user_query_event(context: HookContext, prompt: str) -> str | None
21
+ Create UserQuery event in database
22
+
23
+ check_version_status() -> dict | None
24
+ Check if HtmlGraph has updates available
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import os
32
+ import subprocess
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ if TYPE_CHECKING:
38
+ from htmlgraph.hooks.context import HookContext
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def init_or_get_session(context: HookContext) -> Any | None:
44
+ """
45
+ Get or create session from SessionManager.
46
+
47
+ Attempts to get an active session for the current agent.
48
+ If none exists, creates a new one with automatic initialization.
49
+
50
+ Args:
51
+ context: HookContext with project and graph directory information
52
+
53
+ Returns:
54
+ Session object if successful, None if SessionManager unavailable or error occurs
55
+
56
+ Note:
57
+ - Handles graceful degradation if SessionManager cannot be imported
58
+ - Caches session in context for reuse
59
+ - Logs session ID for debugging
60
+ """
61
+ try:
62
+ manager = context.session_manager
63
+ agent = context.agent_id
64
+
65
+ # Try to get existing session for this agent
66
+ active = manager.get_active_session_for_agent(agent=agent)
67
+ if not active:
68
+ # Create new session with commit info
69
+ try:
70
+ head_commit = _get_head_commit(context.project_dir)
71
+ except Exception:
72
+ head_commit = None
73
+
74
+ active = manager.start_session(
75
+ session_id=None,
76
+ agent=agent,
77
+ start_commit=head_commit,
78
+ title=f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}",
79
+ )
80
+
81
+ context.log("info", f"Session initialized: {active.id if active else 'None'}")
82
+ return active
83
+
84
+ except ImportError as e:
85
+ context.log("error", f"SessionManager not available: {e}")
86
+ return None
87
+ except Exception as e:
88
+ context.log("error", f"Failed to initialize session: {e}")
89
+ return None
90
+
91
+
92
+ def handle_session_start(context: HookContext, session: Any | None) -> dict[str, Any]:
93
+ """
94
+ Initialize HtmlGraph tracking for the session.
95
+
96
+ Performs session startup operations:
97
+ - Initializes database entry if needed
98
+ - Loads active features and spikes from project
99
+ - Builds feature context string
100
+ - Records session start event
101
+ - Creates conversation-init spike if new conversation
102
+ - Injects concurrent session and recent work context
103
+
104
+ Args:
105
+ context: HookContext with project and graph directory information
106
+ session: Session object from SessionManager (optional)
107
+
108
+ Returns:
109
+ dict with:
110
+ {
111
+ "continue": True,
112
+ "hookSpecificOutput": {
113
+ "sessionFeatureContext": str with feature context,
114
+ "sessionContext": optional concurrent/recent work context,
115
+ "versionInfo": optional version check result
116
+ }
117
+ }
118
+ """
119
+ output: dict[str, Any] = {
120
+ "continue": True,
121
+ "hookSpecificOutput": {
122
+ "sessionFeatureContext": "",
123
+ "sessionContext": "",
124
+ "versionInfo": None,
125
+ },
126
+ }
127
+
128
+ if not session:
129
+ return output
130
+
131
+ # Ensure session exists in database
132
+ try:
133
+ db = context.database
134
+ cursor = db.connection.cursor()
135
+ cursor.execute(
136
+ "SELECT COUNT(*) FROM sessions WHERE session_id = ?",
137
+ (session.id,),
138
+ )
139
+ session_exists = cursor.fetchone()[0] > 0
140
+
141
+ if not session_exists:
142
+ cursor.execute(
143
+ """
144
+ INSERT INTO sessions (session_id, agent_assigned, created_at, status)
145
+ VALUES (?, ?, ?, 'active')
146
+ """,
147
+ (
148
+ session.id,
149
+ context.agent_id,
150
+ datetime.now(timezone.utc).isoformat(),
151
+ ),
152
+ )
153
+ db.connection.commit()
154
+ context.log("info", f"Created database session: {session.id}")
155
+ except ImportError:
156
+ context.log("debug", "Database not available, skipping session entry")
157
+ except Exception as e:
158
+ context.log("warning", f"Could not create database session: {e}")
159
+
160
+ # Track session start activity
161
+ try:
162
+ external_session_id = context.hook_input.get("session_id", "unknown")
163
+ context.session_manager.track_activity(
164
+ session_id=session.id,
165
+ tool="SessionStart",
166
+ summary=f"Session started: {external_session_id}",
167
+ payload={
168
+ "agent": context.agent_id,
169
+ "external_session_id": external_session_id,
170
+ },
171
+ )
172
+ except Exception as e:
173
+ context.log("warning", f"Could not track session start activity: {e}")
174
+
175
+ # Load features and build context
176
+ try:
177
+ features = _load_features(context.graph_dir)
178
+ active_features = [f for f in features if f.get("status") == "in-progress"]
179
+
180
+ if active_features:
181
+ feature_list = "\n".join(
182
+ [f"- **{f['id']}**: {f['title']}" for f in active_features[:3]]
183
+ )
184
+ context_str = f"""## Active Features
185
+
186
+ {feature_list}
187
+
188
+ Activity will be attributed to these features based on file patterns and keywords."""
189
+ output["hookSpecificOutput"]["sessionFeatureContext"] = context_str
190
+ context.log("info", f"Loaded {len(active_features)} active features")
191
+
192
+ except Exception as e:
193
+ context.log("warning", f"Could not load features: {e}")
194
+
195
+ # Check version status
196
+ try:
197
+ version_info = check_version_status()
198
+ if version_info and version_info.get("is_outdated"):
199
+ output["hookSpecificOutput"]["versionInfo"] = version_info
200
+ context.log(
201
+ "info",
202
+ f"Update available: {version_info.get('installed')} → {version_info.get('latest')}",
203
+ )
204
+ except Exception as e:
205
+ context.log("debug", f"Could not check version: {e}")
206
+
207
+ # Build concurrent session context
208
+ try:
209
+ from htmlgraph.hooks.concurrent_sessions import (
210
+ format_concurrent_sessions_markdown,
211
+ format_recent_work_markdown,
212
+ get_concurrent_sessions,
213
+ get_recent_completed_sessions,
214
+ )
215
+
216
+ db = context.database
217
+
218
+ # Get concurrent sessions (other active windows)
219
+ concurrent = get_concurrent_sessions(db, context.session_id, minutes=30)
220
+ concurrent_md = format_concurrent_sessions_markdown(concurrent)
221
+
222
+ # Get recent completed work
223
+ recent = get_recent_completed_sessions(db, hours=24, limit=5)
224
+ recent_md = format_recent_work_markdown(recent)
225
+
226
+ # Build session context
227
+ session_context = ""
228
+ if concurrent_md:
229
+ session_context += concurrent_md + "\n"
230
+ if recent_md:
231
+ session_context += recent_md + "\n"
232
+
233
+ if session_context:
234
+ output["hookSpecificOutput"]["sessionContext"] = session_context.strip()
235
+ context.log(
236
+ "info",
237
+ f"Injected context: {len(concurrent)} concurrent, {len(recent)} recent",
238
+ )
239
+
240
+ except ImportError:
241
+ context.log("debug", "Concurrent session module not available")
242
+ except Exception as e:
243
+ context.log("warning", f"Failed to get concurrent session context: {e}")
244
+
245
+ # Update session with user's current query (if available from hook input)
246
+ try:
247
+ user_query = context.hook_input.get("prompt", "")
248
+ if user_query and session:
249
+ context.session_manager.track_activity(
250
+ session_id=session.id,
251
+ tool="UserQuery",
252
+ summary=user_query[:100],
253
+ payload={"query_length": len(user_query)},
254
+ )
255
+ except Exception as e:
256
+ context.log("warning", f"Failed to update session activity: {e}")
257
+
258
+ return output
259
+
260
+
261
+ def handle_session_end(context: HookContext) -> dict[str, Any]:
262
+ """
263
+ Close session gracefully and record final metrics.
264
+
265
+ Performs session end operations:
266
+ - Captures handoff notes if provided
267
+ - Links transcript if available
268
+ - Records session end event
269
+ - Cleans up temporary state files
270
+
271
+ Args:
272
+ context: HookContext with project and graph directory information
273
+
274
+ Returns:
275
+ dict with:
276
+ {
277
+ "continue": True,
278
+ "status": "success" | "partial" | "error"
279
+ }
280
+ """
281
+ output: dict[str, Any] = {
282
+ "continue": True,
283
+ "status": "success",
284
+ }
285
+
286
+ try:
287
+ session = context.session_manager.get_active_session()
288
+ if not session:
289
+ context.log("debug", "No active session to close")
290
+ else:
291
+ # Capture handoff context if provided
292
+ handoff_notes = context.hook_input.get("handoff_notes") or os.environ.get(
293
+ "HTMLGRAPH_HANDOFF_NOTES"
294
+ )
295
+ recommended_next = context.hook_input.get(
296
+ "recommended_next"
297
+ ) or os.environ.get("HTMLGRAPH_HANDOFF_RECOMMEND")
298
+ blockers_raw = context.hook_input.get("blockers") or os.environ.get(
299
+ "HTMLGRAPH_HANDOFF_BLOCKERS"
300
+ )
301
+
302
+ blockers = None
303
+ if isinstance(blockers_raw, str):
304
+ blockers = [b.strip() for b in blockers_raw.split(",") if b.strip()]
305
+ elif isinstance(blockers_raw, list):
306
+ blockers = [str(b).strip() for b in blockers_raw if str(b).strip()]
307
+
308
+ if handoff_notes or recommended_next or blockers:
309
+ try:
310
+ context.session_manager.set_session_handoff(
311
+ session_id=session.id,
312
+ handoff_notes=handoff_notes,
313
+ recommended_next=recommended_next,
314
+ blockers=blockers,
315
+ )
316
+ context.log("info", "Session handoff recorded")
317
+ except Exception as e:
318
+ context.log("warning", f"Could not set handoff: {e}")
319
+ output["status"] = "partial"
320
+
321
+ # Link transcript if external session ID provided
322
+ external_session_id = context.hook_input.get(
323
+ "session_id"
324
+ ) or os.environ.get("CLAUDE_SESSION_ID")
325
+ if external_session_id:
326
+ try:
327
+ from htmlgraph.transcript import TranscriptReader
328
+
329
+ reader = TranscriptReader()
330
+ transcript = reader.read_session(external_session_id)
331
+ if transcript:
332
+ context.session_manager.link_transcript(
333
+ session_id=session.id,
334
+ transcript_id=external_session_id,
335
+ transcript_path=str(transcript.path),
336
+ git_branch=transcript.git_branch
337
+ if hasattr(transcript, "git_branch")
338
+ else None,
339
+ )
340
+ context.log("info", "Transcript linked to session")
341
+ except ImportError:
342
+ context.log("debug", "Transcript reader not available")
343
+ except Exception as e:
344
+ context.log("warning", f"Could not link transcript: {e}")
345
+ output["status"] = "partial"
346
+
347
+ # Record session end activity
348
+ try:
349
+ context.session_manager.track_activity(
350
+ session_id=session.id,
351
+ tool="SessionEnd",
352
+ summary="Session ended",
353
+ )
354
+ except Exception as e:
355
+ context.log("warning", f"Could not track session end: {e}")
356
+ output["status"] = "partial"
357
+
358
+ context.log("info", f"Session closed: {session.id}")
359
+
360
+ except ImportError:
361
+ context.log("error", "SessionManager not available")
362
+ output["status"] = "error"
363
+ except Exception as e:
364
+ context.log("error", f"Failed to close session: {e}")
365
+ output["status"] = "error"
366
+
367
+ # Always cleanup temp files
368
+ try:
369
+ _cleanup_temp_files(context.graph_dir)
370
+ except Exception as e:
371
+ context.log("warning", f"Could not cleanup temp files: {e}")
372
+
373
+ return output
374
+
375
+
376
+ def record_user_query_event(context: HookContext, prompt: str) -> str | None:
377
+ """
378
+ Create UserQuery event in database.
379
+
380
+ Records a user query prompt as an event in the database for later
381
+ reference by tool calls in the same conversation turn.
382
+
383
+ Args:
384
+ context: HookContext with project and graph directory information
385
+ prompt: The user query prompt text
386
+
387
+ Returns:
388
+ event_id if successful, None otherwise
389
+
390
+ Note:
391
+ - Event ID is stored for parent-child linking of subsequent tool calls
392
+ - Events expire after 10 minutes (conversation turn boundary)
393
+ - Safe to call even if database unavailable (graceful degradation)
394
+ """
395
+ try:
396
+ from htmlgraph.ids import generate_id
397
+
398
+ db = context.database
399
+ event_id = generate_id("event")
400
+
401
+ # Preview for logging
402
+ preview = prompt[:100].replace("\n", " ")
403
+ if len(prompt) > 100:
404
+ preview += "..."
405
+
406
+ # Insert UserQuery event
407
+ success = db.insert_event(
408
+ event_id=event_id,
409
+ agent_id=context.agent_id,
410
+ event_type="user_query",
411
+ session_id=context.session_id,
412
+ tool_name="UserQuery",
413
+ input_summary=preview,
414
+ output_summary="Query recorded",
415
+ context={"full_prompt_length": len(prompt)},
416
+ )
417
+
418
+ if success:
419
+ context.log("info", f"Recorded UserQuery event: {event_id}")
420
+ return event_id
421
+ else:
422
+ context.log("warning", "Failed to insert UserQuery event")
423
+ return None
424
+
425
+ except ImportError:
426
+ context.log("debug", "Database not available for UserQuery event")
427
+ return None
428
+ except Exception as e:
429
+ context.log("error", f"Failed to record UserQuery event: {e}")
430
+ return None
431
+
432
+
433
+ def check_version_status() -> dict | None:
434
+ """
435
+ Check if HtmlGraph has updates available.
436
+
437
+ Compares installed version with latest version on PyPI.
438
+ Attempts multiple methods to get version information:
439
+ 1. import htmlgraph and check __version__
440
+ 2. pip show htmlgraph
441
+ 3. PyPI JSON API (requires network)
442
+
443
+ Returns:
444
+ dict with version info if outdated:
445
+ {
446
+ "installed": "0.9.0",
447
+ "latest": "0.9.1",
448
+ "is_outdated": True
449
+ }
450
+ None if versions match or cannot be determined
451
+
452
+ Note:
453
+ - Never blocks on network errors (5 second timeout)
454
+ - Gracefully degrades if methods unavailable
455
+ - Safe for use in hooks (catches all exceptions)
456
+ """
457
+ try:
458
+ installed_version = _get_installed_version()
459
+ latest_version = _get_latest_pypi_version()
460
+
461
+ if not (installed_version and latest_version):
462
+ return None
463
+
464
+ if installed_version == latest_version:
465
+ return None
466
+
467
+ # Compare versions
468
+ is_outdated = _compare_versions(installed_version, latest_version)
469
+ if is_outdated:
470
+ return {
471
+ "installed": installed_version,
472
+ "latest": latest_version,
473
+ "is_outdated": True,
474
+ }
475
+
476
+ return None
477
+
478
+ except Exception:
479
+ return None
480
+
481
+
482
+ # ============================================================================
483
+ # Private Helper Functions
484
+ # ============================================================================
485
+
486
+
487
+ def _get_head_commit(project_dir: str) -> str | None:
488
+ """Get current HEAD commit hash (short form)."""
489
+ try:
490
+ result = subprocess.run(
491
+ ["git", "rev-parse", "--short", "HEAD"],
492
+ capture_output=True,
493
+ text=True,
494
+ cwd=project_dir,
495
+ timeout=5,
496
+ )
497
+ if result.returncode == 0:
498
+ return result.stdout.strip()
499
+ except Exception:
500
+ pass
501
+ return None
502
+
503
+
504
+ def _load_features(graph_dir: Path) -> list[dict]:
505
+ """Load all features as dicts."""
506
+ try:
507
+ from htmlgraph.converter import node_to_dict # type: ignore[import]
508
+ from htmlgraph.graph import HtmlGraph
509
+
510
+ features_dir = graph_dir / "features"
511
+ if not features_dir.exists():
512
+ return []
513
+
514
+ graph = HtmlGraph(features_dir, auto_load=True)
515
+ return [node_to_dict(node) for node in graph.nodes.values()]
516
+
517
+ except Exception:
518
+ return []
519
+
520
+
521
+ def _get_installed_version() -> str | None:
522
+ """Get installed htmlgraph version."""
523
+ # Method 1: Import and check __version__
524
+ try:
525
+ import htmlgraph
526
+
527
+ return htmlgraph.__version__
528
+ except Exception:
529
+ pass
530
+
531
+ # Method 2: pip show
532
+ try:
533
+ result = subprocess.run(
534
+ ["pip", "show", "htmlgraph"],
535
+ capture_output=True,
536
+ text=True,
537
+ timeout=10,
538
+ )
539
+ if result.returncode == 0:
540
+ for line in result.stdout.splitlines():
541
+ if line.startswith("Version:"):
542
+ return line.split(":", 1)[1].strip()
543
+ except Exception:
544
+ pass
545
+
546
+ return None
547
+
548
+
549
+ def _get_latest_pypi_version() -> str | None:
550
+ """Get latest htmlgraph version from PyPI."""
551
+ try:
552
+ import urllib.request
553
+
554
+ req = urllib.request.Request(
555
+ "https://pypi.org/pypi/htmlgraph/json",
556
+ headers={
557
+ "Accept": "application/json",
558
+ "User-Agent": "htmlgraph-version-check",
559
+ },
560
+ )
561
+ with urllib.request.urlopen(req, timeout=5) as response:
562
+ data = json.loads(response.read().decode())
563
+ version: str | None = data.get("info", {}).get("version")
564
+ return version
565
+ except Exception:
566
+ return None
567
+
568
+
569
+ def _compare_versions(installed: str, latest: str) -> bool:
570
+ """
571
+ Check if installed version is older than latest.
572
+
573
+ Args:
574
+ installed: Installed version string
575
+ latest: Latest version string
576
+
577
+ Returns:
578
+ True if installed < latest, False otherwise
579
+
580
+ Note:
581
+ - Uses semantic versioning comparison
582
+ - Falls back to string comparison for non-semver versions
583
+ """
584
+ try:
585
+ # Try semantic version comparison
586
+ installed_parts = [int(x) for x in installed.split(".")]
587
+ latest_parts = [int(x) for x in latest.split(".")]
588
+ return installed_parts < latest_parts
589
+ except (ValueError, IndexError):
590
+ # Fallback to string comparison
591
+ return installed != latest
592
+
593
+
594
+ def _cleanup_temp_files(graph_dir: Path) -> None:
595
+ """
596
+ Clean up temporary state files after session end.
597
+
598
+ Removes session-scoped temporary files that are no longer needed.
599
+ Safe to call even if files don't exist (idempotent).
600
+
601
+ Args:
602
+ graph_dir: Path to .htmlgraph directory
603
+ """
604
+ temp_patterns = [
605
+ "parent-activity.json",
606
+ "user-query-event-*.json",
607
+ ]
608
+
609
+ for pattern in temp_patterns:
610
+ if "*" in pattern:
611
+ # Handle glob patterns
612
+ import glob
613
+
614
+ for path in glob.glob(str(graph_dir / pattern)):
615
+ try:
616
+ Path(path).unlink()
617
+ logger.debug(f"Cleaned up: {path}")
618
+ except Exception as e:
619
+ logger.debug(f"Could not clean up {path}: {e}")
620
+ else:
621
+ # Handle single file
622
+ file_path: Path = graph_dir / pattern
623
+ try:
624
+ if file_path.exists():
625
+ file_path.unlink()
626
+ logger.debug(f"Cleaned up: {file_path}")
627
+ except Exception as e:
628
+ logger.debug(f"Could not clean up {file_path}: {e}")
629
+
630
+
631
+ __all__ = [
632
+ "init_or_get_session",
633
+ "handle_session_start",
634
+ "handle_session_end",
635
+ "record_user_query_event",
636
+ "check_version_status",
637
+ ]