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