nabla-cli 0.3.0__tar.gz → 0.4.1__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.
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/PKG-INFO +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/__init__.py +1 -1
- nabla_cli-0.4.1/nabla/branding.py +74 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/cli.py +120 -42
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/recorder.py +18 -6
- nabla_cli-0.4.1/nabla/ui.py +171 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/SOURCES.txt +1 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/pyproject.toml +1 -1
- nabla_cli-0.3.0/nabla/branding.py +0 -50
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/README.md +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/__main__.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/config.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/contract.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/induce.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/profile.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/samplegen.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/scanner.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla/term.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/setup.cfg +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_cli_ux.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_config.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_induce.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_recorder.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_scanner.py +0 -0
- {nabla_cli-0.3.0 → nabla_cli-0.4.1}/tests/test_schema_resolver.py +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""nabla terminal branding: the ∇ logo and startup banner.
|
|
2
|
+
|
|
3
|
+
The banner prints only on a real terminal — piped output, tests, and
|
|
4
|
+
--json consumers never see it. Block glyphs render via the Windows
|
|
5
|
+
console's UTF-16 writer, so no legacy-codepage fallback is needed on an
|
|
6
|
+
interactive console.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
# Uniform thick strokes like the wordmark: solid bar, solid diagonals,
|
|
17
|
+
# single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
|
|
18
|
+
# reads close to the mark's near-equilateral proportions.
|
|
19
|
+
_LOGO = [
|
|
20
|
+
"█▀▀▀▀▀▀▀▀▀█",
|
|
21
|
+
" █ █",
|
|
22
|
+
" █ █",
|
|
23
|
+
" █ █",
|
|
24
|
+
" █ █",
|
|
25
|
+
" █",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_BRAND_STYLE = "bold cyan"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def banner_lines() -> list[tuple[str, str, str]]:
|
|
32
|
+
"""(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
|
|
33
|
+
taglines = [
|
|
34
|
+
("", ""),
|
|
35
|
+
("nabla", "bold"),
|
|
36
|
+
(f"v{__version__} · OpenAI call-site harvester", ""),
|
|
37
|
+
("scan · record · profile", "cyan"),
|
|
38
|
+
("", ""),
|
|
39
|
+
("", ""),
|
|
40
|
+
]
|
|
41
|
+
return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _compose(reveal: int | None = None, with_taglines: bool = True) -> Text:
|
|
45
|
+
frame = Text()
|
|
46
|
+
for logo_line, tagline, tag_style in banner_lines():
|
|
47
|
+
segment = logo_line if reveal is None else logo_line[:reveal]
|
|
48
|
+
frame.append(f" {segment:<14}", style=_BRAND_STYLE)
|
|
49
|
+
if with_taglines and tagline:
|
|
50
|
+
frame.append(tagline, style=tag_style)
|
|
51
|
+
frame.append("\n")
|
|
52
|
+
return frame
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def print_banner(console: Console | None = None, animate: bool = False) -> None:
|
|
56
|
+
console = console or Console()
|
|
57
|
+
if not console.is_terminal:
|
|
58
|
+
return
|
|
59
|
+
console.print()
|
|
60
|
+
if animate:
|
|
61
|
+
import time
|
|
62
|
+
|
|
63
|
+
from rich.live import Live
|
|
64
|
+
|
|
65
|
+
width = max(len(line) for line, _, _ in banner_lines())
|
|
66
|
+
with Live(console=console, refresh_per_second=60, transient=False) as live:
|
|
67
|
+
# The mark draws itself in, left to right; the words arrive last.
|
|
68
|
+
for cut in range(width + 1):
|
|
69
|
+
live.update(_compose(reveal=cut, with_taglines=False))
|
|
70
|
+
time.sleep(0.028)
|
|
71
|
+
live.update(_compose())
|
|
72
|
+
else:
|
|
73
|
+
console.print(_compose(), end="")
|
|
74
|
+
console.print()
|
|
@@ -191,15 +191,41 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
191
191
|
return commands[args.command](args)
|
|
192
192
|
|
|
193
193
|
|
|
194
|
+
def _clipboard_text() -> str:
|
|
195
|
+
"""Windows clipboard contents (CF_UNICODETEXT), or "" on any failure."""
|
|
196
|
+
try:
|
|
197
|
+
import ctypes
|
|
198
|
+
|
|
199
|
+
CF_UNICODETEXT = 13
|
|
200
|
+
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
|
|
201
|
+
if not user32.OpenClipboard(0):
|
|
202
|
+
return ""
|
|
203
|
+
try:
|
|
204
|
+
handle = user32.GetClipboardData(CF_UNICODETEXT)
|
|
205
|
+
if not handle:
|
|
206
|
+
return ""
|
|
207
|
+
locked = kernel32.GlobalLock(handle)
|
|
208
|
+
if not locked:
|
|
209
|
+
return ""
|
|
210
|
+
try:
|
|
211
|
+
return ctypes.c_wchar_p(locked).value or ""
|
|
212
|
+
finally:
|
|
213
|
+
kernel32.GlobalUnlock(handle)
|
|
214
|
+
finally:
|
|
215
|
+
user32.CloseClipboard()
|
|
216
|
+
except Exception:
|
|
217
|
+
return ""
|
|
218
|
+
|
|
219
|
+
|
|
194
220
|
def _read_api_key(prompt: str) -> str:
|
|
195
221
|
"""Read a secret with visible masking: one `*` per character, backspace
|
|
196
|
-
supported,
|
|
197
|
-
|
|
198
|
-
fully hidden input where character
|
|
222
|
+
supported, and Ctrl+V pastes from the clipboard (classic consoles don't
|
|
223
|
+
translate Ctrl+V into keystrokes, which silently swallowed pastes).
|
|
224
|
+
Falls back to fully hidden input where character reading isn't available."""
|
|
199
225
|
if sys.platform == "win32" and sys.stdin.isatty():
|
|
200
226
|
import msvcrt
|
|
201
227
|
|
|
202
|
-
print(prompt, end="", flush=True)
|
|
228
|
+
print(f"{prompt}(paste with Ctrl+V or right-click) ", end="", flush=True)
|
|
203
229
|
chars: list[str] = []
|
|
204
230
|
while True:
|
|
205
231
|
ch = msvcrt.getwch()
|
|
@@ -208,7 +234,13 @@ def _read_api_key(prompt: str) -> str:
|
|
|
208
234
|
break
|
|
209
235
|
if ch == "\x03": # Ctrl+C
|
|
210
236
|
raise KeyboardInterrupt
|
|
211
|
-
if ch == "\
|
|
237
|
+
if ch == "\x16": # Ctrl+V — classic console gives us the raw control char
|
|
238
|
+
pasted = _clipboard_text().strip()
|
|
239
|
+
for c in pasted:
|
|
240
|
+
if c >= " ":
|
|
241
|
+
chars.append(c)
|
|
242
|
+
print("*" * len(pasted), end="", flush=True)
|
|
243
|
+
elif ch == "\b":
|
|
212
244
|
if chars:
|
|
213
245
|
chars.pop()
|
|
214
246
|
print("\b \b", end="", flush=True)
|
|
@@ -220,24 +252,24 @@ def _read_api_key(prompt: str) -> str:
|
|
|
220
252
|
|
|
221
253
|
|
|
222
254
|
def _cmd_init(args) -> int:
|
|
223
|
-
|
|
255
|
+
from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
|
|
256
|
+
|
|
257
|
+
print_banner(_out, animate=True)
|
|
258
|
+
rail_start(_out, "nabla init")
|
|
259
|
+
rail(_out)
|
|
260
|
+
|
|
224
261
|
provider = args.provider
|
|
225
262
|
if provider is None:
|
|
226
263
|
names = list(PROVIDERS)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
choice = input("> ").strip()
|
|
232
|
-
if choice.isdigit() and 1 <= int(choice) <= len(names):
|
|
233
|
-
provider = names[int(choice) - 1]
|
|
234
|
-
else:
|
|
235
|
-
provider = choice
|
|
264
|
+
options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
|
|
265
|
+
provider = names[select(_out, "Provider", options)]
|
|
266
|
+
else:
|
|
267
|
+
rail(_out, f"Provider {provider}")
|
|
236
268
|
|
|
237
269
|
api_key = args.api_key
|
|
238
270
|
if api_key is None:
|
|
239
271
|
key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
|
|
240
|
-
api_key = _read_api_key(f"
|
|
272
|
+
api_key = _read_api_key(f"{RAIL} {key_env} ")
|
|
241
273
|
|
|
242
274
|
if not api_key:
|
|
243
275
|
_fail("nabla init: no API key given")
|
|
@@ -250,12 +282,14 @@ def _cmd_init(args) -> int:
|
|
|
250
282
|
return 2
|
|
251
283
|
|
|
252
284
|
masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
|
|
253
|
-
|
|
285
|
+
rail(_out)
|
|
286
|
+
rail_ok(_out, f"saved to {path} (key: {masked})")
|
|
254
287
|
try:
|
|
255
288
|
_ensure_scripts_on_path()
|
|
256
289
|
except Exception:
|
|
257
290
|
pass # PATH help is best-effort; setup itself already succeeded
|
|
258
|
-
|
|
291
|
+
rail(_out)
|
|
292
|
+
rail_end(_out, "next: nabla scan — run it from inside the repo you want to harvest")
|
|
259
293
|
return 0
|
|
260
294
|
|
|
261
295
|
|
|
@@ -330,7 +364,9 @@ def _cmd_scan(args) -> int:
|
|
|
330
364
|
for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
|
|
331
365
|
table.add_column(col, overflow="fold")
|
|
332
366
|
supported_count = 0
|
|
333
|
-
|
|
367
|
+
# Harvestable sites first — the reader's question is "what can I use?"
|
|
368
|
+
keyed = sorted(rows, key=lambda r: not is_supported(Verifiability(r["verifiability"]), r["flags"]))
|
|
369
|
+
for r in keyed:
|
|
334
370
|
verif = r["verifiability"]
|
|
335
371
|
harvestable = is_supported(Verifiability(verif), r["flags"])
|
|
336
372
|
if harvestable:
|
|
@@ -342,12 +378,13 @@ def _cmd_scan(args) -> int:
|
|
|
342
378
|
else:
|
|
343
379
|
verif_color = "dim"
|
|
344
380
|
table.add_row(
|
|
345
|
-
Text(r["symbol"], style="cyan"),
|
|
381
|
+
Text(r["symbol"], style="cyan" if harvestable else "dim"),
|
|
346
382
|
r["file"],
|
|
347
383
|
r["kind"],
|
|
348
384
|
Text(verif, style=verif_color),
|
|
349
385
|
Text("yes", style="green") if harvestable else Text("no", style="dim"),
|
|
350
386
|
",".join(r["flags"]) or "-",
|
|
387
|
+
style=None if harvestable else "dim",
|
|
351
388
|
)
|
|
352
389
|
_out.print(table)
|
|
353
390
|
noun = "call site" if len(rows) == 1 else "call sites"
|
|
@@ -389,8 +426,8 @@ def _resolve_site(args, sites):
|
|
|
389
426
|
|
|
390
427
|
if len(supported) == 1:
|
|
391
428
|
site = supported[0]
|
|
392
|
-
|
|
393
|
-
|
|
429
|
+
from .ui import rail
|
|
430
|
+
rail(_out, f"using site {site.symbol} (the only supported call site)")
|
|
394
431
|
return site, None
|
|
395
432
|
|
|
396
433
|
candidates = supported or sites
|
|
@@ -410,10 +447,15 @@ def _resolve_site(args, sites):
|
|
|
410
447
|
|
|
411
448
|
|
|
412
449
|
def _cmd_record(args) -> int:
|
|
450
|
+
from .ui import make_progress, rail, rail_end, rail_ok, rail_start
|
|
451
|
+
|
|
452
|
+
rail_start(_out, "nabla record")
|
|
413
453
|
sites = scan(args.repo)
|
|
414
454
|
site, err = _resolve_site(args, sites)
|
|
415
455
|
if err is not None:
|
|
416
456
|
return err
|
|
457
|
+
if args.site:
|
|
458
|
+
rail(_out, f"site {site.symbol} · {site.file}", style="dim")
|
|
417
459
|
|
|
418
460
|
if _unconfigured():
|
|
419
461
|
_warn(
|
|
@@ -432,7 +474,7 @@ def _cmd_record(args) -> int:
|
|
|
432
474
|
else:
|
|
433
475
|
conventional = Path(f"{site.symbol}.samples.jsonl")
|
|
434
476
|
if conventional.exists():
|
|
435
|
-
|
|
477
|
+
rail(_out, f"using {conventional} (found by convention; pass --samples to override)")
|
|
436
478
|
source = RecordSource(kind="samples", samples_file=str(conventional))
|
|
437
479
|
else:
|
|
438
480
|
source, err = _generate_samples_source(args, site, conventional)
|
|
@@ -440,24 +482,48 @@ def _cmd_record(args) -> int:
|
|
|
440
482
|
return err
|
|
441
483
|
source_kind = "generated_samples"
|
|
442
484
|
|
|
485
|
+
on_progress = None
|
|
486
|
+
progress = None
|
|
487
|
+
if _out.is_terminal and source.kind == "samples":
|
|
488
|
+
progress = make_progress(_out)
|
|
489
|
+
task_ref: list = []
|
|
490
|
+
|
|
491
|
+
def on_progress(done: int, total: int) -> None:
|
|
492
|
+
if not task_ref:
|
|
493
|
+
task_ref.append(progress.add_task("recording", total=total))
|
|
494
|
+
progress.update(task_ref[0], completed=done)
|
|
495
|
+
|
|
443
496
|
try:
|
|
444
|
-
|
|
497
|
+
if progress is not None:
|
|
498
|
+
with progress:
|
|
499
|
+
examples = record(site, source, args.repo, on_progress=on_progress)
|
|
500
|
+
else:
|
|
501
|
+
examples = record(site, source, args.repo)
|
|
445
502
|
except ZeroTrafficError as exc:
|
|
446
503
|
_fail(f"nabla record: {exc}")
|
|
447
504
|
return 1
|
|
505
|
+
except RuntimeError as exc:
|
|
506
|
+
message = str(exc)
|
|
507
|
+
_fail(f"nabla record: {message[:400]}")
|
|
508
|
+
if "invalid_api_key" in message or "Invalid API Key" in message or "401" in message:
|
|
509
|
+
_warn("the configured provider rejected your API key — rerun `nabla init` "
|
|
510
|
+
"and paste it again (the input shows one * per character, including pastes)")
|
|
511
|
+
return 1
|
|
448
512
|
out = args.out or f"{site.symbol}.recorded.jsonl"
|
|
449
513
|
with open(out, "w", encoding="utf-8") as f:
|
|
450
514
|
for ex in examples:
|
|
451
515
|
f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
|
|
452
|
-
|
|
453
|
-
|
|
516
|
+
rail(_out)
|
|
517
|
+
rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
|
|
518
|
+
f"site {site.symbol} -> {out}")
|
|
454
519
|
if source_kind == "generated_samples":
|
|
455
|
-
|
|
456
|
-
|
|
520
|
+
rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
|
|
521
|
+
"data and rerun for a higher-fidelity harvest", style="yellow")
|
|
522
|
+
rail(_out)
|
|
457
523
|
if args.out is None and args.site is None:
|
|
458
|
-
|
|
524
|
+
rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
|
|
459
525
|
else:
|
|
460
|
-
|
|
526
|
+
rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
|
|
461
527
|
return 0
|
|
462
528
|
|
|
463
529
|
|
|
@@ -482,8 +548,9 @@ def _generate_samples_source(args, site, out_path: Path):
|
|
|
482
548
|
)
|
|
483
549
|
return None, 1
|
|
484
550
|
out_path.write_text("\n".join(json.dumps(item) for item in items) + "\n", encoding="utf-8")
|
|
485
|
-
|
|
486
|
-
|
|
551
|
+
from .ui import rail
|
|
552
|
+
rail(_out, f"no samples given — generated {len(items)} synthetic inputs -> {out_path} "
|
|
553
|
+
"(edit this file and rerun to use realistic inputs)", style="dim")
|
|
487
554
|
return RecordSource(kind="samples", samples_file=str(out_path)), None
|
|
488
555
|
|
|
489
556
|
|
|
@@ -491,11 +558,15 @@ def _cmd_profile(args) -> int:
|
|
|
491
558
|
from .induce import induce
|
|
492
559
|
from .prompt_recon import reconstruct
|
|
493
560
|
from .schema_resolver import resolve_schema as _resolve
|
|
561
|
+
from .ui import rail, rail_end, rail_ok, rail_start
|
|
494
562
|
|
|
563
|
+
rail_start(_out, "nabla profile")
|
|
495
564
|
sites = scan(args.repo)
|
|
496
565
|
site, err = _resolve_site(args, sites)
|
|
497
566
|
if err is not None:
|
|
498
567
|
return err
|
|
568
|
+
if args.site:
|
|
569
|
+
rail(_out, f"site {site.symbol} · {site.file}", style="dim")
|
|
499
570
|
|
|
500
571
|
examples_path = args.examples
|
|
501
572
|
if examples_path is None:
|
|
@@ -512,7 +583,7 @@ def _cmd_profile(args) -> int:
|
|
|
512
583
|
print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
|
|
513
584
|
f"Run first: {record_cmd}", file=sys.stderr)
|
|
514
585
|
return 2
|
|
515
|
-
|
|
586
|
+
rail(_out, f"using {examples_path}")
|
|
516
587
|
|
|
517
588
|
examples, recorded_from = [], "samples"
|
|
518
589
|
for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
|
|
@@ -528,22 +599,29 @@ def _cmd_profile(args) -> int:
|
|
|
528
599
|
|
|
529
600
|
template = reconstruct(site, args.repo)
|
|
530
601
|
schema, _ = _resolve(site, args.repo)
|
|
531
|
-
|
|
602
|
+
if _out.is_terminal:
|
|
603
|
+
with _out.status(f"inferring goal from {len(examples)} examples", spinner="dots"):
|
|
604
|
+
goal = induce(template, schema, examples)
|
|
605
|
+
else:
|
|
606
|
+
goal = induce(template, schema, examples)
|
|
532
607
|
|
|
533
608
|
profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
|
|
534
609
|
out = args.out or f"{site.symbol}.profile.json"
|
|
535
610
|
Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
536
611
|
supported = profile["classification"]["supported"]
|
|
537
|
-
|
|
538
|
-
_out
|
|
539
|
-
_out
|
|
540
|
-
_out
|
|
541
|
-
_out
|
|
612
|
+
rail(_out)
|
|
613
|
+
rail_ok(_out, f"wrote {out}")
|
|
614
|
+
rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
|
|
615
|
+
rail(_out, f"supported {supported}")
|
|
616
|
+
rail(_out, f"examples {len(examples)} ({recorded_from})")
|
|
617
|
+
rail(_out, f"goal {goal.description[:90]}")
|
|
542
618
|
dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
|
|
543
|
-
_out
|
|
619
|
+
rail(_out, f"difficulty {dims}")
|
|
544
620
|
if len(examples) < 20:
|
|
545
|
-
|
|
546
|
-
|
|
621
|
+
rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
|
|
622
|
+
"handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
|
|
623
|
+
rail(_out)
|
|
624
|
+
rail_end(_out, f"done — {out} is the handoff artifact", style="green")
|
|
547
625
|
return 0
|
|
548
626
|
|
|
549
627
|
|
|
@@ -415,13 +415,18 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
|
|
|
415
415
|
timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
|
|
416
416
|
proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
|
|
417
417
|
if proc.returncode != 0:
|
|
418
|
+
# The last stderr lines carry the actual exception; the rest is
|
|
419
|
+
# traceback scaffolding that buries it.
|
|
420
|
+
lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
|
|
421
|
+
detail = " | ".join(lines[-2:]) if lines else "no output"
|
|
418
422
|
raise RuntimeError(
|
|
419
423
|
f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
|
|
420
|
-
f"(exit {proc.returncode}): {
|
|
424
|
+
f"(exit {proc.returncode}): {detail}"
|
|
421
425
|
)
|
|
422
426
|
|
|
423
427
|
|
|
424
|
-
def _record_samples(site: RawSite, source: RecordSource, repo_path: str
|
|
428
|
+
def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
|
|
429
|
+
on_progress=None) -> list[Example]:
|
|
425
430
|
if not source.samples_file:
|
|
426
431
|
raise ValueError("RecordSource.kind='samples' requires samples_file")
|
|
427
432
|
sample_lines = _read_jsonl(source.samples_file)
|
|
@@ -466,8 +471,11 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list
|
|
|
466
471
|
new_captures = list(proxy.captured[before:])
|
|
467
472
|
for capture in new_captures:
|
|
468
473
|
examples.append(_capture_to_example(capture, input_slots))
|
|
469
|
-
|
|
470
|
-
|
|
474
|
+
if on_progress is not None:
|
|
475
|
+
on_progress(i + 1, total)
|
|
476
|
+
else:
|
|
477
|
+
print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
|
|
478
|
+
file=sys.stderr)
|
|
471
479
|
finally:
|
|
472
480
|
proxy.stop()
|
|
473
481
|
|
|
@@ -544,15 +552,19 @@ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Ex
|
|
|
544
552
|
# Public interface
|
|
545
553
|
# ---------------------------------------------------------------------------
|
|
546
554
|
|
|
547
|
-
def record(site: RawSite, source: RecordSource, repo_path: str
|
|
555
|
+
def record(site: RawSite, source: RecordSource, repo_path: str,
|
|
556
|
+
on_progress=None) -> list[Example]:
|
|
548
557
|
"""Capture real (prompt, completion, tokens, latency) pairs for site.
|
|
549
558
|
|
|
550
559
|
Must: attribute captured requests back to the site (template match),
|
|
551
560
|
populate token counts and latency, raise ZeroTrafficError on an empty
|
|
552
561
|
proxy run instead of returning [].
|
|
562
|
+
|
|
563
|
+
``on_progress(done, total)`` (samples path only) replaces the default
|
|
564
|
+
per-sample stderr lines — used by the CLI to drive a progress bar.
|
|
553
565
|
"""
|
|
554
566
|
if source.kind == "samples":
|
|
555
|
-
return _record_samples(site, source, repo_path)
|
|
567
|
+
return _record_samples(site, source, repo_path, on_progress=on_progress)
|
|
556
568
|
if source.kind == "proxy":
|
|
557
569
|
return _record_proxy(site, source, repo_path)
|
|
558
570
|
if source.kind == "logs":
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""nabla's terminal design system.
|
|
2
|
+
|
|
3
|
+
One visual language across commands:
|
|
4
|
+
|
|
5
|
+
- a rail (``┌ │ └``) threads each multi-step flow so the user always sees
|
|
6
|
+
where they are in the sequence — structure as information, not decoration;
|
|
7
|
+
- selection is pointer-driven (arrow keys) on an interactive Windows
|
|
8
|
+
console, with a numbered fallback everywhere else;
|
|
9
|
+
- progress fills with the nabla glyph itself — the one flourish;
|
|
10
|
+
- color carries meaning only: cyan = brand/interactive, green = done,
|
|
11
|
+
yellow = caution, red = failure, dim = structure.
|
|
12
|
+
|
|
13
|
+
Glyphs are box-drawing / geometric only — never emoji.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.progress import Progress, ProgressColumn, TextColumn
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
RAIL = "│"
|
|
25
|
+
_POINTER = "❯"
|
|
26
|
+
_ON = "●"
|
|
27
|
+
_OFF = "○"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Rail primitives
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def rail_start(console: Console, title: str) -> None:
|
|
35
|
+
line = Text("┌ ", style="dim")
|
|
36
|
+
line.append(title, style="bold")
|
|
37
|
+
console.print(line)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rail(console: Console, text: str = "", style: str | None = None) -> None:
|
|
41
|
+
line = Text(RAIL, style="dim")
|
|
42
|
+
if text:
|
|
43
|
+
line.append(" " + text, style=style or "")
|
|
44
|
+
console.print(line)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rail_ok(console: Console, text: str) -> None:
|
|
48
|
+
line = Text(f"{RAIL} ", style="dim")
|
|
49
|
+
line.append("✓ ", style="green")
|
|
50
|
+
line.append(text)
|
|
51
|
+
console.print(line)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rail_end(console: Console, text: str, style: str = "cyan") -> None:
|
|
55
|
+
line = Text("└ ", style="dim")
|
|
56
|
+
line.append(text, style=style)
|
|
57
|
+
console.print(line)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Selection
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
65
|
+
default: int = 0) -> int:
|
|
66
|
+
"""Pick one of ``options`` ([(name, description), ...]); returns the index.
|
|
67
|
+
|
|
68
|
+
Interactive Windows console: arrow keys + enter, pointer rendering,
|
|
69
|
+
menu collapses to a single confirmation line after the choice.
|
|
70
|
+
Anywhere else: numbered prompt.
|
|
71
|
+
"""
|
|
72
|
+
interactive = (
|
|
73
|
+
sys.platform == "win32"
|
|
74
|
+
and console.is_terminal
|
|
75
|
+
and sys.stdin.isatty()
|
|
76
|
+
)
|
|
77
|
+
if not interactive:
|
|
78
|
+
rail(console, label, style="bold")
|
|
79
|
+
for i, (name, desc) in enumerate(options, start=1):
|
|
80
|
+
rail(console, f" {i}) {name} — {desc}")
|
|
81
|
+
choice = input(f"{RAIL} > ").strip()
|
|
82
|
+
if choice.isdigit() and 1 <= int(choice) <= len(options):
|
|
83
|
+
return int(choice) - 1
|
|
84
|
+
for i, (name, _) in enumerate(options):
|
|
85
|
+
if name == choice:
|
|
86
|
+
return i
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
import msvcrt
|
|
90
|
+
|
|
91
|
+
from .term import init_terminal
|
|
92
|
+
|
|
93
|
+
init_terminal()
|
|
94
|
+
idx = default
|
|
95
|
+
name_width = max(len(name) for name, _ in options)
|
|
96
|
+
hint = "↑ ↓ move · enter chooses"
|
|
97
|
+
lines_drawn = 0
|
|
98
|
+
|
|
99
|
+
def draw() -> None:
|
|
100
|
+
nonlocal lines_drawn
|
|
101
|
+
if lines_drawn:
|
|
102
|
+
sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
|
|
103
|
+
header = Text(f"{RAIL} ", style="dim")
|
|
104
|
+
header.append(label, style="bold")
|
|
105
|
+
header.append(f" {hint}", style="dim")
|
|
106
|
+
console.print(header)
|
|
107
|
+
for i, (name, desc) in enumerate(options):
|
|
108
|
+
row = Text(f"{RAIL} ", style="dim")
|
|
109
|
+
if i == idx:
|
|
110
|
+
row.append(f" {_POINTER} {_ON} ", style="cyan")
|
|
111
|
+
row.append(f"{name:<{name_width}}", style="bold cyan")
|
|
112
|
+
else:
|
|
113
|
+
# Unselected is quieter, never grey — grey reads as disabled.
|
|
114
|
+
row.append(f" {_OFF} ", style="dim")
|
|
115
|
+
row.append(f"{name:<{name_width}}")
|
|
116
|
+
row.append(f" {desc}", style="dim")
|
|
117
|
+
console.print(row)
|
|
118
|
+
lines_drawn = len(options) + 1
|
|
119
|
+
|
|
120
|
+
draw()
|
|
121
|
+
while True:
|
|
122
|
+
ch = msvcrt.getwch()
|
|
123
|
+
if ch in ("\r", "\n"):
|
|
124
|
+
break
|
|
125
|
+
if ch == "\x03":
|
|
126
|
+
raise KeyboardInterrupt
|
|
127
|
+
if ch in ("\xe0", "\x00"):
|
|
128
|
+
arrow = msvcrt.getwch()
|
|
129
|
+
if arrow == "H":
|
|
130
|
+
idx = (idx - 1) % len(options)
|
|
131
|
+
elif arrow == "P":
|
|
132
|
+
idx = (idx + 1) % len(options)
|
|
133
|
+
draw()
|
|
134
|
+
|
|
135
|
+
# Collapse the menu into one confirmation line.
|
|
136
|
+
sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
|
|
137
|
+
chosen = Text(f"{RAIL} ", style="dim")
|
|
138
|
+
chosen.append(f"{label} ", style="bold")
|
|
139
|
+
chosen.append(options[idx][0], style="cyan")
|
|
140
|
+
console.print(chosen)
|
|
141
|
+
return idx
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Progress: the ∇ bar
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
class NablaBar(ProgressColumn):
|
|
149
|
+
"""A bar that fills with the nabla glyph — brand as instrument."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, width: int = 28):
|
|
152
|
+
super().__init__()
|
|
153
|
+
self.width = width
|
|
154
|
+
|
|
155
|
+
def render(self, task) -> Text:
|
|
156
|
+
total = task.total or 1
|
|
157
|
+
filled = int(self.width * min(task.completed / total, 1.0))
|
|
158
|
+
bar = Text()
|
|
159
|
+
bar.append("∇" * filled, style="cyan")
|
|
160
|
+
bar.append("·" * (self.width - filled), style="dim")
|
|
161
|
+
return bar
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
165
|
+
return Progress(
|
|
166
|
+
TextColumn(RAIL, style="dim"),
|
|
167
|
+
NablaBar(),
|
|
168
|
+
TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
|
|
169
|
+
console=console,
|
|
170
|
+
transient=True,
|
|
171
|
+
)
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""nabla terminal branding: the ∇ logo and startup banner.
|
|
2
|
-
|
|
3
|
-
The banner prints only on a real terminal — piped output, tests, and
|
|
4
|
-
--json consumers never see it. Block glyphs render via the Windows
|
|
5
|
-
console's UTF-16 writer, so no legacy-codepage fallback is needed on an
|
|
6
|
-
interactive console.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from rich.console import Console
|
|
12
|
-
from rich.text import Text
|
|
13
|
-
|
|
14
|
-
from . import __version__
|
|
15
|
-
|
|
16
|
-
_LOGO = [
|
|
17
|
-
"▛▀▀▀▀▀▀▀▜",
|
|
18
|
-
" ▚ ▞",
|
|
19
|
-
" ▚ ▞",
|
|
20
|
-
" ▚ ▞",
|
|
21
|
-
" ▘",
|
|
22
|
-
]
|
|
23
|
-
|
|
24
|
-
_BRAND_STYLE = "bold cyan"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def banner_lines() -> list[tuple[str, str]]:
|
|
28
|
-
"""(logo_line, tagline) pairs; taglines are empty on the outer rows."""
|
|
29
|
-
taglines = [
|
|
30
|
-
"",
|
|
31
|
-
f"nabla v{__version__}",
|
|
32
|
-
"OpenAI call-site harvester",
|
|
33
|
-
"scan · record · profile",
|
|
34
|
-
"",
|
|
35
|
-
]
|
|
36
|
-
return list(zip(_LOGO, taglines))
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def print_banner(console: Console | None = None) -> None:
|
|
40
|
-
console = console or Console()
|
|
41
|
-
if not console.is_terminal:
|
|
42
|
-
return
|
|
43
|
-
console.print()
|
|
44
|
-
for logo_line, tagline in banner_lines():
|
|
45
|
-
row = Text()
|
|
46
|
-
row.append(f" {logo_line:<12}", style=_BRAND_STYLE)
|
|
47
|
-
if tagline:
|
|
48
|
-
row.append(tagline, style="bold" if tagline.startswith("nabla") else "dim")
|
|
49
|
-
console.print(row)
|
|
50
|
-
console.print()
|
|
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
|