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.
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/PKG-INFO +1 -1
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/__init__.py +1 -1
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/api_client.py +17 -6
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cli.py +102 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/run.py +9 -1
- openrunner_sdk-2.6.0/openrunner/session.py +531 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/pyproject.toml +1 -1
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/.gitignore +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/=6.0 +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/=8.1 +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/README.md +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/artifact.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/buffer.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cache.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/config.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/cost.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/dataset.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/environment.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/evaluation.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/feedback.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/git_info.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/guardrails.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/__init__.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/accelerate.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/anthropic_tracer.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/catboost.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/diffusers.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/fastai.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/forced_alignment.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/gladia.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/gymnasium.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/huggingface.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/hydra.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/ignite.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/jax.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/keras.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/langchain.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/lightgbm.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/lightning.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/llamaindex.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/openai_finetune.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/openai_tracer.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/optuna.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/pytorch.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/sb3.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/sklearn.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/tensorflow.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/trl.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/tts.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/ultralytics.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/voice_agent.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/whisper.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/integration/xgboost.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/launch.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/media.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/migrate.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/model.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/offline.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/pii.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/plot.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/prompt.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/query_api.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/scorers.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/sender.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/settings.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/summary.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/sweep.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/system_metrics.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/tensorboard.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/trace.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/transcript_formatter.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wal.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wandb_compat/__init__.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wandb_compat/_shim.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/openrunner/wer.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/__init__.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/conftest.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_alert.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_aliases.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_api_client.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_artifact.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_buffer.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_cache.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_class_scorers.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_cli.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_config.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_evaluation.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_finish.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_git_info.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_init.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_fastai.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_huggingface.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_keras.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_langchain.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_lightning.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_pytorch.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_sklearn.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_integration_xgboost.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_launch.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_log.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_log_code.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_media.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_migrate.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_offline.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_offline_sync.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_pii.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_plot.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_query_api.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_resume.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sdk_features.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sender.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_summary.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_sweep.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_system_metrics.py +0 -0
- {openrunner_sdk-2.4.4 → openrunner_sdk-2.6.0}/tests/test_trace.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
698
|
-
|
|
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.
|
|
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=
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|