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.
Files changed (51) hide show
  1. {hum_cli-0.1.0 → hum_cli-0.1.2}/.github/workflows/publish.yml +1 -0
  2. {hum_cli-0.1.0 → hum_cli-0.1.2}/PKG-INFO +1 -1
  3. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/__init__.py +1 -1
  4. hum_cli-0.1.2/hum/catalog.py +64 -0
  5. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/cli.py +19 -14
  6. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/engine.py +55 -12
  7. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/sync.py +35 -0
  8. hum_cli-0.1.2/hum/tui/cli.mjs +84428 -0
  9. {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/package.json +1 -1
  10. {hum_cli-0.1.0 → hum_cli-0.1.2}/pyproject.toml +6 -2
  11. {hum_cli-0.1.0 → hum_cli-0.1.2}/server/app.py +31 -2
  12. {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_sme_ready.py +89 -0
  13. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/app.jsx +36 -3
  14. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/cli.jsx +1 -1
  15. {hum_cli-0.1.0 → hum_cli-0.1.2}/.github/workflows/test.yml +0 -0
  16. {hum_cli-0.1.0 → hum_cli-0.1.2}/.gitignore +0 -0
  17. {hum_cli-0.1.0 → hum_cli-0.1.2}/README.md +0 -0
  18. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/adapters/__init__.py +0 -0
  19. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/adapters/harbor.py +0 -0
  20. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/auth.py +0 -0
  21. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/__init__.py +0 -0
  22. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/agents.py +0 -0
  23. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/autonomy.py +0 -0
  24. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/config.py +0 -0
  25. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/drivers.py +0 -0
  26. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/executor.py +0 -0
  27. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/grader.py +0 -0
  28. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/llm.py +0 -0
  29. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/loop.py +0 -0
  30. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/models.py +0 -0
  31. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/observe.py +0 -0
  32. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/prompt.py +0 -0
  33. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/session.py +0 -0
  34. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/store.py +0 -0
  35. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/tools.py +0 -0
  36. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/trajectory.py +0 -0
  37. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/runtime/workspace.py +0 -0
  38. {hum_cli-0.1.0 → hum_cli-0.1.2}/hum/ui.py +0 -0
  39. {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/bin/hum.js +0 -0
  40. {hum_cli-0.1.0 → hum_cli-0.1.2}/npm/scripts/postinstall.js +0 -0
  41. {hum_cli-0.1.0 → hum_cli-0.1.2}/server/__init__.py +0 -0
  42. {hum_cli-0.1.0 → hum_cli-0.1.2}/server/modal_app.py +0 -0
  43. {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_cli.py +0 -0
  44. {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_runtime.py +0 -0
  45. {hum_cli-0.1.0 → hum_cli-0.1.2}/tests/test_server_and_auth.py +0 -0
  46. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/package-lock.json +0 -0
  47. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/package.json +0 -0
  48. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/devtools-stub.js +0 -0
  49. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/engine.js +0 -0
  50. {hum_cli-0.1.0 → hum_cli-0.1.2}/tui/src/input.jsx +0 -0
  51. {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.0
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
@@ -1,4 +1,4 @@
1
1
  """Hum — an agent harness built as an RL environment that humans can drive.
2
2
 
3
3
  हम (Hindi): we, us. Human and model, one harness."""
4
- __version__ = "0.1.0"
4
+ __version__ = "0.1.2"
@@ -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
- for p in list_sessions():
358
- try:
359
- s = Session.load(p)
360
- hv = sum(1 for n in s.nodes.values() if n.intervention)
361
- ui.console.print(f"[dim]{p.stem}[/] {len(s.order):>3} turns {hv:>2} yours {s.task.get('instruction', '')[:70]}")
362
- except Exception as e: # noqa: BLE001
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
- ms = [p for p in list_sessions() if name in p.name] if name and name != "last" else list_sessions()
368
- if not ms:
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 ms[-1]
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
- return asyncio.run(Client(cfg, cwd, ui).chat(None, resume=s))
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.session is None:
204
- name = str(cmd.get("name") or "last")
205
- ms = [p for p in list_sessions() if name in p.name] if name != "last" else list_sessions()
206
- if not ms:
207
- self.wire.send("error", text=f"no session matching {name!r}")
208
- return
209
- s = Session.load(ms[-1])
210
- self._open(s)
211
- self.wire.send("idle")
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