synth-ai 0.2.2.dev0__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. synth_ai/cli/__init__.py +66 -0
  2. synth_ai/cli/balance.py +205 -0
  3. synth_ai/cli/calc.py +70 -0
  4. synth_ai/cli/demo.py +74 -0
  5. synth_ai/{cli.py → cli/legacy_root_backup.py} +60 -15
  6. synth_ai/cli/man.py +103 -0
  7. synth_ai/cli/recent.py +126 -0
  8. synth_ai/cli/root.py +184 -0
  9. synth_ai/cli/status.py +126 -0
  10. synth_ai/cli/traces.py +136 -0
  11. synth_ai/cli/watch.py +508 -0
  12. synth_ai/config/base_url.py +53 -0
  13. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +252 -0
  14. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_duckdb_v2_backup.py +413 -0
  15. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +646 -0
  16. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_synth.py +34 -0
  17. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth.py +1740 -0
  18. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth_v2_backup.py +1318 -0
  19. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_duckdb_v2_backup.py +386 -0
  20. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +580 -0
  21. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v2_backup.py +1352 -0
  22. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/test_crafter_react_agent_openai_v2_backup.py +2551 -0
  23. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +1 -1
  24. synth_ai/environments/examples/crafter_classic/agent_demos/old/traces/session_crafter_episode_16_15227b68-2906-416f-acc4-d6a9b4fa5828_20250725_001154.json +1363 -1
  25. synth_ai/environments/examples/crafter_classic/agent_demos/test_crafter_react_agent.py +3 -3
  26. synth_ai/environments/examples/enron/dataset/corbt___enron_emails_sample_questions/default/0.0.0/293c9fe8170037e01cc9cf5834e0cd5ef6f1a6bb/dataset_info.json +1 -0
  27. synth_ai/environments/examples/nethack/helpers/achievements.json +64 -0
  28. synth_ai/environments/examples/red/units/test_exploration_strategy.py +1 -1
  29. synth_ai/environments/examples/red/units/test_menu_bug_reproduction.py +5 -5
  30. synth_ai/environments/examples/red/units/test_movement_debug.py +2 -2
  31. synth_ai/environments/examples/red/units/test_retry_movement.py +1 -1
  32. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/available_envs.json +122 -0
  33. synth_ai/environments/examples/sokoban/verified_puzzles.json +54987 -0
  34. synth_ai/experimental/synth_oss.py +446 -0
  35. synth_ai/learning/core.py +21 -0
  36. synth_ai/learning/gateway.py +4 -0
  37. synth_ai/learning/prompts/mipro.py +0 -0
  38. synth_ai/lm/__init__.py +3 -0
  39. synth_ai/lm/core/main.py +4 -0
  40. synth_ai/lm/core/main_v3.py +68 -13
  41. synth_ai/lm/core/vendor_clients.py +4 -0
  42. synth_ai/lm/provider_support/openai.py +11 -2
  43. synth_ai/lm/vendors/base.py +7 -0
  44. synth_ai/lm/vendors/openai_standard.py +339 -4
  45. synth_ai/lm/vendors/openai_standard_responses.py +243 -0
  46. synth_ai/lm/vendors/synth_client.py +155 -5
  47. synth_ai/lm/warmup.py +54 -17
  48. synth_ai/tracing/__init__.py +18 -0
  49. synth_ai/tracing_v1/__init__.py +29 -14
  50. synth_ai/tracing_v3/config.py +13 -7
  51. synth_ai/tracing_v3/db_config.py +6 -6
  52. synth_ai/tracing_v3/turso/manager.py +8 -8
  53. synth_ai/tui/__main__.py +13 -0
  54. synth_ai/tui/dashboard.py +329 -0
  55. synth_ai/v0/tracing/__init__.py +0 -0
  56. synth_ai/{tracing → v0/tracing}/base_client.py +3 -3
  57. synth_ai/{tracing → v0/tracing}/client_manager.py +1 -1
  58. synth_ai/{tracing → v0/tracing}/context.py +1 -1
  59. synth_ai/{tracing → v0/tracing}/decorators.py +11 -11
  60. synth_ai/v0/tracing/events/__init__.py +0 -0
  61. synth_ai/{tracing → v0/tracing}/events/manage.py +4 -4
  62. synth_ai/{tracing → v0/tracing}/events/scope.py +6 -6
  63. synth_ai/{tracing → v0/tracing}/events/store.py +3 -3
  64. synth_ai/{tracing → v0/tracing}/immediate_client.py +6 -6
  65. synth_ai/{tracing → v0/tracing}/log_client_base.py +2 -2
  66. synth_ai/{tracing → v0/tracing}/retry_queue.py +3 -3
  67. synth_ai/{tracing → v0/tracing}/trackers.py +2 -2
  68. synth_ai/{tracing → v0/tracing}/upload.py +4 -4
  69. synth_ai/v0/tracing_v1/__init__.py +16 -0
  70. synth_ai/{tracing_v1 → v0/tracing_v1}/base_client.py +3 -3
  71. synth_ai/{tracing_v1 → v0/tracing_v1}/client_manager.py +1 -1
  72. synth_ai/{tracing_v1 → v0/tracing_v1}/context.py +1 -1
  73. synth_ai/{tracing_v1 → v0/tracing_v1}/decorators.py +11 -11
  74. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  75. synth_ai/{tracing_v1 → v0/tracing_v1}/events/manage.py +4 -4
  76. synth_ai/{tracing_v1 → v0/tracing_v1}/events/scope.py +6 -6
  77. synth_ai/{tracing_v1 → v0/tracing_v1}/events/store.py +3 -3
  78. synth_ai/{tracing_v1 → v0/tracing_v1}/immediate_client.py +6 -6
  79. synth_ai/{tracing_v1 → v0/tracing_v1}/log_client_base.py +2 -2
  80. synth_ai/{tracing_v1 → v0/tracing_v1}/retry_queue.py +3 -3
  81. synth_ai/{tracing_v1 → v0/tracing_v1}/trackers.py +2 -2
  82. synth_ai/{tracing_v1 → v0/tracing_v1}/upload.py +4 -4
  83. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/METADATA +98 -4
  84. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/RECORD +98 -62
  85. /synth_ai/{tracing/events/__init__.py → environments/examples/crafter_classic/debug_translation.py} +0 -0
  86. /synth_ai/{tracing_v1/events/__init__.py → learning/prompts/gepa.py} +0 -0
  87. /synth_ai/{tracing → v0/tracing}/abstractions.py +0 -0
  88. /synth_ai/{tracing → v0/tracing}/config.py +0 -0
  89. /synth_ai/{tracing → v0/tracing}/local.py +0 -0
  90. /synth_ai/{tracing → v0/tracing}/utils.py +0 -0
  91. /synth_ai/{tracing_v1 → v0/tracing_v1}/abstractions.py +0 -0
  92. /synth_ai/{tracing_v1 → v0/tracing_v1}/config.py +0 -0
  93. /synth_ai/{tracing_v1 → v0/tracing_v1}/local.py +0 -0
  94. /synth_ai/{tracing_v1 → v0/tracing_v1}/utils.py +0 -0
  95. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/WHEEL +0 -0
  96. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/entry_points.txt +0 -0
  97. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/licenses/LICENSE +0 -0
  98. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,66 @@
1
+ """CLI subcommands for Synth AI.
2
+
3
+ This package hosts modular commands (watch, traces, recent, calc, status)
4
+ and exposes a top-level Click group named `cli` compatible with the
5
+ pyproject entry point `synth_ai.cli:cli`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import pathlib
12
+ import sys
13
+
14
+ # Load environment variables from a local .env if present (repo root)
15
+ try:
16
+ from dotenv import load_dotenv, find_dotenv
17
+ # Source .env early so CLI subcommands inherit config; do not override shell
18
+ load_dotenv(find_dotenv(usecwd=True), override=False)
19
+ except Exception:
20
+ # dotenv is optional at runtime; proceed if unavailable
21
+ pass
22
+
23
+
24
+ from .root import cli # new canonical CLI entrypoint
25
+
26
+ # Register subcommands from this package onto the group
27
+ try:
28
+ from . import watch as _watch
29
+ _watch.register(cli)
30
+ except Exception:
31
+ pass
32
+ try:
33
+ from . import balance as _balance
34
+ _balance.register(cli)
35
+ except Exception:
36
+ pass
37
+ try:
38
+ from . import man as _man
39
+ _man.register(cli)
40
+ except Exception:
41
+ pass
42
+ try:
43
+ from . import traces as _traces
44
+ _traces.register(cli)
45
+ except Exception:
46
+ pass
47
+ try:
48
+ from . import recent as _recent
49
+ _recent.register(cli)
50
+ except Exception:
51
+ pass
52
+ try:
53
+ from . import calc as _calc
54
+ _calc.register(cli)
55
+ except Exception:
56
+ pass
57
+ try:
58
+ from . import status as _status
59
+ _status.register(cli)
60
+ except Exception:
61
+ pass
62
+ try:
63
+ from . import demo as _demo
64
+ _demo.register(cli)
65
+ except Exception:
66
+ pass
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI: check remaining credit balance from Synth backend.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import click
10
+ import requests
11
+ from requests import Response
12
+ from urllib.parse import urlparse
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+ from rich import box
17
+
18
+
19
+ PROD_BACKEND_BASE = "https://agent-learning.onrender.com/api/v1"
20
+
21
+
22
+ def _get_default_base_url() -> str:
23
+ # Prefer explicit backend variables that are NOT modal; else default to prod backend
24
+ for var in ("SYNTH_BACKEND_BASE_URL", "BACKEND_BASE_URL", "SYNTH_BASE_URL"):
25
+ val = os.getenv(var)
26
+ if val and ("modal" not in val.lower() and "modal.run" not in val.lower()):
27
+ return val
28
+ return PROD_BACKEND_BASE
29
+
30
+
31
+ def _ensure_api_v1_prefix(base_url: str) -> str:
32
+ """Ensure the base URL includes the /api/v1 prefix.
33
+
34
+ Accepts either a full prefix (http://host:port/api/v1) or a root
35
+ service URL (http://host:port). If no '/api' segment is present, append
36
+ '/api/v1'.
37
+ """
38
+ b = base_url.rstrip("/")
39
+ if b.endswith("/api") or b.endswith("/api/v1") or "/api/" in b:
40
+ return b
41
+ return b + "/api/v1"
42
+
43
+
44
+ def _resolve_api_key(explicit_key: str | None) -> tuple[str | None, str | None]:
45
+ if explicit_key:
46
+ return explicit_key, "--api-key"
47
+ # Try multiple env vars commonly used in this repo
48
+ for var in ("SYNTH_BACKEND_API_KEY", "SYNTH_API_KEY", "DEFAULT_DEV_API_KEY"):
49
+ val = os.getenv(var)
50
+ if val:
51
+ return val, var
52
+ return None, None
53
+
54
+
55
+ def _auth_headers(api_key: str | None) -> dict[str, str]:
56
+ key, _ = _resolve_api_key(api_key)
57
+ if not key:
58
+ return {}
59
+ return {"Authorization": f"Bearer {key}"}
60
+
61
+
62
+ def register(cli):
63
+ @cli.command()
64
+ @click.option(
65
+ "--base-url",
66
+ default=_get_default_base_url,
67
+ show_default=True,
68
+ help="Synth backend base URL (prefix like http://host:port/api/v1)",
69
+ )
70
+ @click.option(
71
+ "--api-key",
72
+ envvar="SYNTH_API_KEY",
73
+ help="API key for the Synth backend (or set SYNTH_API_KEY)",
74
+ )
75
+ @click.option(
76
+ "--usage/--no-usage",
77
+ default=False,
78
+ help="Also fetch recent usage summary",
79
+ )
80
+ def balance(base_url: str, api_key: str | None, usage: bool):
81
+ """Show your remaining credit balance from the Synth backend."""
82
+ console = Console()
83
+
84
+ key_val, key_src = _resolve_api_key(api_key)
85
+ if not key_val:
86
+ console.print(
87
+ "[red]Missing API key.[/red] Set via --api-key or SYNTH_API_KEY env var."
88
+ )
89
+ return
90
+
91
+ base = _ensure_api_v1_prefix(base_url)
92
+
93
+ # Hard guard: never hit Modal URLs for account balance
94
+ try:
95
+ parsed = urlparse(base)
96
+ host = (parsed.hostname or "").lower()
97
+ except Exception:
98
+ host = ""
99
+ if "modal" in host or "modal.run" in base.lower():
100
+ # Override to prod backend unconditionally
101
+ fallback = PROD_BACKEND_BASE
102
+ console.print(
103
+ f"[yellow]Detected remote Modal URL ({base}). Using backend instead:[/yellow] {fallback}"
104
+ )
105
+ base = fallback
106
+
107
+ try:
108
+ resp: Response = requests.get(
109
+ f"{base}/balance/current",
110
+ headers=_auth_headers(api_key),
111
+ timeout=10,
112
+ )
113
+ resp.raise_for_status()
114
+ data = resp.json()
115
+ cents = int(data.get("balance_cents", 0))
116
+ dollars = float(data.get("balance_dollars", cents / 100.0))
117
+ console.print(f"Balance: [bold]${dollars:,.2f}[/bold]")
118
+
119
+ # Try to print compact spend summary for 24h and 7d
120
+ try:
121
+ u: Response = requests.get(
122
+ f"{base}/balance/usage/windows",
123
+ params={"hours": "24,168"},
124
+ headers=_auth_headers(api_key),
125
+ timeout=10,
126
+ )
127
+ if u.ok:
128
+ uj = u.json()
129
+ rows = uj.get("windows", [])
130
+ windows = {int(r.get("window_hours")): r for r in rows if isinstance(r.get("window_hours"), int)}
131
+ def _usd(c):
132
+ try:
133
+ return f"${(int(c)/100):,.2f}"
134
+ except Exception:
135
+ return "$0.00"
136
+ if 24 in windows or 168 in windows:
137
+ t = Table(title="Spend (Tokens vs GPU)", box=box.SIMPLE, header_style="bold")
138
+ t.add_column("Window")
139
+ t.add_column("Tokens", justify="right")
140
+ t.add_column("GPU", justify="right")
141
+ t.add_column("Total", justify="right")
142
+ for h,label in ((24,"24h"),(168,"7d")):
143
+ if h in windows:
144
+ w = windows[h]
145
+ t.add_row(
146
+ label,
147
+ _usd(w.get("token_spend_cents", 0)),
148
+ _usd(w.get("gpu_spend_cents", 0)),
149
+ _usd(w.get("total_spend_cents", 0)),
150
+ )
151
+ console.print(t)
152
+ elif usage:
153
+ # Fallback to older summary if requested explicitly
154
+ u2: Response = requests.get(
155
+ f"{base}/balance/usage",
156
+ headers=_auth_headers(api_key),
157
+ timeout=10,
158
+ )
159
+ if u2.ok:
160
+ uj = u2.json()
161
+ cm = uj.get("current_month", {})
162
+ l30 = uj.get("last_30_days", {})
163
+ t = Table(title="Usage Summary", box=box.SIMPLE, header_style="bold")
164
+ t.add_column("Window")
165
+ t.add_column("Token Spend", justify="right")
166
+ t.add_column("GPU Spend", justify="right")
167
+ t.add_column("Total", justify="right")
168
+ t.add_row(
169
+ "Current Month",
170
+ f"${(cm.get('token_spend_cents',0)/100):,.2f}",
171
+ f"${(cm.get('gpu_spend_cents',0)/100):,.2f}",
172
+ f"${(cm.get('total_spend_cents',0)/100):,.2f}",
173
+ )
174
+ t.add_row(
175
+ "Last 30 days",
176
+ f"${(l30.get('token_spend_cents',0)/100):,.2f}",
177
+ f"${(l30.get('gpu_spend_cents',0)/100):,.2f}",
178
+ f"${(l30.get('total_spend_cents',0)/100):,.2f}",
179
+ )
180
+ console.print(t)
181
+ except Exception:
182
+ # Silent failure on usage summary
183
+ pass
184
+
185
+ except requests.HTTPError as e:
186
+ try:
187
+ detail = e.response.json().get("detail") if e.response else None
188
+ except Exception:
189
+ detail = None
190
+ if e.response is not None and e.response.status_code == 401:
191
+ key_dbg, key_src = _resolve_api_key(api_key)
192
+ shown = (key_dbg[:6] + "…" + key_dbg[-4:]) if key_dbg else "<none>"
193
+ console.print(
194
+ "[red]Unauthorized (401).[/red] The API key was not accepted by the backend."
195
+ )
196
+ console.print(
197
+ f"- Using base URL: {base}\n- API key (masked): {shown}\n- Key source: {key_src or '<none>'}\n- Ensure this key exists in the backend DB (table api_keys) and is active."
198
+ )
199
+ console.print(
200
+ "If running locally, you can seed a dev key by setting ENVIRONMENT=dev and ensuring the DB has no API keys (auto-seed path), or create one via your admin path."
201
+ )
202
+ else:
203
+ console.print(f"[red]HTTP error:[/red] {e} {detail or ''}")
204
+ except Exception as e:
205
+ console.print(f"[red]Error:[/red] {e}")
synth_ai/cli/calc.py ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI: basic calculator for quick math in terminal.
4
+ Safe evaluation of arithmetic expressions.
5
+ """
6
+
7
+ import ast
8
+ import operator as op
9
+ import click
10
+ from rich.console import Console
11
+
12
+
13
+ # Supported operators
14
+ _OPS = {
15
+ ast.Add: op.add,
16
+ ast.Sub: op.sub,
17
+ ast.Mult: op.mul,
18
+ ast.Div: op.truediv,
19
+ ast.FloorDiv: op.floordiv,
20
+ ast.Mod: op.mod,
21
+ ast.Pow: op.pow,
22
+ ast.USub: op.neg,
23
+ ast.UAdd: op.pos,
24
+ }
25
+
26
+
27
+ def _safe_eval(expr: str) -> float:
28
+ node = ast.parse(expr, mode="eval")
29
+
30
+ def _eval(n):
31
+ if isinstance(n, ast.Expression):
32
+ return _eval(n.body)
33
+ if isinstance(n, ast.Num): # 3.8 and earlier
34
+ return n.n
35
+ if isinstance(n, ast.Constant): # 3.8+
36
+ if isinstance(n.value, (int, float)):
37
+ return n.value
38
+ raise ValueError("Only numeric constants are allowed")
39
+ if isinstance(n, ast.BinOp) and type(n.op) in _OPS:
40
+ return _OPS[type(n.op)](_eval(n.left), _eval(n.right))
41
+ if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS:
42
+ return _OPS[type(n.op)](_eval(n.operand))
43
+ if isinstance(n, ast.Expr):
44
+ return _eval(n.value)
45
+ raise ValueError("Unsupported expression")
46
+
47
+ return _eval(node)
48
+
49
+
50
+ def register(cli):
51
+ @cli.command(name="calc")
52
+ @click.argument("expr", nargs=-1)
53
+ def calc(expr: tuple[str, ...]):
54
+ """Evaluate a basic math expression, e.g., "(12_345 + 6789) / 100".
55
+
56
+ Supports + - * / // % ** and parentheses. No variables or functions.
57
+ """
58
+ console = Console()
59
+ expression = " ".join(expr).strip()
60
+ if not expression:
61
+ console.print("[dim]Usage:[/dim] synth-ai calc '2*(3+4)' or: uvx . calc 2 + 2")
62
+ return
63
+ try:
64
+ # Allow underscores in numbers for readability
65
+ expression = expression.replace("_", "")
66
+ result = _safe_eval(expression)
67
+ console.print(f"= [bold]{result}[/bold]")
68
+ except Exception as e:
69
+ console.print(f"[red]Error:[/red] {e}")
70
+
synth_ai/cli/demo.py ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI: interactive launcher for example demos.
4
+
5
+ Finds all `run_demo.sh` scripts under `examples/` and lets the user pick one
6
+ to run. Intended to be used as: `uvx synth-ai demo` or `synth-ai demo`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import List
15
+
16
+ import click
17
+
18
+
19
+ def _find_demo_scripts(root: Path) -> List[Path]:
20
+ if not root.exists():
21
+ return []
22
+ return sorted([p for p in root.rglob("run_demo.sh") if p.is_file()])
23
+
24
+
25
+ def register(cli):
26
+ @cli.command()
27
+ @click.option("--list", "list_only", is_flag=True, help="List available demos and exit")
28
+ @click.option("-f", "filter_term", default="", help="Filter demos by substring")
29
+ def demo(list_only: bool, filter_term: str):
30
+ """Launch an interactive demo from examples/"""
31
+ repo_root = Path(os.getcwd())
32
+ examples_dir = repo_root / "examples"
33
+ demos = _find_demo_scripts(examples_dir)
34
+ if filter_term:
35
+ demos = [p for p in demos if filter_term.lower() in str(p).lower()]
36
+
37
+ if not demos:
38
+ click.echo("No run_demo.sh scripts found under examples/.")
39
+ return
40
+
41
+ if list_only:
42
+ click.echo("Available demos:")
43
+ for p in demos:
44
+ click.echo(f" - {p.relative_to(repo_root)}")
45
+ return
46
+
47
+ click.echo("Available demos:")
48
+ for idx, p in enumerate(demos, start=1):
49
+ click.echo(f" {idx}. {p.relative_to(repo_root)}")
50
+ click.echo("")
51
+
52
+ def _validate_choice(val: str) -> int:
53
+ try:
54
+ i = int(val)
55
+ except Exception:
56
+ raise click.BadParameter("Enter a number from the list")
57
+ if i < 1 or i > len(demos):
58
+ raise click.BadParameter(f"Choose a number between 1 and {len(demos)}")
59
+ return i
60
+
61
+ choice = click.prompt("Select a demo to run", value_proc=_validate_choice)
62
+ script = demos[choice - 1]
63
+
64
+ click.echo("")
65
+ click.echo(f"🚀 Running {script.relative_to(repo_root)}\n")
66
+
67
+ # Run via bash to avoid relying on executable bit; inherit environment
68
+ try:
69
+ subprocess.run(["bash", str(script)], check=True)
70
+ except subprocess.CalledProcessError as e:
71
+ click.echo(f"❌ Demo exited with non-zero status: {e.returncode}")
72
+ except KeyboardInterrupt:
73
+ click.echo("\n🛑 Demo interrupted by user")
74
+
@@ -9,7 +9,6 @@ import subprocess
9
9
  import signal
10
10
  import time
11
11
  import shutil
12
- import asyncio
13
12
  from pathlib import Path
14
13
  from typing import Optional
15
14
  import logging
@@ -228,13 +227,33 @@ def unregister_env(name: str, service_url: str):
228
227
  click.echo(f"❌ Error: {e}")
229
228
 
230
229
 
230
+ @cli.command()
231
+ @click.option("--url", default="sqlite+aiosqlite:///./synth_ai.db/dbs/default/data", help="Database URL")
232
+ def view(url: str):
233
+ """Launch the interactive TUI dashboard."""
234
+ try:
235
+ from .tui.dashboard import SynthDashboard
236
+ app = SynthDashboard(db_url=url)
237
+ app.run()
238
+ except ImportError:
239
+ click.echo("❌ Textual not installed. Install with: pip install textual", err=True)
240
+ sys.exit(1)
241
+ except KeyboardInterrupt:
242
+ click.echo("\n👋 Dashboard closed", err=True)
243
+
244
+ # Note: subcommands (watch, experiments, experiment, usage, traces, status, recent, calc)
245
+ # are registered from the package module synth_ai.cli at import time.
246
+
247
+
231
248
  @cli.command()
232
249
  @click.option("--db-file", default="synth_ai.db", help="Database file path")
233
250
  @click.option("--sqld-port", default=8080, type=int, help="Port for sqld HTTP interface")
234
251
  @click.option("--env-port", default=8901, type=int, help="Port for environment service")
235
252
  @click.option("--no-sqld", is_flag=True, help="Skip starting sqld daemon")
236
253
  @click.option("--no-env", is_flag=True, help="Skip starting environment service")
237
- def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bool):
254
+ @click.option("--reload/--no-reload", default=False, help="Enable auto-reload (default: off). Or set SYNTH_RELOAD=1")
255
+ @click.option("--force", is_flag=True, help="Kill any process already bound to --env-port without prompting")
256
+ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bool, reload: bool, force: bool):
238
257
  """Start Synth AI services (sqld daemon and environment service)."""
239
258
 
240
259
  # Configure logging
@@ -330,18 +349,38 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
330
349
  # Running from source
331
350
  env_module = "synth_ai.environments.service.app:app"
332
351
 
333
- # Check if port is already in use
352
+ # Ensure env_port is free; offer to kill existing listeners
334
353
  try:
335
354
  import socket
336
355
 
337
356
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
338
- result = s.connect_ex(("127.0.0.1", env_port))
339
- if result == 0:
340
- click.echo(f"⚠️ Port {env_port} is already in use!")
341
- click.echo(" Another instance of the environment service may be running.")
342
- click.echo("")
343
- except:
344
- pass
357
+ in_use = s.connect_ex(("127.0.0.1", env_port)) == 0
358
+ except Exception:
359
+ in_use = False
360
+
361
+ if in_use:
362
+ # Try to find PIDs using lsof (macOS/Linux)
363
+ pids: list[str] = []
364
+ try:
365
+ out = subprocess.run(["lsof", "-ti", f":{env_port}"], capture_output=True, text=True)
366
+ if out.returncode == 0 and out.stdout.strip():
367
+ pids = [p for p in out.stdout.strip().splitlines() if p]
368
+ except FileNotFoundError:
369
+ pids = []
370
+
371
+ if force:
372
+ if pids:
373
+ subprocess.run(["kill", "-9", *pids], check=False)
374
+ time.sleep(0.5)
375
+ else:
376
+ pid_info = f" PIDs: {', '.join(pids)}" if pids else ""
377
+ if click.confirm(f"⚠️ Port {env_port} is in use.{pid_info} Kill and continue?", default=True):
378
+ if pids:
379
+ subprocess.run(["kill", "-9", *pids], check=False)
380
+ time.sleep(0.5)
381
+ else:
382
+ click.echo("❌ Aborting. Re-run with --force to auto-kill or choose a different --env-port.")
383
+ sys.exit(1)
345
384
 
346
385
  # Set environment variables
347
386
  env = os.environ.copy()
@@ -358,7 +397,12 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
358
397
  click.echo("💡 Tips:")
359
398
  click.echo(" - Check sqld.log if database issues occur")
360
399
  click.echo(" - Use Ctrl+C to stop all services")
361
- click.echo(" - Service will auto-reload on code changes")
400
+ # Determine reload behavior: CLI flag overrides env var, default is off
401
+ reload_enabled = reload or (os.getenv("SYNTH_RELOAD", "0") == "1")
402
+ if reload_enabled:
403
+ click.echo(" - Auto-reload ENABLED (code changes restart service)")
404
+ else:
405
+ click.echo(" - Auto-reload DISABLED (stable in-memory sessions)")
362
406
  click.echo("")
363
407
 
364
408
  # Start uvicorn
@@ -373,12 +417,13 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
373
417
  str(env_port),
374
418
  "--log-level",
375
419
  "info",
376
- "--reload",
377
420
  ]
378
421
 
379
- # If running from source, add reload directory
380
- if os.path.exists("synth_ai"):
381
- uvicorn_cmd.extend(["--reload-dir", "synth_ai"])
422
+ if reload_enabled:
423
+ uvicorn_cmd.append("--reload")
424
+ # If running from source, add reload directory
425
+ if os.path.exists("synth_ai"):
426
+ uvicorn_cmd.extend(["--reload-dir", "synth_ai"])
382
427
 
383
428
  proc = subprocess.Popen(uvicorn_cmd, env=env)
384
429
  processes.append(proc)
synth_ai/cli/man.py ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI: human-friendly manual for Synth AI commands and options.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+ from rich import box
13
+
14
+
15
+ def _commands_table() -> Table:
16
+ t = Table(title="Commands", box=box.SIMPLE, header_style="bold")
17
+ t.add_column("Command")
18
+ t.add_column("Summary")
19
+ t.add_row(
20
+ "balance",
21
+ "Show remaining credit balance (USD) and a compact spend summary for last 24h and 7d.\n"
22
+ "Options: --base-url, --api-key, --usage",
23
+ )
24
+ t.add_row(
25
+ "traces",
26
+ "List local trace DBs, trace counts, experiments, and per-system counts.\n"
27
+ "Options: --root",
28
+ )
29
+ t.add_row(
30
+ "experiments",
31
+ "Snapshot table of experiments from the local traces DB.\n"
32
+ "Options: --url, --limit",
33
+ )
34
+ t.add_row(
35
+ "experiment <id>",
36
+ "Details and sessions for an experiment (accepts partial ID).\n"
37
+ "Options: --url",
38
+ )
39
+ t.add_row(
40
+ "usage",
41
+ "Model usage statistics (tokens, cost).\n"
42
+ "Options: --url, --model",
43
+ )
44
+ t.add_row(
45
+ "status",
46
+ "DB stats, systems, and environment service health.\n"
47
+ "Options: --url, --service-url",
48
+ )
49
+ t.add_row(
50
+ "calc '<expr>'",
51
+ "Evaluate a simple arithmetic expression (e.g., 2*(3+4)).",
52
+ )
53
+ t.add_row(
54
+ "env list | env register | env unregister",
55
+ "Manage environment registry via the service.\n"
56
+ "Options vary; see examples.",
57
+ )
58
+ return t
59
+
60
+
61
+ def _env_table() -> Table:
62
+ t = Table(title="Environment Variables", box=box.SIMPLE, header_style="bold")
63
+ t.add_column("Variable")
64
+ t.add_column("Used By")
65
+ t.add_column("Purpose")
66
+ t.add_row("SYNTH_BACKEND_BASE_URL", "balance", "Backend base URL (preferred) e.g. http://localhost:8000/api/v1")
67
+ t.add_row("BACKEND_BASE_URL", "balance", "Fallback backend base URL")
68
+ t.add_row("LOCAL_BACKEND_URL", "balance", "Another fallback backend base URL")
69
+ t.add_row("SYNTH_BASE_URL", "balance", "Generic base URL (may point to Modal, guarded)")
70
+ t.add_row("SYNTH_BACKEND_API_KEY", "balance", "Backend API key (preferred)")
71
+ t.add_row("SYNTH_API_KEY", "balance, env*", "API key used if backend-specific key not set")
72
+ t.add_row("DEFAULT_DEV_API_KEY", "balance", "Dev fallback key for local testing")
73
+ t.add_row("SYNTH_TRACES_ROOT", "traces", "Root directory of local trace DBs (default ./synth_ai.db/dbs)")
74
+ return t
75
+
76
+
77
+ def _examples_table() -> Table:
78
+ t = Table(title="Examples", box=box.SIMPLE, header_style="bold")
79
+ t.add_column("Command")
80
+ t.add_column("Example")
81
+ t.add_row("Balance (local backend)", "uvx . balance")
82
+ t.add_row("Balance with URL+key", "uvx . balance --base-url http://localhost:8000 --api-key $SYNTH_API_KEY")
83
+ t.add_row("Traces (default root)", "uvx . traces")
84
+ t.add_row("Traces (custom root)", "uvx . traces --root /path/to/dbs")
85
+ t.add_row("Experiments", "uvx . experiments --limit 20")
86
+ t.add_row("Experiment detail", "uvx . experiment abcd1234")
87
+ t.add_row("Usage by model", "uvx . usage --model gpt-4o-mini")
88
+ t.add_row("Status", "uvx . status")
89
+ t.add_row("Calc", "uvx . calc '2*(3+4)'")
90
+ t.add_row("Env list", "uvx . env list --service-url http://localhost:8901")
91
+ return t
92
+
93
+
94
+ def register(cli):
95
+ @cli.command(name="man")
96
+ def man():
97
+ """Show Synth AI CLI manual with commands, options, env vars, and examples."""
98
+ console = Console()
99
+ console.print(Panel("Synth AI CLI Manual", border_style="cyan"))
100
+ console.print(_commands_table())
101
+ console.print(_env_table())
102
+ console.print(_examples_table())
103
+