openrunner-sdk 2.4.4__tar.gz → 2.6.0__tar.gz

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 (116) hide show
  1. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/PKG-INFO +1 -1
  2. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/__init__.py +1 -1
  3. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/api_client.py +17 -6
  4. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cli.py +102 -0
  5. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/run.py +9 -1
  6. openrunner_sdk-2.6.0/openrunner/session.py +531 -0
  7. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/pyproject.toml +1 -1
  8. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/.gitignore +0 -0
  9. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/=6.0 +0 -0
  10. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/=8.1 +0 -0
  11. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/README.md +0 -0
  12. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/artifact.py +0 -0
  13. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/buffer.py +0 -0
  14. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cache.py +0 -0
  15. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/config.py +0 -0
  16. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cost.py +0 -0
  17. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/dataset.py +0 -0
  18. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/environment.py +0 -0
  19. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/evaluation.py +0 -0
  20. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/feedback.py +0 -0
  21. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/git_info.py +0 -0
  22. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/guardrails.py +0 -0
  23. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/__init__.py +0 -0
  24. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/accelerate.py +0 -0
  25. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/anthropic_tracer.py +0 -0
  26. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/catboost.py +0 -0
  27. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/diffusers.py +0 -0
  28. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/fastai.py +0 -0
  29. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/forced_alignment.py +0 -0
  30. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/gladia.py +0 -0
  31. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/gymnasium.py +0 -0
  32. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/huggingface.py +0 -0
  33. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/hydra.py +0 -0
  34. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/ignite.py +0 -0
  35. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/jax.py +0 -0
  36. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/keras.py +0 -0
  37. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/langchain.py +0 -0
  38. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/lightgbm.py +0 -0
  39. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/lightning.py +0 -0
  40. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/llamaindex.py +0 -0
  41. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/openai_finetune.py +0 -0
  42. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/openai_tracer.py +0 -0
  43. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/optuna.py +0 -0
  44. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/pytorch.py +0 -0
  45. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/sb3.py +0 -0
  46. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/sklearn.py +0 -0
  47. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/tensorflow.py +0 -0
  48. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/trl.py +0 -0
  49. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/tts.py +0 -0
  50. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/ultralytics.py +0 -0
  51. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/voice_agent.py +0 -0
  52. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/whisper.py +0 -0
  53. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/xgboost.py +0 -0
  54. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/launch.py +0 -0
  55. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/media.py +0 -0
  56. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/migrate.py +0 -0
  57. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/model.py +0 -0
  58. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/offline.py +0 -0
  59. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/pii.py +0 -0
  60. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/plot.py +0 -0
  61. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/prompt.py +0 -0
  62. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/query_api.py +0 -0
  63. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/scorers.py +0 -0
  64. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/sender.py +0 -0
  65. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/settings.py +0 -0
  66. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/summary.py +0 -0
  67. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/sweep.py +0 -0
  68. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/system_metrics.py +0 -0
  69. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/tensorboard.py +0 -0
  70. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/trace.py +0 -0
  71. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/transcript_formatter.py +0 -0
  72. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wal.py +0 -0
  73. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wandb_compat/__init__.py +0 -0
  74. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wandb_compat/_shim.py +0 -0
  75. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wer.py +0 -0
  76. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/__init__.py +0 -0
  77. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/conftest.py +0 -0
  78. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_alert.py +0 -0
  79. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_aliases.py +0 -0
  80. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_api_client.py +0 -0
  81. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_artifact.py +0 -0
  82. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_buffer.py +0 -0
  83. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_cache.py +0 -0
  84. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_class_scorers.py +0 -0
  85. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_cli.py +0 -0
  86. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_config.py +0 -0
  87. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_evaluation.py +0 -0
  88. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_finish.py +0 -0
  89. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_git_info.py +0 -0
  90. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_init.py +0 -0
  91. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_fastai.py +0 -0
  92. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_huggingface.py +0 -0
  93. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_keras.py +0 -0
  94. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_langchain.py +0 -0
  95. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_lightning.py +0 -0
  96. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_pytorch.py +0 -0
  97. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_sklearn.py +0 -0
  98. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_xgboost.py +0 -0
  99. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_launch.py +0 -0
  100. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_log.py +0 -0
  101. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_log_code.py +0 -0
  102. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_media.py +0 -0
  103. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_migrate.py +0 -0
  104. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_offline.py +0 -0
  105. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_offline_sync.py +0 -0
  106. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_pii.py +0 -0
  107. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_plot.py +0 -0
  108. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_query_api.py +0 -0
  109. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_resume.py +0 -0
  110. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sdk_features.py +0 -0
  111. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sender.py +0 -0
  112. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_summary.py +0 -0
  113. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sweep.py +0 -0
  114. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_system_metrics.py +0 -0
  115. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_trace.py +0 -0
  116. {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_wandb_compat.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrunner-sdk
3
- Version: 2.4.4
3
+ Version: 2.6.0
4
4
  Summary: OpenRunner SDK - W&B-compatible ML experiment tracking client
5
5
  Project-URL: Homepage, https://github.com/jqueguiner/openrunner
6
6
  Project-URL: Repository, https://github.com/jqueguiner/openrunner
@@ -120,7 +120,7 @@ launch.from_run = _launch_from_run # type: ignore[attr-defined]
120
120
  # openrunner.trace.patch_openai() syntax
121
121
  trace.patch_openai = _patch_openai # type: ignore[attr-defined]
122
122
 
123
- __version__ = "2.4.4"
123
+ __version__ = "2.6.0"
124
124
 
125
125
  logger = logging.getLogger("openrunner")
126
126
 
@@ -693,27 +693,38 @@ class APIClient:
693
693
  2. Absolute URL — try direct PUT, fall back to proxy on ConnectError
694
694
  3. ConnectError fallback — PUT via /runs/{run_id}/files/{name}
695
695
  """
696
+ import os
697
+
696
698
  try:
697
- with open(file_path, "rb") as f:
698
- data = f.read()
699
+ file_size = os.path.getsize(file_path)
700
+ # Use streaming for large files, read into memory for small ones
701
+ if file_size > 50 * 1024 * 1024: # 50 MB
702
+ data = open(file_path, "rb") # noqa: SIM115
703
+ else:
704
+ with open(file_path, "rb") as f:
705
+ data = f.read()
706
+
707
+ # Scale timeout by file size (minimum 60s, ~30s per 100MB)
708
+ upload_timeout = max(60.0, file_size / (3 * 1024 * 1024))
699
709
 
700
710
  # Case 1: relative proxy URL from server
701
711
  if presigned_url.startswith("/"):
702
712
  api_path = presigned_url.replace("/api/v1/", "/", 1) if presigned_url.startswith("/api/v1/") else presigned_url
703
- resp = self._request("put", api_path, content=data)
713
+ resp = self._client.put(api_path, content=data, timeout=upload_timeout)
704
714
  return resp.status_code == 200
705
715
 
706
716
  # Case 2: absolute presigned URL (direct to S3)
707
717
  try:
708
- resp = httpx.put(presigned_url, content=data, timeout=300.0)
718
+ resp = httpx.put(presigned_url, content=data, timeout=upload_timeout)
709
719
  resp.raise_for_status()
710
720
  return True
711
721
  except (httpx.ConnectError, httpx.ConnectTimeout):
712
722
  # Case 3: fallback to API proxy
713
723
  if run_id:
714
- import os
715
724
  fname = os.path.basename(file_path)
716
- proxy_resp = self._request("put", f"/runs/{run_id}/files/{fname}", content=data)
725
+ proxy_resp = self._client.put(
726
+ f"/runs/{run_id}/files/{fname}", content=data, timeout=upload_timeout
727
+ )
717
728
  return proxy_resp.status_code == 200
718
729
  return False
719
730
  except Exception as e:
@@ -2362,3 +2362,105 @@ def _builtin_suggest(
2362
2362
  result[name] = spec
2363
2363
 
2364
2364
  return result if result else None
2365
+
2366
+
2367
+ # ---------------------------------------------------------------------------
2368
+ # session commands — AI session capture
2369
+ # ---------------------------------------------------------------------------
2370
+
2371
+
2372
+ @main.group()
2373
+ def session() -> None:
2374
+ """Capture and log AI coding sessions (Claude Code, ChatGPT, Codex, Qwen)."""
2375
+ pass
2376
+
2377
+
2378
+ @session.command("sync")
2379
+ @click.option("--hours", "-h", default=24.0, help="Look back N hours (default: 24)")
2380
+ @click.option("--project", "-p", default=None, help="Target project (default: research-sessions)")
2381
+ @click.option("--dry-run", is_flag=True, help="Show what would be synced without uploading")
2382
+ def session_sync(hours: float, project: str | None, dry_run: bool) -> None:
2383
+ """Sync recent AI sessions to OpenRunner."""
2384
+ from openrunner.session import discover_all_sessions, sync_all
2385
+
2386
+ if dry_run:
2387
+ sessions = discover_all_sessions(hours)
2388
+ if not sessions:
2389
+ click.echo("No sessions found.")
2390
+ return
2391
+ click.echo(f"Found {len(sessions)} session(s):")
2392
+ for s in sessions:
2393
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(s["mtime"]))
2394
+ size = s["size"] / 1024
2395
+ click.echo(f" [{s['source']}] {ts} ({size:.0f} KB) {s['path'].name}")
2396
+ return
2397
+
2398
+ synced = sync_all(since_hours=hours, project=project)
2399
+ if synced:
2400
+ click.echo(f"Synced {len(synced)} session(s) to OpenRunner.")
2401
+ for run_id in synced:
2402
+ click.echo(f" -> run {run_id}")
2403
+ else:
2404
+ click.echo("No new sessions to sync.")
2405
+
2406
+
2407
+ @session.command("watch")
2408
+ @click.option("--interval", "-i", default=60, help="Check interval in seconds (default: 60)")
2409
+ @click.option("--project", "-p", default=None, help="Target project")
2410
+ def session_watch(interval: int, project: str | None) -> None:
2411
+ """Watch for new sessions and sync them continuously (daemon mode)."""
2412
+ from openrunner.session import watch
2413
+ click.echo(f"Watching for AI sessions (every {interval}s). Ctrl+C to stop.")
2414
+ try:
2415
+ watch(interval=interval, project=project)
2416
+ except KeyboardInterrupt:
2417
+ click.echo("\nStopped.")
2418
+
2419
+
2420
+ @session.command("list")
2421
+ @click.option("--hours", "-h", default=24.0, help="Look back N hours")
2422
+ def session_list(hours: float) -> None:
2423
+ """List discovered AI sessions."""
2424
+ from openrunner.session import discover_all_sessions, _load_sync_state
2425
+
2426
+ sessions = discover_all_sessions(hours)
2427
+ state = _load_sync_state()
2428
+
2429
+ if not sessions:
2430
+ click.echo("No sessions found.")
2431
+ return
2432
+
2433
+ click.echo(f"{'SOURCE':<14} {'TIME':<18} {'SIZE':<10} {'STATUS'}")
2434
+ click.echo("-" * 60)
2435
+ for s in sessions:
2436
+ from openrunner.session import _session_hash
2437
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(s["mtime"]))
2438
+ size = f"{s['size'] / 1024:.0f} KB"
2439
+ h = _session_hash(s["path"])
2440
+ synced_info = state.get("synced", {}).get(h)
2441
+ status = f"synced ({synced_info['run_id']})" if synced_info else "new"
2442
+ click.echo(f" {s['source']:<12} {ts:<18} {size:<10} {status}")
2443
+
2444
+
2445
+ @session.command("hook")
2446
+ @click.argument("action", type=click.Choice(["install", "uninstall"]))
2447
+ def session_hook(action: str) -> None:
2448
+ """Install/uninstall Claude Code auto-capture hook."""
2449
+ if action == "install":
2450
+ from openrunner.session import install_claude_hook
2451
+ path = install_claude_hook()
2452
+ click.echo(f"Claude Code hook installed.")
2453
+ click.echo(f" Config: {path}")
2454
+ click.echo(" Sessions will be auto-logged on exit.")
2455
+ else:
2456
+ from pathlib import Path
2457
+ hooks_file = Path.home() / ".claude" / "hooks.json"
2458
+ if hooks_file.exists():
2459
+ import json as json_mod
2460
+ hooks = json_mod.loads(hooks_file.read_text())
2461
+ stop_hooks = hooks.get("hooks", {}).get("Stop", [])
2462
+ hooks["hooks"]["Stop"] = [h for h in stop_hooks if "openrunner" not in str(h)]
2463
+ hooks_file.write_text(json_mod.dumps(hooks, indent=2))
2464
+ click.echo("Claude Code hook removed.")
2465
+ else:
2466
+ click.echo("No hooks.json found.")
@@ -825,13 +825,21 @@ class Run:
825
825
 
826
826
  # Upload files that need uploading (dedup may skip some)
827
827
  # Reference files and reused files never have upload URLs.
828
+ all_uploaded = True
828
829
  for url_entry in result.get("upload_urls", []):
829
830
  local = next(
830
831
  f for f in artifact._files if f["name"] == url_entry["name"]
831
832
  )
832
- self._client.upload_file_to_presigned_url(
833
+ ok = self._client.upload_file_to_presigned_url(
833
834
  url_entry["presigned_url"], local["local_path"]
834
835
  )
836
+ if not ok:
837
+ logger.warning("upload failed for %s", url_entry["name"])
838
+ all_uploaded = False
839
+
840
+ if not all_uploaded:
841
+ logger.error("artifact upload incomplete — not confirming version")
842
+ return None
835
843
 
836
844
  # Confirm the version
837
845
  self._client.confirm_artifact_version(result["version_id"])
@@ -0,0 +1,531 @@
1
+ """AI session capture — sniff and log coding sessions from Claude Code, ChatGPT, Codex, Qwen.
2
+
3
+ Usage:
4
+ openrunner session watch # daemon mode, auto-logs new sessions
5
+ openrunner session sync # one-shot, sync recent unlogged sessions
6
+ openrunner session hook install # install Claude Code hook for auto-capture
7
+ openrunner session list # list captured sessions
8
+
9
+ Supports:
10
+ - Claude Code (~/.claude/projects/*/*.jsonl)
11
+ - ChatGPT API logs (if OPENAI_LOG_DIR set)
12
+ - Codex CLI (~/.codex/sessions/)
13
+ - Qwen Code (~/.qwen-code/sessions/)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ logger = logging.getLogger("openrunner.session")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Session source detectors
32
+ # ---------------------------------------------------------------------------
33
+
34
+ CLAUDE_CODE_DIR = Path.home() / ".claude" / "projects"
35
+ CODEX_DIR = Path.home() / ".codex" / "sessions"
36
+ QWEN_DIR = Path.home() / ".qwen-code" / "sessions"
37
+ CHATGPT_LOG_DIR = Path(os.environ.get("OPENAI_LOG_DIR", "~/.openai/logs")).expanduser()
38
+
39
+ # Track which sessions we've already synced
40
+ SYNC_STATE_FILE = Path.home() / ".openrunner" / "session_sync_state.json"
41
+
42
+
43
+ def _load_sync_state() -> dict:
44
+ """Load set of already-synced session hashes."""
45
+ if SYNC_STATE_FILE.exists():
46
+ return json.loads(SYNC_STATE_FILE.read_text())
47
+ return {"synced": {}}
48
+
49
+
50
+ def _save_sync_state(state: dict) -> None:
51
+ SYNC_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
52
+ SYNC_STATE_FILE.write_text(json.dumps(state, indent=2))
53
+
54
+
55
+ def _session_hash(path: Path) -> str:
56
+ """Stable hash for a session file (path + size + mtime)."""
57
+ stat = path.stat()
58
+ key = f"{path}:{stat.st_size}:{int(stat.st_mtime)}"
59
+ return hashlib.md5(key.encode()).hexdigest()[:12]
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Claude Code parser
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def discover_claude_sessions(since_hours: float = 24) -> list[dict]:
68
+ """Find recent Claude Code session files."""
69
+ sessions = []
70
+ if not CLAUDE_CODE_DIR.exists():
71
+ return sessions
72
+
73
+ cutoff = time.time() - (since_hours * 3600)
74
+ for jsonl in CLAUDE_CODE_DIR.rglob("*.jsonl"):
75
+ if jsonl.stat().st_mtime < cutoff:
76
+ continue
77
+ sessions.append({
78
+ "source": "claude-code",
79
+ "path": jsonl,
80
+ "mtime": jsonl.stat().st_mtime,
81
+ "size": jsonl.stat().st_size,
82
+ })
83
+ return sorted(sessions, key=lambda s: s["mtime"], reverse=True)
84
+
85
+
86
+ def parse_claude_session(path: Path) -> dict[str, Any]:
87
+ """Parse a Claude Code .jsonl session into structured data."""
88
+ messages = []
89
+ tools_used = set()
90
+ files_touched = set()
91
+ total_tokens = 0
92
+
93
+ with open(path) as f:
94
+ for line in f:
95
+ line = line.strip()
96
+ if not line:
97
+ continue
98
+ try:
99
+ entry = json.loads(line)
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ # Claude Code format: {type, message: {role, content}, ...}
104
+ msg = entry.get("message", entry)
105
+ role = msg.get("role", entry.get("type", ""))
106
+ content = msg.get("content", "")
107
+
108
+ # Handle content as list (Claude API format)
109
+ if isinstance(content, list):
110
+ text_parts = []
111
+ for block in content:
112
+ if isinstance(block, dict):
113
+ if block.get("type") == "text":
114
+ text_parts.append(block.get("text", ""))
115
+ elif block.get("type") == "tool_use":
116
+ tools_used.add(block.get("name", "unknown"))
117
+ # Track files from Read/Write/Edit
118
+ inp = block.get("input", {})
119
+ if "file_path" in inp:
120
+ files_touched.add(inp["file_path"])
121
+ elif "path" in inp:
122
+ files_touched.add(inp["path"])
123
+ elif block.get("type") == "tool_result":
124
+ pass
125
+ content = "\n".join(text_parts)
126
+
127
+ if role == "user" and content:
128
+ messages.append({"role": "user", "content": content[:2000]})
129
+ elif role == "assistant" and content:
130
+ messages.append({"role": "assistant", "content": content[:2000]})
131
+
132
+ # Token counting from usage field (may be at entry or message level)
133
+ usage = entry.get("usage", msg.get("usage", {}))
134
+ total_tokens += usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
135
+
136
+ # Extract project context from path
137
+ # ~/.claude/projects/-home-user-myproject/session.jsonl
138
+ project_hint = path.parent.name.replace("-", "/").lstrip("/")
139
+
140
+ # Build summary
141
+ user_messages = [m for m in messages if m["role"] == "user"]
142
+ first_msg = user_messages[0]["content"][:200] if user_messages else "Empty session"
143
+
144
+ return {
145
+ "source": "claude-code",
146
+ "session_file": str(path),
147
+ "project_hint": project_hint,
148
+ "started_at": datetime.fromtimestamp(path.stat().st_mtime - _estimate_duration(path), tz=timezone.utc).isoformat(),
149
+ "ended_at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat(),
150
+ "message_count": len(messages),
151
+ "user_message_count": len(user_messages),
152
+ "tools_used": sorted(tools_used),
153
+ "files_touched": sorted(files_touched)[:50],
154
+ "total_tokens": total_tokens,
155
+ "first_message": first_msg,
156
+ "summary": _summarize_session(messages),
157
+ "messages": messages[:100], # cap at 100 for storage
158
+ }
159
+
160
+
161
+ def _estimate_duration(path: Path) -> float:
162
+ """Rough session duration estimate from file size."""
163
+ # ~500 bytes per message exchange, ~30s per exchange
164
+ size = path.stat().st_size
165
+ exchanges = size / 500
166
+ return min(exchanges * 30, 7200) # cap at 2h
167
+
168
+
169
+ def _summarize_session(messages: list[dict]) -> str:
170
+ """Generate a text summary from messages."""
171
+ user_msgs = [m["content"] for m in messages if m["role"] == "user"]
172
+ if not user_msgs:
173
+ return "Empty session"
174
+
175
+ # Use first and last user messages as summary
176
+ parts = []
177
+ if user_msgs:
178
+ parts.append(f"Started: {user_msgs[0][:100]}")
179
+ if len(user_msgs) > 1:
180
+ parts.append(f"Last: {user_msgs[-1][:100]}")
181
+ parts.append(f"({len(user_msgs)} user messages)")
182
+ return " | ".join(parts)
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Codex / Qwen / ChatGPT parsers (simpler)
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def discover_codex_sessions(since_hours: float = 24) -> list[dict]:
191
+ """Find recent Codex sessions."""
192
+ sessions = []
193
+ if not CODEX_DIR.exists():
194
+ return sessions
195
+ cutoff = time.time() - (since_hours * 3600)
196
+ for f in CODEX_DIR.glob("*.json"):
197
+ if f.stat().st_mtime < cutoff:
198
+ continue
199
+ sessions.append({"source": "codex", "path": f, "mtime": f.stat().st_mtime, "size": f.stat().st_size})
200
+ return sessions
201
+
202
+
203
+ def discover_qwen_sessions(since_hours: float = 24) -> list[dict]:
204
+ """Find recent Qwen Code sessions."""
205
+ sessions = []
206
+ if not QWEN_DIR.exists():
207
+ return sessions
208
+ cutoff = time.time() - (since_hours * 3600)
209
+ for f in QWEN_DIR.rglob("*.json"):
210
+ if f.stat().st_mtime < cutoff:
211
+ continue
212
+ sessions.append({"source": "qwen", "path": f, "mtime": f.stat().st_mtime, "size": f.stat().st_size})
213
+ return sessions
214
+
215
+
216
+ def discover_chatgpt_sessions(since_hours: float = 24) -> list[dict]:
217
+ """Find ChatGPT API log files."""
218
+ sessions = []
219
+ if not CHATGPT_LOG_DIR.exists():
220
+ return sessions
221
+ cutoff = time.time() - (since_hours * 3600)
222
+ for f in CHATGPT_LOG_DIR.rglob("*.jsonl"):
223
+ if f.stat().st_mtime < cutoff:
224
+ continue
225
+ sessions.append({"source": "chatgpt", "path": f, "mtime": f.stat().st_mtime, "size": f.stat().st_size})
226
+ return sessions
227
+
228
+
229
+ def parse_generic_session(path: Path, source: str) -> dict[str, Any]:
230
+ """Parse a generic JSON/JSONL session file."""
231
+ messages = []
232
+ try:
233
+ if path.suffix == ".jsonl":
234
+ with open(path) as f:
235
+ for line in f:
236
+ if line.strip():
237
+ try:
238
+ messages.append(json.loads(line))
239
+ except json.JSONDecodeError:
240
+ pass
241
+ else:
242
+ data = json.loads(path.read_text())
243
+ if isinstance(data, list):
244
+ messages = data
245
+ elif isinstance(data, dict) and "messages" in data:
246
+ messages = data["messages"]
247
+ except Exception:
248
+ pass
249
+
250
+ return {
251
+ "source": source,
252
+ "session_file": str(path),
253
+ "project_hint": "",
254
+ "started_at": datetime.fromtimestamp(path.stat().st_mtime - 1800, tz=timezone.utc).isoformat(),
255
+ "ended_at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat(),
256
+ "message_count": len(messages),
257
+ "user_message_count": sum(1 for m in messages if isinstance(m, dict) and m.get("role") == "user"),
258
+ "tools_used": [],
259
+ "files_touched": [],
260
+ "total_tokens": 0,
261
+ "first_message": str(messages[0])[:200] if messages else "Empty",
262
+ "summary": f"{source} session with {len(messages)} messages",
263
+ "messages": messages[:100],
264
+ }
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Sync to OpenRunner
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def sync_session_to_openrunner(
273
+ parsed: dict[str, Any],
274
+ project: str | None = None,
275
+ api_key: str | None = None,
276
+ base_url: str | None = None,
277
+ ) -> str | None:
278
+ """Upload a parsed session to OpenRunner as a run with notes.
279
+
280
+ Returns the run ID on success, None on failure.
281
+ """
282
+ from openrunner.api_client import APIClient
283
+
284
+ # Load settings from ~/.openrunner/settings.json
285
+ if not api_key or not base_url:
286
+ settings_file = Path.home() / ".openrunner" / "settings.json"
287
+ settings = {}
288
+ if settings_file.exists():
289
+ try:
290
+ settings = json.loads(settings_file.read_text())
291
+ except (json.JSONDecodeError, OSError):
292
+ pass
293
+ api_key = api_key or os.environ.get("OPENRUNNER_API_KEY") or settings.get("api_key")
294
+ base_url = base_url or os.environ.get("OPENRUNNER_BASE_URL") or settings.get("base_url")
295
+
296
+ if not api_key or not base_url:
297
+ logger.warning("No API key or base URL configured. Run 'openrunner login' first.")
298
+ return None
299
+
300
+ # Determine project
301
+ if not project:
302
+ project = os.environ.get("OPENRUNNER_SESSION_PROJECT", "research-sessions")
303
+
304
+ client = APIClient(base_url=base_url, api_key=api_key)
305
+
306
+ # Create run
307
+ source = parsed["source"]
308
+ timestamp = parsed.get("ended_at", "")[:16].replace("T", " ")
309
+ run_name = f"{source}/{timestamp}"
310
+
311
+ run_data = {
312
+ "project": project,
313
+ "display_name": run_name,
314
+ "config": {
315
+ "source": source,
316
+ "session_file": parsed.get("session_file", ""),
317
+ "project_hint": parsed.get("project_hint", ""),
318
+ "message_count": parsed.get("message_count", 0),
319
+ "user_message_count": parsed.get("user_message_count", 0),
320
+ "tools_used": parsed.get("tools_used", []),
321
+ "total_tokens": parsed.get("total_tokens", 0),
322
+ },
323
+ "tags": [f"source:{source}", "ai-session"],
324
+ "notes": _format_session_notes(parsed),
325
+ "state": "finished",
326
+ }
327
+
328
+ result = client.create_run(run_data)
329
+ if not result:
330
+ logger.warning("Failed to create session run")
331
+ client.close()
332
+ return None
333
+
334
+ run_id = result.get("id")
335
+
336
+ # Log metrics
337
+ metrics = []
338
+ if parsed.get("total_tokens"):
339
+ metrics.append({"key": "tokens", "value": parsed["total_tokens"], "step": 1})
340
+ if parsed.get("message_count"):
341
+ metrics.append({"key": "messages", "value": parsed["message_count"], "step": 1})
342
+ if parsed.get("user_message_count"):
343
+ metrics.append({"key": "user_messages", "value": parsed["user_message_count"], "step": 1})
344
+ if parsed.get("files_touched"):
345
+ metrics.append({"key": "files_touched", "value": len(parsed["files_touched"]), "step": 1})
346
+
347
+ if metrics and run_id:
348
+ client.post_metrics(run_id, metrics)
349
+
350
+ # Log files touched as summary
351
+ if parsed.get("files_touched") and run_id:
352
+ client.update_run(run_id, {
353
+ "summary": {
354
+ "files_touched": len(parsed["files_touched"]),
355
+ "tools_used": len(parsed.get("tools_used", [])),
356
+ "tokens": parsed.get("total_tokens", 0),
357
+ }
358
+ })
359
+
360
+ client.close()
361
+ return run_id
362
+
363
+
364
+ def _format_session_notes(parsed: dict) -> str:
365
+ """Format parsed session into readable notes."""
366
+ lines = []
367
+ lines.append(f"# {parsed['source'].title()} Session")
368
+ lines.append(f"**Time:** {parsed.get('started_at', '?')[:16]} → {parsed.get('ended_at', '?')[:16]}")
369
+ lines.append("")
370
+
371
+ if parsed.get("first_message"):
372
+ lines.append(f"## First Request")
373
+ lines.append(parsed["first_message"])
374
+ lines.append("")
375
+
376
+ if parsed.get("tools_used"):
377
+ lines.append(f"## Tools Used ({len(parsed['tools_used'])})")
378
+ for t in parsed["tools_used"][:20]:
379
+ lines.append(f"- {t}")
380
+ lines.append("")
381
+
382
+ if parsed.get("files_touched"):
383
+ lines.append(f"## Files Touched ({len(parsed['files_touched'])})")
384
+ for f in parsed["files_touched"][:30]:
385
+ lines.append(f"- `{f}`")
386
+ lines.append("")
387
+
388
+ if parsed.get("summary"):
389
+ lines.append(f"## Summary")
390
+ lines.append(parsed["summary"])
391
+
392
+ return "\n".join(lines)
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Discovery + sync orchestration
397
+ # ---------------------------------------------------------------------------
398
+
399
+
400
+ def discover_all_sessions(since_hours: float = 24) -> list[dict]:
401
+ """Discover sessions from all sources."""
402
+ all_sessions = []
403
+ all_sessions.extend(discover_claude_sessions(since_hours))
404
+ all_sessions.extend(discover_codex_sessions(since_hours))
405
+ all_sessions.extend(discover_qwen_sessions(since_hours))
406
+ all_sessions.extend(discover_chatgpt_sessions(since_hours))
407
+ return sorted(all_sessions, key=lambda s: s["mtime"], reverse=True)
408
+
409
+
410
+ def sync_all(
411
+ since_hours: float = 24,
412
+ project: str | None = None,
413
+ dry_run: bool = False,
414
+ ) -> list[str]:
415
+ """Discover and sync all new sessions. Returns list of synced run IDs."""
416
+ state = _load_sync_state()
417
+ sessions = discover_all_sessions(since_hours)
418
+ synced_ids = []
419
+
420
+ for session_info in sessions:
421
+ h = _session_hash(session_info["path"])
422
+ if h in state["synced"]:
423
+ continue
424
+
425
+ if dry_run:
426
+ logger.info(f"[dry-run] Would sync: {session_info['source']} {session_info['path']}")
427
+ continue
428
+
429
+ # Parse
430
+ if session_info["source"] == "claude-code":
431
+ parsed = parse_claude_session(session_info["path"])
432
+ else:
433
+ parsed = parse_generic_session(session_info["path"], session_info["source"])
434
+
435
+ # Sync
436
+ run_id = sync_session_to_openrunner(parsed, project=project)
437
+ if run_id:
438
+ state["synced"][h] = {
439
+ "run_id": run_id,
440
+ "source": session_info["source"],
441
+ "synced_at": datetime.now(timezone.utc).isoformat(),
442
+ }
443
+ synced_ids.append(run_id)
444
+ logger.info(f"Synced {session_info['source']} session -> run {run_id}")
445
+
446
+ _save_sync_state(state)
447
+ return synced_ids
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # Claude Code hook installer
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ HOOK_SCRIPT = '''#!/usr/bin/env python3
456
+ """Auto-log Claude Code session to OpenRunner on exit."""
457
+ import sys
458
+ from pathlib import Path
459
+
460
+ def main():
461
+ # Find the most recent session file that was just written
462
+ from openrunner.session import discover_claude_sessions, parse_claude_session, sync_session_to_openrunner
463
+
464
+ sessions = discover_claude_sessions(since_hours=0.1) # last 6 minutes
465
+ if not sessions:
466
+ return
467
+
468
+ latest = sessions[0]
469
+ parsed = parse_claude_session(latest["path"])
470
+
471
+ # Only sync if meaningful (>2 user messages)
472
+ if parsed.get("user_message_count", 0) < 2:
473
+ return
474
+
475
+ run_id = sync_session_to_openrunner(parsed)
476
+ if run_id:
477
+ print(f"openrunner: Session logged as run {run_id}", file=sys.stderr)
478
+
479
+ if __name__ == "__main__":
480
+ main()
481
+ '''
482
+
483
+
484
+ def install_claude_hook() -> str:
485
+ """Install Claude Code hook for auto-session capture.
486
+
487
+ Returns path to the installed hook config.
488
+ """
489
+ claude_dir = Path.home() / ".claude"
490
+ claude_dir.mkdir(exist_ok=True)
491
+
492
+ # Write hook script
493
+ hook_script_path = claude_dir / "openrunner_session_hook.py"
494
+ hook_script_path.write_text(HOOK_SCRIPT)
495
+ hook_script_path.chmod(0o755)
496
+
497
+ # Update or create hooks.json
498
+ hooks_file = claude_dir / "hooks.json"
499
+ if hooks_file.exists():
500
+ hooks = json.loads(hooks_file.read_text())
501
+ else:
502
+ hooks = {"hooks": {}}
503
+
504
+ # Add to Stop hooks
505
+ stop_hooks = hooks.setdefault("hooks", {}).setdefault("Stop", [])
506
+ hook_cmd = f"python3 {hook_script_path}"
507
+
508
+ # Don't duplicate
509
+ if not any(hook_cmd in str(h) for h in stop_hooks):
510
+ stop_hooks.append({"command": hook_cmd})
511
+
512
+ hooks_file.write_text(json.dumps(hooks, indent=2))
513
+ return str(hooks_file)
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Watch mode (daemon)
518
+ # ---------------------------------------------------------------------------
519
+
520
+
521
+ def watch(interval: int = 60, project: str | None = None) -> None:
522
+ """Watch for new sessions and sync them continuously."""
523
+ logger.info(f"Watching for AI sessions (interval={interval}s)...")
524
+ while True:
525
+ try:
526
+ synced = sync_all(since_hours=1, project=project)
527
+ if synced:
528
+ logger.info(f"Synced {len(synced)} new session(s)")
529
+ except Exception as e:
530
+ logger.warning(f"Watch cycle error: {e}")
531
+ time.sleep(interval)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openrunner-sdk"
3
- version = "2.4.4"
3
+ version = "2.6.0"
4
4
  description = "OpenRunner SDK - W&B-compatible ML experiment tracking client"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
File without changes
File without changes
File without changes