hum-cli 0.1.0__tar.gz → 0.1.2__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.
- {hum_cli-0.1.0 → hum_cli-0.1.2}/.github/workflows/publish.yml +1 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/PKG-INFO +1 -1
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/__init__.py +1 -1
- hum_cli-0.1.2/hum/catalog.py +64 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/cli.py +19 -14
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/engine.py +55 -12
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/sync.py +35 -0
- hum_cli-0.1.2/hum/tui/cli.mjs +84428 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/package.json +1 -1
- {hum_cli-0.1.0 → hum_cli-0.1.2}/pyproject.toml +6 -2
- {hum_cli-0.1.0 → hum_cli-0.1.2}/server/app.py +31 -2
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_sme_ready.py +89 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/app.jsx +36 -3
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/cli.jsx +1 -1
- {hum_cli-0.1.0 → hum_cli-0.1.2}/.github/workflows/test.yml +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/.gitignore +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/README.md +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/adapters/__init__.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/adapters/harbor.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/auth.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/__init__.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/agents.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/autonomy.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/config.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/drivers.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/executor.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/grader.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/llm.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/loop.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/models.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/observe.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/prompt.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/session.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/store.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/tools.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/trajectory.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/workspace.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/ui.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/bin/hum.js +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/scripts/postinstall.js +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/server/__init__.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/server/modal_app.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_cli.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_runtime.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_server_and_auth.py +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/package-lock.json +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/package.json +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/devtools-stub.js +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/engine.js +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/input.jsx +0 -0
- {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/markdown.js +0 -0
|
@@ -14,6 +14,7 @@ jobs:
|
|
|
14
14
|
- run: cd tui && npm ci && npm run build
|
|
15
15
|
- uses: astral-sh/setup-uv@v5
|
|
16
16
|
- run: uv build
|
|
17
|
+
- run: unzip -l dist/*.whl | grep -q "hum/tui/cli.mjs" # the wheel must carry the terminal UI
|
|
17
18
|
- uses: actions/upload-artifact@v4
|
|
18
19
|
with: { name: dist, path: dist/ }
|
|
19
20
|
- uses: actions/upload-artifact@v4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hum-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: An agent harness built as an RL environment that humans can drive: session trees, interchangeable human/policy drivers, counterfactual shadow, earned autonomy, verifier in the loop.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What a person sees when they choose a session to pick up: when, what, how far,
|
|
2
|
+
what it cost, where. Local files first; the server's list fills in sessions from
|
|
3
|
+
other machines (pulled on demand when chosen)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from hum.runtime.store import list_sessions
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _summary(path: Path) -> dict[str, Any]:
|
|
15
|
+
steps = 0
|
|
16
|
+
cost = 0.0
|
|
17
|
+
task: dict[str, Any] = {}
|
|
18
|
+
last = 0.0
|
|
19
|
+
try:
|
|
20
|
+
with path.open(encoding="utf-8") as f:
|
|
21
|
+
for line in f:
|
|
22
|
+
try:
|
|
23
|
+
r = json.loads(line)
|
|
24
|
+
except json.JSONDecodeError:
|
|
25
|
+
continue
|
|
26
|
+
k = r.get("kind")
|
|
27
|
+
if k == "session":
|
|
28
|
+
task = r.get("task") or {}
|
|
29
|
+
elif k == "meta":
|
|
30
|
+
task.update({kk: v for kk, v in r.items() if kk != "kind"})
|
|
31
|
+
elif k == "node":
|
|
32
|
+
if r.get("branch") == "main":
|
|
33
|
+
steps += 1
|
|
34
|
+
u = (r.get("turn") or {}).get("usage") or {}
|
|
35
|
+
cost += float(u.get("cost_usd") or 0)
|
|
36
|
+
last = max(last, float(r.get("at") or 0))
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
return {"name": path.name, "instruction": (task.get("instruction") or "")[:200], "cwd": task.get("cwd"), "model": task.get("model"),
|
|
40
|
+
"steps": steps, "cost_usd": round(cost, 4), "at": last or path.stat().st_mtime, "local": True}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def catalog(remote: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
|
|
44
|
+
"""Newest first. Remote entries not present locally are included with local=False."""
|
|
45
|
+
items = {p.name: _summary(p) for p in list_sessions()}
|
|
46
|
+
for r in remote or []:
|
|
47
|
+
n = r.get("name")
|
|
48
|
+
if n and n not in items:
|
|
49
|
+
items[n] = {"name": n, "instruction": (r.get("instruction") or "")[:200], "cwd": r.get("cwd"), "model": r.get("model"),
|
|
50
|
+
"steps": None, "cost_usd": None, "at": float(r.get("created") or 0), "local": False}
|
|
51
|
+
return sorted(items.values(), key=lambda x: x["at"], reverse=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def when(at: float, now: float | None = None) -> str:
|
|
55
|
+
d = (now or time.time()) - at
|
|
56
|
+
if d < 90:
|
|
57
|
+
return "just now"
|
|
58
|
+
if d < 3600:
|
|
59
|
+
return f"{int(d // 60)}m ago"
|
|
60
|
+
if d < 86400:
|
|
61
|
+
return f"{int(d // 3600)}h ago"
|
|
62
|
+
if d < 7 * 86400:
|
|
63
|
+
return f"{int(d // 86400)}d ago"
|
|
64
|
+
return time.strftime("%b %d", time.localtime(at))
|
|
@@ -37,6 +37,7 @@ HELP = """/help this
|
|
|
37
37
|
/model [name] show or switch the model for this session
|
|
38
38
|
/tools what's mounted
|
|
39
39
|
/cost tokens and cost so far
|
|
40
|
+
/compact summarise and restart the context now
|
|
40
41
|
/quit leave (Ctrl-D works too)
|
|
41
42
|
!cmd run a command yourself
|
|
42
43
|
Ctrl-C stop the current turn"""
|
|
@@ -336,6 +337,10 @@ class Client:
|
|
|
336
337
|
self.ui.info(f"model: {self.cfg.model}" + (f" via {self.cfg.api_base}" if self.cfg.api_base else ""))
|
|
337
338
|
elif cmd == "tools":
|
|
338
339
|
self.ui.info(", ".join(sorted(self.tools.tools)))
|
|
340
|
+
elif cmd == "compact":
|
|
341
|
+
head = session.head("main")
|
|
342
|
+
cid = asyncio.get_event_loop().run_until_complete(runner._compact(head.id if head else None, runner.policy, 0)) if False else None
|
|
343
|
+
self.ui.info("use /compact from the terminal UI; the plain client compacts automatically at the threshold")
|
|
339
344
|
elif cmd == "cost":
|
|
340
345
|
u = runner.usage + getattr(getattr(self, "delegation", None), "usage", u.__class__())
|
|
341
346
|
self.ui.info(f"{u.input_tokens}+{u.output_tokens} tokens · ${u.cost_usd:.4f} · {runner.turns} turns" +
|
|
@@ -354,20 +359,20 @@ class Client:
|
|
|
354
359
|
# --- commands -------------------------------------------------------------------------------------
|
|
355
360
|
|
|
356
361
|
def cmd_sessions(ui: UI) -> None:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
ui.console.print(f"[dim]{p.name}[/] (unreadable: {e})")
|
|
362
|
+
from hum import sync
|
|
363
|
+
from hum.catalog import catalog, when
|
|
364
|
+
for it in catalog(sync.remote_list()):
|
|
365
|
+
steps = f"{it['steps']:>3} steps" if it["steps"] is not None else " (on the server)"
|
|
366
|
+
cost = f"${it['cost_usd']:.2f}" if it["cost_usd"] else ""
|
|
367
|
+
ui.console.print(f"[dim]{when(it['at']):>9}[/] {steps:<16} {cost:>6} {it['instruction'][:60]} [dim]{Path(it['cwd']).name if it.get('cwd') else ''}[/]")
|
|
364
368
|
|
|
365
369
|
|
|
366
370
|
def _find(name: str) -> Path:
|
|
367
|
-
|
|
368
|
-
|
|
371
|
+
from hum.engine import find_session
|
|
372
|
+
p = find_session(name or "last")
|
|
373
|
+
if p is None:
|
|
369
374
|
sys.exit(f"no session matching {name!r}")
|
|
370
|
-
return
|
|
375
|
+
return p
|
|
371
376
|
|
|
372
377
|
|
|
373
378
|
def cmd_show(ui: UI, name: str) -> None:
|
|
@@ -494,6 +499,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
494
499
|
cp = sub.add_parser("chat"); cp.add_argument("task", nargs="?"); cp.add_argument("-C", "--cwd", default="."); cp.add_argument("--plain", action="store_true", help="the Rich client, no Ink")
|
|
495
500
|
rp = sub.add_parser("run"); rp.add_argument("task"); rp.add_argument("-C", "--cwd", default=".")
|
|
496
501
|
rs = sub.add_parser("resume"); rs.add_argument("name", nargs="?", default="last"); rs.add_argument("--plain", action="store_true")
|
|
502
|
+
rs.add_argument("--last", dest="pick_last", action="store_true", help="skip the picker; continue the newest session")
|
|
497
503
|
lg = sub.add_parser("login"); lg.add_argument("--auth-url")
|
|
498
504
|
sub.add_parser("logout"); sub.add_parser("whoami"); sub.add_parser("sessions"); sub.add_parser("sync")
|
|
499
505
|
mp = sub.add_parser("model"); mp.add_argument("name", nargs="?")
|
|
@@ -533,13 +539,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
533
539
|
if a.cmd == "run":
|
|
534
540
|
return asyncio.run(Client(cfg, Path(a.cwd).resolve(), ui).one_shot(a.task))
|
|
535
541
|
if a.cmd == "resume":
|
|
536
|
-
s = Session.load(_find(a.name))
|
|
537
|
-
cwd = Path(s.task.get("cwd", ".")).resolve()
|
|
538
542
|
if not a.plain and sys.stdin.isatty():
|
|
539
|
-
rc = launch_tui(cfg, cwd, None, a.name)
|
|
543
|
+
rc = launch_tui(cfg, Path(a.cwd if hasattr(a, "cwd") else ".").resolve(), None, "pick" if a.name == "last" and not a.pick_last else a.name)
|
|
540
544
|
if rc >= 0:
|
|
541
545
|
return rc
|
|
542
|
-
|
|
546
|
+
s = Session.load(_find(a.name))
|
|
547
|
+
return asyncio.run(Client(cfg, Path(s.task.get("cwd", ".")).resolve(), ui).chat(None, resume=s))
|
|
543
548
|
if not a.plain and sys.stdin.isatty():
|
|
544
549
|
rc = launch_tui(cfg, Path(a.cwd).resolve(), a.task, None)
|
|
545
550
|
if rc >= 0:
|
|
@@ -8,7 +8,9 @@ other way round (the front-end was spawned by Python and told where to connect).
|
|
|
8
8
|
|
|
9
9
|
Front-end → engine (commands)
|
|
10
10
|
{"cmd": "start", "task": "…"} begin a session with this instruction
|
|
11
|
-
{"cmd": "resume", "name": "…"} re-open a saved session (name or "last")
|
|
11
|
+
{"cmd": "resume", "name": "…"} re-open a saved session (name or "last"); while idle, switches sessions
|
|
12
|
+
{"cmd": "sessions"} list sessions to pick from (local and on the server) → sessions {items}
|
|
13
|
+
{"cmd": "compact"} summarise and restart the context now (while idle)
|
|
12
14
|
{"cmd": "message", "text": "…"} steer (while busy) or a follow-up (while idle)
|
|
13
15
|
{"cmd": "shell", "command": "…"} the person runs a command themselves (!cmd)
|
|
14
16
|
{"cmd": "interrupt"} stop the current turn; what it had so far is kept
|
|
@@ -22,7 +24,7 @@ Engine → front-end (events); every event has "type"
|
|
|
22
24
|
human_edit {stat} compaction {prompt_tokens_before}
|
|
23
25
|
turn_end {turns, cost_usd, input_tokens, output_tokens, elapsed_s, stop_reason}
|
|
24
26
|
interrupted {kept} info {text} · warn {text} · error {text}
|
|
25
|
-
session {name, path} idle · busy {phase} · retry {attempt, text} · bye
|
|
27
|
+
session {name, path} idle · busy {phase} · retry {attempt, text} · bye · sessions {items}
|
|
26
28
|
"""
|
|
27
29
|
from __future__ import annotations
|
|
28
30
|
|
|
@@ -38,7 +40,7 @@ from hum.cli import Client, Uploader, make_llm
|
|
|
38
40
|
from hum.runtime import CURRENT_AGENT, HumanAction, HumanDriver, PolicyDriver, Session, StreamEvents, ToolCall, ToolRegistry, Turn
|
|
39
41
|
from hum.runtime.config import Config
|
|
40
42
|
from hum.runtime.llm import describe_partial_call, error_text
|
|
41
|
-
from hum.runtime.store import list_sessions
|
|
43
|
+
from hum.runtime.store import home, list_sessions
|
|
42
44
|
from hum.ui import UI
|
|
43
45
|
|
|
44
46
|
|
|
@@ -200,15 +202,44 @@ class Engine:
|
|
|
200
202
|
self._open(self.client._session(str(cmd.get("task", "")).strip(), "chat"))
|
|
201
203
|
self._launch(None, None)
|
|
202
204
|
elif kind == "resume":
|
|
203
|
-
if self.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
205
|
+
if self.busy:
|
|
206
|
+
self.wire.send("warn", text="finish or stop the current turn first")
|
|
207
|
+
return
|
|
208
|
+
name = str(cmd.get("name") or "last")
|
|
209
|
+
path = await asyncio.to_thread(find_session, name)
|
|
210
|
+
if path is None:
|
|
211
|
+
self.wire.send("error", text=f"no session matching {name!r}")
|
|
212
|
+
return
|
|
213
|
+
if self.session is not None:
|
|
214
|
+
if self.uploader:
|
|
215
|
+
await self.uploader.stop()
|
|
216
|
+
await self.client.close()
|
|
217
|
+
s = Session.load(path)
|
|
218
|
+
cwd = Path(s.task.get("cwd") or self.cwd).resolve()
|
|
219
|
+
if cwd != self.cwd:
|
|
220
|
+
# the session belongs to another directory: work there
|
|
221
|
+
self.cwd = cwd
|
|
222
|
+
self.client = Client(self.cfg, cwd, self.ui)
|
|
223
|
+
await self.client.mount()
|
|
224
|
+
self._open(s)
|
|
225
|
+
self.wire.send("idle")
|
|
226
|
+
elif kind == "sessions":
|
|
227
|
+
from hum import sync
|
|
228
|
+
from hum.catalog import catalog, when
|
|
229
|
+
remote = await asyncio.to_thread(sync.remote_list)
|
|
230
|
+
items = catalog(remote)
|
|
231
|
+
for it in items:
|
|
232
|
+
it["when"] = when(it["at"])
|
|
233
|
+
self.wire.send("sessions", items=items[:50])
|
|
234
|
+
elif kind == "compact":
|
|
235
|
+
if self.busy or self.runner is None or self.session is None:
|
|
236
|
+
self.wire.send("warn", text="nothing to compact yet" if self.session is None else "wait for the current turn to finish")
|
|
237
|
+
return
|
|
238
|
+
head = self.session.head("main")
|
|
239
|
+
self.wire.send("busy", phase="compacting")
|
|
240
|
+
cid = await self.runner._compact(head.id if head else None, self.runner.policy, 0)
|
|
241
|
+
self.wire.send("info", text="context summarised and restarted" if cid else "compaction failed; context unchanged")
|
|
242
|
+
self.wire.send("idle")
|
|
212
243
|
elif kind == "message":
|
|
213
244
|
text = str(cmd.get("text", "")).strip()
|
|
214
245
|
if not text:
|
|
@@ -301,6 +332,18 @@ class Engine:
|
|
|
301
332
|
pass
|
|
302
333
|
|
|
303
334
|
|
|
335
|
+
def find_session(name: str) -> Path | None:
|
|
336
|
+
"""A local session by name (substring; "last" = newest), else pull it from the server."""
|
|
337
|
+
from hum import sync
|
|
338
|
+
ms = [p for p in list_sessions() if name in p.name] if name != "last" else list_sessions()
|
|
339
|
+
if ms:
|
|
340
|
+
return ms[-1]
|
|
341
|
+
for r in sync.remote_list():
|
|
342
|
+
if name == "last" or name in r.get("name", ""):
|
|
343
|
+
return sync.pull(r["name"])
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
304
347
|
async def serve(cfg: Config, cwd: Path, *, listen: bool, port: int | None) -> int:
|
|
305
348
|
"""``listen``: bind an ephemeral localhost port, print it, accept one front-end.
|
|
306
349
|
Otherwise connect to ``port`` (the front-end is listening)."""
|
|
@@ -54,3 +54,38 @@ def sync_all(client: Any = None) -> tuple[int, int]:
|
|
|
54
54
|
if upload(p, c, client=client):
|
|
55
55
|
sent += 1
|
|
56
56
|
return sent, skipped
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remote_list(creds: Credentials | None = None, client: Any = None, timeout: float = 8.0) -> list[dict[str, Any]]:
|
|
60
|
+
"""Sessions the server holds for this person (any machine). Empty on any failure."""
|
|
61
|
+
import httpx
|
|
62
|
+
|
|
63
|
+
c = creds or load_creds()
|
|
64
|
+
if not c.logged_in:
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
http = client or httpx.Client(timeout=timeout)
|
|
68
|
+
r = http.get(f"{c.auth_url}/sessions", headers={"Authorization": f"Bearer {c.access_token}"})
|
|
69
|
+
return list(r.json()) if r.status_code == 200 else []
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pull(name: str, creds: Credentials | None = None, client: Any = None, timeout: float = 30.0) -> Path | None:
|
|
75
|
+
"""Fetch one session file from the server into ~/.hum/sessions (so it can be resumed here)."""
|
|
76
|
+
import httpx
|
|
77
|
+
|
|
78
|
+
c = creds or load_creds()
|
|
79
|
+
if not c.logged_in:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
http = client or httpx.Client(timeout=timeout)
|
|
83
|
+
r = http.get(f"{c.auth_url}/sessions/{name}", headers={"Authorization": f"Bearer {c.access_token}"})
|
|
84
|
+
if r.status_code != 200 or not r.content:
|
|
85
|
+
return None
|
|
86
|
+
dest = home() / "sessions" / Path(name).name
|
|
87
|
+
dest.write_bytes(r.content)
|
|
88
|
+
_mark(dest.name, {"size": dest.stat().st_size, "id": None})
|
|
89
|
+
return dest
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
return None
|