hum-cli 0.2.63__py3-none-any.whl → 0.2.64__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.
hum/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
1
  """Hum — an agent harness built as an RL environment that humans can drive."""
2
- __version__ = "0.2.63"
2
+ __version__ = "0.2.64"
hum/client.py CHANGED
@@ -183,11 +183,14 @@ class VisibleTools(ToolRegistry):
183
183
  return results
184
184
 
185
185
 
186
- def retry_line(kind: str, model: str | None, wait_s: float, attempt: int, since_s: float) -> str:
187
- """``model capacity (google/gemini-3.8-flash): waiting 30 s — retry 2 · 1m 05s so far``"""
188
- m, s = divmod(int(since_s), 60)
189
- return (f"model {kind}{f' ({model})' if model else ''}: waiting {wait_s:.0f} s — retry {attempt} · "
190
- f"{f'{m}m {s:02d}s' if m else f'{s}s'} so far")
186
+ def retry_line(kind: str, wait_s: float, attempt: int, of: int) -> str:
187
+ """``Retrying in 30s · attempt 2/10 · capacity``, as Claude Code says it."""
188
+ return f"Retrying in {wait_s:.0f}s · attempt {attempt}/{of} · {kind}"
189
+
190
+
191
+ def waiting_line(s: float) -> str:
192
+ """An attempt that has shown nothing for ``s`` seconds."""
193
+ return f"still waiting for the model ({s:.0f}s)"
191
194
 
192
195
 
193
196
  class VisiblePolicy(PolicyDriver):
@@ -198,14 +201,13 @@ class VisiblePolicy(PolicyDriver):
198
201
  **({"retries": retries} if retries is not None else {}))
199
202
  self.ui = ui
200
203
  self.on_retry = self._retrying
204
+ self.on_waiting = lambda s: ui.set_phase(waiting_line(s))
201
205
  if hasattr(llm, "on_notice"):
202
206
  llm.on_notice = ui.warn # our proxy's word on the person's credits, once
203
207
 
204
208
  def _retrying(self, attempt: int, e: BaseException, delay: float) -> None:
205
- """The person sees the failure's kind, the model it came from and the wait before the next attempt."""
206
- since = self.clock() - (self.started_at or self.clock())
207
- self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, getattr(self.llm, "model", None),
208
- delay, attempt, since))
209
+ """The person sees the wait before the next attempt, which attempt of how many, and the failure's kind."""
210
+ self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, delay, attempt, self.retries))
209
211
 
210
212
  async def next(self, messages, tools): # type: ignore[override]
211
213
  self.ui.turn_started()
hum/core.py CHANGED
@@ -673,7 +673,7 @@ class WireUI(UI):
673
673
  self._text = []
674
674
  self._announced.clear()
675
675
  self._fold_thought()
676
- self.set_phase(f"model {kind}: retry {attempt} in {wait_s:.0f} s")
676
+ self.set_phase(text)
677
677
  self.wire.send("retry", attempt=attempt, kind=kind, wait_s=wait_s, text=text)
678
678
 
679
679
  def error(self, msg: str) -> None:
hum/runtime/config.py CHANGED
@@ -65,9 +65,11 @@ class Config:
65
65
  api_key: str | None = None
66
66
  reasoning_effort: str | None = None
67
67
  temperature: float | None = None
68
- idle_timeout_s: float = 600.0 # a stream silent this long is dead (models can think silently for minutes)
68
+ # [model] idle_timeout_s: once an attempt is answered, the longest silence between stream events (keepalives count)
69
+ # before it is cut and retried; the gateway keeps a long think alive (hum.runtime.llm.STREAM_IDLE_S)
70
+ idle_timeout_s: float = 300.0
69
71
  # [model] retries: requests tried again after a throttle, an upstream failure or a dropped connection
70
- retries: int = 10 # a cap; the wait budget (hum.runtime.drivers.RETRY_BUDGET_S) is what binds
72
+ retries: int = 10 # the most retries of a turn; fewer by kind and wait (hum.runtime.drivers)
71
73
  mcp: list[MCPConfig] = field(default_factory=list)
72
74
  lsp: list[LSPConfig] = field(default_factory=list) # [[lsp]]: language servers, beside the ones found on PATH
73
75
  gate: str = "off"
@@ -293,7 +295,7 @@ def load(path: Path | None = None) -> Config:
293
295
  # server's default
294
296
  reasoning_effort=_env("HUM_REASONING_EFFORT") or m.get("reasoning_effort") or cred.get("reasoning_effort")
295
297
  or None,
296
- temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s", 600.0)),
298
+ temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s", 300.0)),
297
299
  retries=int(m.get("retries", 10)),
298
300
  mcp=[_server(s) for s in d.get("mcp", []) if s.get("name")],
299
301
  lsp=[LSPConfig(name=str(s["name"]), command=str(s["command"]), args=[str(x) for x in s.get("args", [])],
hum/runtime/drivers.py CHANGED
@@ -12,23 +12,30 @@ from typing import Any, Awaitable, Callable
12
12
 
13
13
  from pydantic import BaseModel
14
14
 
15
- from .llm import ATTEMPT_BUDGET_S, LLM, REQUEST_KEY, Failure, StreamEvents, failure_of
15
+ from .llm import LLM, REQUEST_KEY, RETRY_AFTER_MAX_S, Failure, ProviderError, StreamEvents, failure_of
16
16
  from .models import Intervention, Turn
17
17
 
18
- # the most attempts a turn makes after its first; the wait budget below is what binds under this default
18
+ # the most retries a turn makes after its first attempt (Claude Code's 10). Each attempt is bounded on its own (the
19
+ # client's answer and stream-idle clocks, hum.runtime.llm), never the turn, so a slow first token is waited out; the
20
+ # kinds below are retried fewer times
19
21
  RETRIES = 10
20
- # the longest a turn's attempts may run, wall time from its first send: the time inside failed attempts (the gateway
21
- # waits out a capacity 429 or holds a silent stream itself before it answers) and the waits between them. A person is
22
- # waiting on it; 3 min outlasts a Vertex capacity episode (429 RESOURCE_EXHAUSTED, 10-07..10-14: median 1 min, p90
23
- # 2.2 min). No retry starts whose wait would end past it, so retries never stack on the gateway's own into minutes
24
- # of silence
25
- RETRY_BUDGET_S = 180.0
26
- # the least an attempt is started with, in seconds of budget left after its wait: the gateway narrows its deadline to
27
- # the declared timeout less its 30 s margin, so under this an attempt has under 15 s to show first output, which an
28
- # upstream answering through a capacity wave rarely does; it would only add silence before the same failure
29
- RETRY_MIN_ATTEMPT_S = 45.0
30
- RETRY_BASE_S = 1.0 # the first backoff ceiling; doubles each attempt
31
- RETRY_CAP_S = 60.0 # the highest the backoff ceiling grows to
22
+ # a clock that ran out: the gateway already retried inside the attempt, and each retry thinks the reply again from the
23
+ # start, so twice is all the person waits on it (worst case 3 attempts of ~5 min)
24
+ RETRIES_BY_KIND = {"deadline_exceeded": 2, "stream_idle": 2, "upstream_timeout": 2}
25
+ # a throttle (capacity, overloaded, rate_limit, a 429): the gateway waits it out and retries inside each attempt, so
26
+ # every retry here multiplies its sends. No retry starts once the person has waited this long on the throttle (the
27
+ # failed attempts and the waits between them): worst case this plus one last attempt, ~10 min, against ~50 min for 11
28
+ # attempts the gateway holds to its deadline
29
+ THROTTLE_WAIT_MAX_S = 300.0
30
+ THROTTLE_KINDS = frozenset({"capacity", "overloaded", "rate_limit"})
31
+ # full-jitter backoff when the server named no wait: the ceiling doubles from 1 s to Claude Code's 32 s cap
32
+ # (code.claude.com/docs/en/errors)
33
+ RETRY_BASE_S = 1.0
34
+ RETRY_CAP_S = 32.0
35
+ # how long an attempt may show nothing before the person is told it is still waited on, then how often after
36
+ # (Claude Code's banner after 20 s with no data)
37
+ WAITING_AFTER_S = 20.0
38
+ WAITING_EVERY_S = 1.0
32
39
 
33
40
 
34
41
  def backoff(attempt: int) -> float:
@@ -37,33 +44,43 @@ def backoff(attempt: int) -> float:
37
44
  return random.uniform(0.0, min(RETRY_CAP_S, RETRY_BASE_S * 2 ** (attempt - 1)))
38
45
 
39
46
 
40
- def retry_wait(attempt: int, f: Failure, left_s: float) -> float | None:
41
- """How long to wait before attempt ``attempt``, or None when there is no retry: the failure is not
42
- retryable, or the wait the server asked for does not fit in what is left of the budget (an early retry would be
43
- answered the same). With no wait named, the backoff, cut to what is left."""
44
- if not f.retryable or left_s <= 0:
47
+ def retry_wait(attempt: int, f: Failure) -> float | None:
48
+ """How long to wait before attempt ``attempt``, or None when there is no retry (the failure is not retryable;
49
+ failure_of already counts a wait past RETRY_AFTER_MAX_S as that): the wait the server named, else the backoff."""
50
+ if not f.retryable:
45
51
  return None
46
- if f.retry_after is not None:
47
- return f.retry_after if f.retry_after <= left_s else None
48
- return min(backoff(attempt), left_s)
52
+ return f.retry_after if f.retry_after is not None else backoff(attempt)
53
+
54
+
55
+ def throttle(f: Failure) -> bool:
56
+ return f.kind in THROTTLE_KINDS or f.status == 429
57
+
58
+
59
+ def said(e: BaseException, f: Failure) -> BaseException:
60
+ """The failure that ends the turn, as the person reads it: a wait asked for and not waited says so."""
61
+ if isinstance(e, ProviderError) and not f.retryable and f.retry_after is not None:
62
+ m, s = divmod(int(f.retry_after), 60)
63
+ return ProviderError(e.status, f"{e.message} — the provider asks to wait {f'{m}m {s:02d}s' if m else f'{s}s'}, "
64
+ f"past {RETRY_AFTER_MAX_S:.0f} s: not retried", type=e.type, code=e.code,
65
+ retry_after=e.retry_after, body=e.body, location=e.location, should_retry=False,
66
+ kind=e.kind)
67
+ return e
49
68
 
50
69
 
51
70
  class PolicyDriver:
52
71
  author = "policy"
53
72
 
54
73
  def __init__(self, llm: LLM, author: str = "policy", on_text: Any = None, events: StreamEvents | None = None,
55
- retries: int = RETRIES, budget_s: float = RETRY_BUDGET_S,
56
- tool_choice: Any = None):
74
+ retries: int = RETRIES, tool_choice: Any = None):
57
75
  self.llm = llm
58
76
  self.author = author
59
77
  self.tool_choice = tool_choice # a caller that requires one named tool (the judge) says so here
60
78
  self.events = events or StreamEvents(text=on_text)
61
79
  self.retries = retries
62
- self.budget_s = budget_s
63
80
  self.on_retry: Any = None # client hook: (attempt, exception, delay_s) -> None
81
+ # client hook: (seconds) -> None, while an attempt has shown nothing past WAITING_AFTER_S
82
+ self.on_waiting: Callable[[float], None] | None = None
64
83
  self.sleep: Callable[[float], Awaitable[Any]] | None = None # the wait between attempts; asyncio.sleep unset
65
- self.clock: Callable[[], float] = time.monotonic # the budget's clock
66
- self.started_at: float | None = None # the turn's first send, on ``clock``
67
84
  # the last call as the person waited for it: request_at, first_at (first streamed delta), done_at, attempts
68
85
  self.timing: dict[str, Any] | None = None
69
86
  # the last call as the record keeps it, with the attempts that failed before it; the loop takes it with the node
@@ -93,17 +110,23 @@ class PolicyDriver:
93
110
  reasoning=(lambda s: (seen(), ev.reasoning(s))[1]) if ev.reasoning else None,
94
111
  tool=(lambda i, n, a: (seen(), ev.tool(i, n, a))[1]) if ev.tool else None)
95
112
  attempt = 0
96
- self.started_at = self.clock()
113
+ by_kind: dict[str, int] = {} # the retries of each kind so far
114
+ throttled = 0.0 # the time the person has waited on a throttle: its failed attempts and the waits after them
115
+ loop = asyncio.get_running_loop()
97
116
  key = REQUEST_KEY.set(f"hum-{uuid.uuid4()}") # one per turn: every retry below is the same request again
98
- budget_key = ATTEMPT_BUDGET_S.set(None)
99
117
  try:
100
118
  while True:
101
119
  # each attempt is its own call: a delta streamed by one that then failed is not this one's first token
102
120
  t_req, first[0] = time.monotonic(), None
103
- ATTEMPT_BUDGET_S.set(self.budget_s - (self.clock() - self.started_at))
121
+ waiting = asyncio.ensure_future(self._waiting(first)) if self.on_waiting else None
122
+ sent = loop.time()
104
123
  try:
105
- t = await self.llm.complete(messages, tools, events=timed,
106
- **({"tool_choice": self.tool_choice} if self.tool_choice else {}))
124
+ try:
125
+ t = await self.llm.complete(messages, tools, events=timed,
126
+ **({"tool_choice": self.tool_choice} if self.tool_choice else {}))
127
+ finally:
128
+ if waiting is not None:
129
+ waiting.cancel() # the attempt is over: the wait before the next is said by on_retry
107
130
  break
108
131
  except asyncio.CancelledError:
109
132
  raise
@@ -113,19 +136,29 @@ class PolicyDriver:
113
136
  "error": (lc or {}).get("error") or {"type": type(e).__name__, "message": str(e)}})
114
137
  if lc is not None:
115
138
  self.llm.last_call = None
116
- # what is left after the wait must be worth an attempt: the person's silence ends by the budget
117
- left = self.budget_s - (self.clock() - self.started_at) - RETRY_MIN_ATTEMPT_S
118
- delay = retry_wait(attempt + 1, failure_of(e), left) if attempt < self.retries else None
139
+ f = failure_of(e)
140
+ if throttle(f):
141
+ throttled += loop.time() - sent
142
+ delay = retry_wait(attempt + 1, f) if attempt < self.retries else None
143
+ if delay is not None and by_kind.get(f.kind, 0) >= RETRIES_BY_KIND.get(f.kind, self.retries):
144
+ delay = None
145
+ if delay is not None and throttle(f):
146
+ if throttled + delay > THROTTLE_WAIT_MAX_S:
147
+ delay = None
148
+ else:
149
+ throttled += delay
119
150
  if delay is None:
120
151
  self._take_call(failed) # the calls that were made are on the record even when the turn is not
152
+ if (out := said(e, f)) is not e:
153
+ raise out from e
121
154
  raise
122
155
  attempt += 1
156
+ by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
123
157
  if self.on_retry:
124
158
  self.on_retry(attempt, e, delay)
125
159
  await (self.sleep or asyncio.sleep)(delay)
126
160
  finally:
127
161
  REQUEST_KEY.reset(key)
128
- ATTEMPT_BUDGET_S.reset(budget_key)
129
162
  t_done = time.monotonic()
130
163
  self.timing = {"started_at": t_start, "request_at": t_req,
131
164
  "first_at": first[0] if first[0] is not None else t_done, "done_at": t_done,
@@ -135,6 +168,16 @@ class PolicyDriver:
135
168
  t = t.model_copy(update={"author": self.author})
136
169
  return t
137
170
 
171
+ async def _waiting(self, first: list[float | None]) -> None:
172
+ """Past WAITING_AFTER_S of an attempt with no output, the person is told how long it has been, every
173
+ WAITING_EVERY_S until output comes or the attempt ends."""
174
+ loop = asyncio.get_running_loop()
175
+ t0 = loop.time()
176
+ await asyncio.sleep(WAITING_AFTER_S)
177
+ while first[0] is None and self.on_waiting is not None:
178
+ self.on_waiting(loop.time() - t0)
179
+ await asyncio.sleep(WAITING_EVERY_S)
180
+
138
181
  def partial(self) -> Turn | None:
139
182
  """What the model had produced when it was interrupted, if the client keeps it."""
140
183
  fn = getattr(self.llm, "partial", None)
hum/runtime/llm.py CHANGED
@@ -50,11 +50,25 @@ CONTEXT_HEADERS = ("session", "agent", "parent", "branch", "purpose")
50
50
  # instead of calling the model again. x-client-request-id stays one per attempt: it names the attempt, this the request
51
51
  REQUEST_KEY: contextvars.ContextVar[str | None] = contextvars.ContextVar("hum_request_key", default=None)
52
52
 
53
- # how long this attempt may keep the person waiting for its first output, in seconds (None: no bound): set by the
54
- # driver from what is left of the turn's retry budget. Sent as `x-stainless-timeout` (the Stainless SDKs' declared
55
- # timeout, seconds), which the gateway narrows its own deadline to, less its margin, so its own holds and waits end
56
- # first and it answers typed; and the client cuts the attempt there itself when no output has come
57
- ATTEMPT_BUDGET_S: contextvars.ContextVar[float | None] = contextvars.ContextVar("hum_attempt_budget_s", default=None)
53
+ # each attempt is bounded on its own, never the turn. Its answer (headers, or the first stream event) is waited 300 s
54
+ # plus 1 s per 32 KB of request body, Claude Code's first-byte bound (code.claude.com/docs/en/network-config), a long
55
+ # prompt being slower to answer. Sent as `x-stainless-timeout` (the Stainless SDKs' declared timeout, seconds), which
56
+ # the gateway narrows its own deadline to, less its 30 s margin, so its holds and waits end first and it answers typed
57
+ FIRST_OUTPUT_S = 300.0
58
+ FIRST_OUTPUT_BYTES_PER_S = 32 * 1024
59
+ # after that, the longest silence between stream events of any kind (a keepalive comment, response.created, a delta)
60
+ # before the stream is taken for dead: Codex's stream_idle_timeout (codex-rs/model-provider-info/src/lib.rs:66), Claude
61
+ # Code's idle timeout. The client's default; [model] idle_timeout_s sets it
62
+ STREAM_IDLE_S = 300.0
63
+ # the longest wait a server may ask for and be waited: past it (an hourly or daily window) a retry is not slept, the
64
+ # person is told (gemini-cli packages/core/src/utils/googleQuotaErrors.ts:120)
65
+ RETRY_AFTER_MAX_S = 300.0
66
+
67
+
68
+ def first_output_s(body_bytes: int) -> float:
69
+ """How long an attempt whose request is ``body_bytes`` long may go without an answer."""
70
+ return FIRST_OUTPUT_S + body_bytes / FIRST_OUTPUT_BYTES_PER_S
71
+
58
72
 
59
73
  # how the answer travels, not what was asked: left out of the digest
60
74
  WIRE_ONLY = ("stream", "stream_options")
@@ -329,20 +343,22 @@ class ProviderError(Exception):
329
343
  (the body's ``error.type`` / ``error.code`` in the OpenAI shape, or FastAPI's ``detail`` when it is an
330
344
  object); its message; how long it asked us to
331
345
  wait; where it sent us instead (the Location header, on a redirect); whether it said to try again (its
332
- ``x-should-retry`` header: True, False, or None when it said nothing); and the body whole. Everything a
333
- caller decides on is a field here, never a word read out of a sentence."""
346
+ ``x-should-retry`` header: True, False, or None when it said nothing); the gateway's catalog kind (the body's
347
+ ``nunatak_kind``, beside the wire's native type and code; None from a base that is not the gateway); and the body
348
+ whole. Everything a caller decides on is a field here, never a word read out of a sentence."""
334
349
 
335
350
  def __init__(self, status: int, message: str, *, type: str | None = None, code: str | None = None,
336
351
  retry_after: float | None = None, body: str = "", location: str | None = None,
337
- should_retry: bool | None = None):
352
+ should_retry: bool | None = None, kind: str | None = None):
338
353
  self.status, self.message, self.type, self.code, self.retry_after, self.body = (int(status), message, type,
339
354
  code, retry_after, body)
340
- self.location, self.should_retry = location, should_retry
341
- super().__init__(f"HTTP {self.status}" + (f" {type}" if type else "") + (f": {message}" if message else ""))
355
+ self.location, self.should_retry, self.kind = location, should_retry, kind
356
+ named = kind or type
357
+ super().__init__(f"HTTP {self.status}" + (f" {named}" if named else "") + (f": {message}" if message else ""))
342
358
 
343
359
  @classmethod
344
360
  def from_response(cls, status: int, body: str, headers: Any = None) -> "ProviderError":
345
- message, type_, code = body.strip(), None, None
361
+ message, type_, code, kind = body.strip(), None, None, None
346
362
  location = (headers.get("location") if headers is not None else None) or None
347
363
  try:
348
364
  d = json.loads(body)
@@ -356,6 +372,7 @@ class ProviderError(Exception):
356
372
  message = str(err.get("message") or message)
357
373
  type_ = str(err["type"]) if err.get("type") else None
358
374
  code = str(err["code"]) if err.get("code") is not None else None
375
+ kind = str(err["nunatak_kind"]) if err.get("nunatak_kind") else None
359
376
  elif isinstance(d.get("detail"), str):
360
377
  message = d["detail"]
361
378
  elif isinstance(d.get("error"), str):
@@ -363,7 +380,7 @@ class ProviderError(Exception):
363
380
  if location and 300 <= status < 400:
364
381
  message = f"moved to {location}" # a redirect's body says "Redirecting"; the person needs the address
365
382
  return cls(status, message, type=type_, code=code, retry_after=retry_after_of(headers, message), body=body,
366
- location=location, should_retry=should_retry_of(headers))
383
+ location=location, should_retry=should_retry_of(headers), kind=kind)
367
384
 
368
385
  @classmethod
369
386
  def from_frame(cls, err: dict[str, Any]) -> "ProviderError":
@@ -376,12 +393,19 @@ class ProviderError(Exception):
376
393
  return cls(status if 400 <= status < 600 else 502, str(err.get("message") or ""),
377
394
  type=str(err["type"]) if err.get("type") else None,
378
395
  code=str(err["code"]) if err.get("code") is not None else None, body=json.dumps({"error": err}),
379
- should_retry=r if isinstance(r, bool) else None)
396
+ should_retry=r if isinstance(r, bool) else None,
397
+ kind=str(err["nunatak_kind"]) if err.get("nunatak_kind") else None)
380
398
 
381
399
  @property
382
400
  def is_quota(self) -> bool:
383
401
  """A 429 that is a budget spent, never a throttle: no retry will help."""
384
- return (self.type or "") in QUOTA or (self.code or "") in QUOTA
402
+ return (self.type or "") in QUOTA or (self.code or "") in QUOTA or self.is_daily_quota
403
+
404
+ @property
405
+ def is_daily_quota(self) -> bool:
406
+ """A quota that resets daily: the gateway's ``daily_quota`` kind (metaphi-labs/nunatak#232). No retry within the
407
+ session changes it."""
408
+ return self.kind == "daily_quota"
385
409
 
386
410
 
387
411
  CREDITS_WARN_AT = 0.8 # the share of credits used at which the person hears it, once per session
@@ -400,10 +424,24 @@ def error_text(e: BaseException) -> str:
400
424
  return f"{type(e).__name__}: {first}" if first else type(e).__name__
401
425
 
402
426
 
403
- # the gateway's failure kinds (Nunatak's catalog: error.type on any failure before output) and whether another request
404
- # can be answered differently. A kind named terminal is never retried, whatever its status or header says
405
- RETRYABLE_KINDS = frozenset({"capacity", "rate_limit", "deadline_exceeded"})
427
+ # the gateway's failure kinds (Nunatak's catalog, config/errors.json: error.nunatak_kind beside the wire's native type
428
+ # and code, before output and in-band) and whether another request can be answered differently. A kind named terminal
429
+ # is never retried, whatever its status or header says
430
+ RETRYABLE_KINDS = frozenset({"capacity", "overloaded", "rate_limit", "deadline_exceeded"})
406
431
  TERMINAL_KINDS = frozenset({"bad_request", "credits", "untyped"})
432
+ # a base that is not the gateway names no kind: its native names read as the kind the gateway would have said for them
433
+ # (the reverse of Nunatak's clients.<wire>.native), the code before the type (OpenAI's type "requests" says nothing)
434
+ NATIVE_KINDS = {"rate_limit_exceeded": "rate_limit", "rate_limit_error": "rate_limit",
435
+ "overloaded_error": "overloaded", "server_is_overloaded": "overloaded",
436
+ "provider_overloaded": "overloaded", "timeout_error": "upstream_timeout",
437
+ "billing_error": "credits", "insufficient_quota": "credits"}
438
+
439
+
440
+ def kind_of(e: ProviderError) -> str:
441
+ """The failure's catalog kind: the gateway's ``nunatak_kind``, else what a native code or type stands for, else
442
+ the provider's own type, else ``http_<status>``."""
443
+ return (e.kind or NATIVE_KINDS.get(e.code or "") or NATIVE_KINDS.get(e.type or "") or e.type
444
+ or f"http_{e.status}")
407
445
 
408
446
 
409
447
  @dataclass(frozen=True)
@@ -411,7 +449,8 @@ class Failure:
411
449
  """A failed model call as the retry decision reads it, typed once (``failure_of``): its kind (the gateway's
412
450
  catalog kind; else ``network`` for no answer at all, ``context_overflow``, or the provider's own type, or
413
451
  ``http_<status>``), whether another request can help, how long the server asked us to wait (None: it named
414
- no wait), and the wire code, status and message it came with."""
452
+ no wait; on a failure not retried, only a wait past RETRY_AFTER_MAX_S, the reason it is not), and the wire code,
453
+ status and message it came with."""
415
454
  kind: str
416
455
  retryable: bool
417
456
  retry_after: float | None = None
@@ -425,12 +464,15 @@ def failure_of(e: BaseException) -> Failure:
425
464
  retries; else ``x-should-retry`` (or a stream frame's ``retryable``); else a retryable kind; else the status
426
465
  rules for a provider that types nothing: a throttle (a 429 that is not a budget), an upstream failure (5xx),
427
466
  a timeout or conflict. A budget spent, a bad request or token, a redirect, a prompt too big (whatever its
428
- status: the loop makes the context smaller instead) are not. A connection that dropped or never answered
467
+ status: the loop makes the context smaller instead) are not, nor a retryable failure that asks for a wait past
468
+ RETRY_AFTER_MAX_S. A connection that dropped or never answered
429
469
  (an httpx transport error) is ``network`` and retried."""
430
470
  if isinstance(e, ProviderError):
431
- kind = e.type or f"http_{e.status}"
471
+ kind = kind_of(e)
432
472
  if is_context_overflow(e):
433
473
  kind, retryable = "context_overflow", False
474
+ elif e.is_daily_quota:
475
+ kind, retryable = "daily_quota", False
434
476
  elif kind in TERMINAL_KINDS or e.is_quota:
435
477
  retryable = False
436
478
  elif e.should_retry is not None:
@@ -440,6 +482,8 @@ def failure_of(e: BaseException) -> Failure:
440
482
  else:
441
483
  # a 3xx says this address is not the host any more: the same request to the same place is answered the same
442
484
  retryable = e.status in (408, 409, 425) or e.status >= 500
485
+ if retryable and e.retry_after is not None and e.retry_after > RETRY_AFTER_MAX_S:
486
+ return Failure(kind, False, e.retry_after, e.code, e.status, e.message) # a wait not waited: terminal
443
487
  return Failure(kind, retryable, e.retry_after if retryable else None, e.code, e.status, e.message)
444
488
  if isinstance(e, httpx.TransportError):
445
489
  return Failure("network", True, None, None, None, error_text(e))
@@ -463,7 +507,7 @@ def is_context_overflow(e: BaseException) -> bool:
463
507
  the context can be made smaller and the turn sent again."""
464
508
  if not isinstance(e, ProviderError):
465
509
  return False
466
- if (e.code or "") in OVERFLOW_CODES or (e.type or "") in OVERFLOW_CODES:
510
+ if e.kind == "context_overflow" or (e.code or "") in OVERFLOW_CODES or (e.type or "") in OVERFLOW_CODES:
467
511
  return True
468
512
  return e.status == 400 and any(s in e.message.lower() for s in OVERFLOW)
469
513
 
@@ -492,7 +536,7 @@ class OpenAICompatClient:
492
536
 
493
537
  def __init__(self, model: str, *, api_base: str, api_key: str | None = None, temperature: float | None = None,
494
538
  reasoning_effort: str | None = None, max_tokens: int | None = None, timeout: float = 3600.0,
495
- idle_timeout: float = 600.0, author: str = "policy", transport: Any = None,
539
+ idle_timeout: float = STREAM_IDLE_S, author: str = "policy", transport: Any = None,
496
540
  keep_requests: Path | None = None, hum: bool = True, gateway: Gateway | None = None):
497
541
  # on a gateway the model is the catalog's name, whole; elsewhere ``openai/`` names the wire, not the model
498
542
  strip = gateway is None and model.startswith("openai/")
@@ -506,9 +550,11 @@ class OpenAICompatClient:
506
550
  self.request_id: str | None = None # the id the current attempt went up under (x-client-request-id)
507
551
  self.temperature, self.reasoning_effort, self.max_tokens, self.timeout = (temperature, reasoning_effort,
508
552
  max_tokens, timeout)
509
- # a model thinking hard can be silent for minutes; a dead stream is silent forever
553
+ # the longest silence between stream events once the attempt is answered: a model thinking for minutes is kept
554
+ # alive by the gateway's keepalives; a dead stream is silent forever
510
555
  self.idle_timeout = idle_timeout
511
- self._first_due: asyncio.Timeout | None = None # the attempt's first-output clock (_call)
556
+ self._due: asyncio.Timeout | None = None # the attempt's clock: first output, then each stream event (_call)
557
+ self._answered = False # whether this attempt has been answered yet (headers or any stream event)
512
558
  self.author, self._transport = author, transport
513
559
  # where to keep each request whole when no server of ours does (the person's own key)
514
560
  self.keep_requests = keep_requests
@@ -567,7 +613,7 @@ class OpenAICompatClient:
567
613
  b["session_id"] = str(session)[:256]
568
614
  return b, []
569
615
 
570
- def _headers(self, request_id: str, api_key: str | None) -> dict[str, str]:
616
+ def _headers(self, request_id: str, api_key: str | None, first_s: float) -> dict[str, str]:
571
617
  # the client names itself here too: our proxy is a route of the deployment, which keeps a client floor
572
618
  from hum.auth import headers as auth_headers
573
619
  # the id this client minted for the request, as Claude Code and Codex send it: every hop logs it, so the call
@@ -576,8 +622,7 @@ class OpenAICompatClient:
576
622
  "x-client-request-id": request_id}
577
623
  if key := REQUEST_KEY.get():
578
624
  h["idempotency-key"] = key
579
- if (budget := ATTEMPT_BUDGET_S.get()) is not None:
580
- h["x-stainless-timeout"] = str(max(1, int(budget)))
625
+ h["x-stainless-timeout"] = str(int(first_s))
581
626
  if api_key:
582
627
  h["authorization"] = f"Bearer {api_key}"
583
628
  return h
@@ -624,8 +669,8 @@ class OpenAICompatClient:
624
669
  if not self.api_base and self.gateway is None:
625
670
  raise ProviderError(401, "no model host: `hum login`, or name an OpenAI-compatible base under [model] "
626
671
  "api_base in config.toml", type="no_host")
627
- # httpx's read timeout is per chunk: a long generation keeps streaming; a stream silent for idle_timeout fails.
628
- timeout = httpx.Timeout(self.timeout, connect=30.0, read=self.idle_timeout)
672
+ # no read timeout: an attempt's own clocks (_call) bound the wait for its answer and each silence after it
673
+ timeout = httpx.Timeout(self.timeout, connect=30.0, read=None)
629
674
  async with httpx.AsyncClient(timeout=timeout, transport=self._transport) as http:
630
675
  if self.gateway is None:
631
676
  return await self._call(http, ev, self._body(messages, tools, tool_choice), "/chat/completions")
@@ -658,21 +703,37 @@ class OpenAICompatClient:
658
703
  async def _call(self, http: httpx.AsyncClient, ev: StreamEvents, body: dict[str, Any], path: str, *,
659
704
  wire: str | None = None, api_base: str | None = None, api_key: str | None = None,
660
705
  record: dict[str, Any] | None = None, route: str | None = None) -> Turn:
661
- """One attempt, held to its budget (ATTEMPT_BUDGET_S) until its first output: from the first delta on, the
662
- stream's own clocks apply. An attempt that showed nothing by then is cut, typed ``deadline_exceeded``."""
663
- budget = ATTEMPT_BUDGET_S.get()
706
+ """One attempt, held to FIRST_OUTPUT_S (and its request's size) until it is answered (headers, or its first
707
+ stream event), then to ``idle_timeout`` between stream events of any kind, keepalive comments included. One
708
+ not answered in time is cut, typed ``deadline_exceeded``; one silent after, ``stream_idle``."""
709
+ # serialized once, as httpx would: its size sets the bound
710
+ raw = json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()
711
+ first_s = first_output_s(len(raw))
712
+ self._answered = False
713
+ t0 = time.monotonic()
664
714
  try:
665
- async with asyncio.timeout(budget) as self._first_due:
666
- return await self._attempt(http, ev, body, path, wire=wire, api_base=api_base, api_key=api_key,
667
- record=record, route=route)
715
+ async with asyncio.timeout_at(asyncio.get_running_loop().time() + first_s) as self._due:
716
+ return await self._attempt(http, ev, body, raw, first_s, path, wire=wire, api_base=api_base,
717
+ api_key=api_key, record=record, route=route)
668
718
  except TimeoutError:
669
- if budget is None or not self._first_due.expired():
719
+ if not self._due.expired():
670
720
  raise
671
- e = ProviderError(504, f"no output within {budget:.0f} s", type="deadline_exceeded")
672
- self._made(body, time.monotonic() - budget, api_base=api_base or self.api_base, wire=record, error=e)
721
+ e = (ProviderError(504, f"no stream event for {self.idle_timeout:.0f} s", type="stream_idle")
722
+ if self._answered else
723
+ ProviderError(504, f"no answer within {first_s:.0f} s", type="deadline_exceeded"))
724
+ p = self._partial
725
+ self._made(body, t0, api_base=api_base or self.api_base, wire=record, error=e,
726
+ response=p.response() if p is not None and not p.empty else None)
673
727
  raise e from None
674
728
 
675
- async def _attempt(self, http: httpx.AsyncClient, ev: StreamEvents, body: dict[str, Any], path: str, *,
729
+ def _alive(self) -> None:
730
+ """The attempt was answered, or its stream sent something: the idle clock starts over from now."""
731
+ self._answered = True
732
+ if self._due is not None:
733
+ self._due.reschedule(asyncio.get_running_loop().time() + self.idle_timeout)
734
+
735
+ async def _attempt(self, http: httpx.AsyncClient, ev: StreamEvents, body: dict[str, Any], raw: bytes,
736
+ first_s: float, path: str, *,
676
737
  wire: str | None = None, api_base: str | None = None, api_key: str | None = None,
677
738
  record: dict[str, Any] | None = None, route: str | None = None) -> Turn:
678
739
  """One request and its stream read to the end. ``wire`` is None through Hum's proxy or a base (chat
@@ -685,7 +746,7 @@ class OpenAICompatClient:
685
746
  self.request_id = str(uuid.uuid4()) # one per attempt: a retry is another request
686
747
  native = wire is not None
687
748
  p = self._partial = _Partial(self.author, route or self.model)
688
- async with http.stream("POST", url, json=body, headers=self._headers(self.request_id, key)) as r:
749
+ async with http.stream("POST", url, content=raw, headers=self._headers(self.request_id, key, first_s)) as r:
689
750
  def made(**kw: Any) -> None:
690
751
  self._made(body, t0, headers=r.headers, api_base=base, wire=record, **kw)
691
752
 
@@ -694,11 +755,14 @@ class OpenAICompatClient:
694
755
  e = ProviderError.from_response(r.status_code, text, r.headers)
695
756
  made(error=e)
696
757
  raise e
758
+ self._alive() # answered: from here on, the silence between events is what is bounded
697
759
  self._credits(r.headers)
698
760
  closed = [False] # the provider said the reply ended: [DONE], or a finish reason on a chunk
699
761
 
700
762
  async def data() -> Any:
701
763
  async for line in r.aiter_lines():
764
+ if line:
765
+ self._alive() # any event, a keepalive comment or one the reply does not use included
702
766
  if not line.startswith("data:"):
703
767
  continue
704
768
  raw = line[5:].strip()
@@ -735,18 +799,15 @@ class OpenAICompatClient:
735
799
  delta = ch.get("delta") or {}
736
800
  if delta.get("content"):
737
801
  p.content.append(delta["content"])
738
- self._first_due.reschedule(None) # output: the person is no longer waiting
739
802
  if ev.text:
740
803
  ev.text(delta["content"])
741
804
  rc = delta.get("reasoning_content") or delta.get("reasoning")
742
805
  if rc:
743
806
  p.reasoning.append(rc)
744
- self._first_due.reschedule(None) # output: the person is no longer waiting
745
807
  if ev.reasoning:
746
808
  ev.reasoning(rc)
747
809
  for tc in delta.get("tool_calls") or []:
748
810
  i, name, args = p.tool_delta(tc)
749
- self._first_due.reschedule(None) # output: the person is no longer waiting
750
811
  if ev.tool:
751
812
  ev.tool(i, name, args)
752
813
  except ProviderError as e:
hum/tui/cli.mjs CHANGED
@@ -89511,7 +89511,7 @@ var init_install = __esm({
89511
89511
  "src/install.js"() {
89512
89512
  init_define_HUM_CORE_SUBS();
89513
89513
  win = process.platform === "win32";
89514
- MINE = true ? "0.2.63" : "";
89514
+ MINE = true ? "0.2.64" : "";
89515
89515
  npmFix = (version = "") => `npm i -g @metaphi-ai/hum@${version || "latest"} --foreground-scripts`;
89516
89516
  NPM_FIX = npmFix("latest");
89517
89517
  humHome = (env3 = process.env) => env3.HUM_HOME || path.join(os3.homedir(), ".hum");
@@ -89866,10 +89866,10 @@ function clientEnv() {
89866
89866
  platform: process.platform,
89867
89867
  arch: process.arch,
89868
89868
  terminal: process.env.TERM_PROGRAM || (process.env.WT_SESSION ? "Windows Terminal" : process.env.TERM || ""),
89869
- tui: true ? "0.2.63" : ""
89869
+ tui: true ? "0.2.64" : ""
89870
89870
  };
89871
89871
  }
89872
- function startFailure(failures, { npm = /^(node|bun)(\.exe)?$/i.test(String(process.execPath).split(/[\\/]/).pop()), platform: platform2 = process.platform, version = true ? "0.2.63" : "" } = {}) {
89872
+ function startFailure(failures, { npm = /^(node|bun)(\.exe)?$/i.test(String(process.execPath).split(/[\\/]/).pop()), platform: platform2 = process.platform, version = true ? "0.2.64" : "" } = {}) {
89873
89873
  const refused = failures.find((f) => f.error && f.error.code !== "ENOENT");
89874
89874
  const fix = npm ? npmFix(version) : updateCommand({ kind: "native" }, platform2);
89875
89875
  if (!refused) {
@@ -91384,7 +91384,7 @@ var after = (ms, fn) => {
91384
91384
 
91385
91385
  // src/app.jsx
91386
91386
  var import_jsx_runtime3 = __toESM(require_jsx_runtime(), 1);
91387
- var MINE2 = true ? "0.2.63" : "";
91387
+ var MINE2 = true ? "0.2.64" : "";
91388
91388
  var fmtDur = (s) => s < 60 ? `${Math.max(1, Math.round(s))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
91389
91389
  var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
91390
91390
  var shortSession = (name) => (name || "").replace(/\.jsonl$/, "");
@@ -94070,7 +94070,7 @@ async function sendPending({
94070
94070
  await init_theme();
94071
94071
  var import_jsx_runtime5 = __toESM(require_jsx_runtime(), 1);
94072
94072
  import { spawnSync as spawnSync6 } from "node:child_process";
94073
- var MINE3 = true ? "0.2.63" : "0.0.0";
94073
+ var MINE3 = true ? "0.2.64" : "0.0.0";
94074
94074
  var layout = installLayout();
94075
94075
  function completeInstall() {
94076
94076
  if (layout.kind !== "npm" || !needsInstall(MINE3)) return;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hum-cli
3
- Version: 0.2.63
3
+ Version: 0.2.64
4
4
  Summary: A self-improving coding agent harness. It improves with your usage.
5
5
  License: Copyright (c) 2026 Metaphi, Inc. All rights reserved.
6
6
 
@@ -1,9 +1,9 @@
1
- hum/__init__.py,sha256=La2jgS8FEutaqUyCv3vf7b7vM-NlCabo-XlFfNEJj0A,104
1
+ hum/__init__.py,sha256=eopcky-Iu9T5iush8TghzpzdP88uA-cB9Jcfq74yEFQ,104
2
2
  hum/auth.py,sha256=Nur8ysHt1WVk7yj0ZICIDLoTebIY8g6Eix_cPp9r3T0,18821
3
3
  hum/catalog.py,sha256=6INNvyhwu69UZV80Ri9pGRc6H2eRzSsqkW08vSfMhAU,5290
4
4
  hum/cli.py,sha256=9YgOzBuoww1sOflXPZoZhKinSHadY_wcLj2a8218gNU,89486
5
- hum/client.py,sha256=B9rDzaGq2eEX4w_wk4TpQ1IR_O7TO9IoPISbX82Di4g,79465
6
- hum/core.py,sha256=TlHSFbKucqvt8dVVGGIISDkHnnheRRp9SWG1B0gYt5U,168336
5
+ hum/client.py,sha256=KDgrgEsLpH23BpnKR0qoCGfqcyul9Zyua6E-Zd9KQxk,79350
6
+ hum/core.py,sha256=ZDjofAn0mANApJxcEs2ZrJS6Nmhi0_YB-_ll7doeKzg,168290
7
7
  hum/errors.py,sha256=xnhAU0A-662IYa4kMI0zbtYfcpo24uRRAPY87BYLHs4,4858
8
8
  hum/gitcred.py,sha256=vCynyn5ZewYJZa1vx73jQNy7KCdDjx_ppiIWegSH4wM,3698
9
9
  hum/jsonui.py,sha256=5zlJn2n99qbz8xvGsfkhSI9Vkb0bBqzUz-vlBG6HD_8,5688
@@ -36,12 +36,12 @@ hum/runtime/ask.py,sha256=SgpoVuknFnDEIEc2S9u54ueAQOA8hhQZyxsQmmAsiIw,24084
36
36
  hum/runtime/background.py,sha256=sFvf-m8DQhLfekr65XlumXU0DKHlOGqj9CVfnrR8lTY,14035
37
37
  hum/runtime/channel.py,sha256=bZNaFg7yAt9pgtKLcU4Rah9k3ngMYWiRxClZARBMyJo,4378
38
38
  hum/runtime/comms.py,sha256=p03TUazJ75VpNpjLEVeDr-aKyR15n9tmAQCXhMPEKcQ,13653
39
- hum/runtime/config.py,sha256=sXbdGa9gtEF-ZOH67ObRDJGLfZeIgrP9rcsuwXZeN_A,24854
39
+ hum/runtime/config.py,sha256=oML9NShkQQHTuVKczggW2MPyhZJykqUY_KaWxpAPvD4,25000
40
40
  hum/runtime/constraints.py,sha256=9WW-Y6W4dNIcKDdtVfxbLBCNYBb4SR18jycOCpf5gPs,6404
41
41
  hum/runtime/decision.py,sha256=rUx1ibu5BfwmZkfyfOs1_9fRnOrVCZUk7zytLvarPpA,7876
42
42
  hum/runtime/dm.py,sha256=79LUVsGa7Z-wgQumGbLRmQoAbnMgC9jBJz483OaZjHs,7929
43
43
  hum/runtime/documents.py,sha256=zIHq3uNJfekF0PZcoQCCsaOZm3HXa8-ThQnQ_u-cpRM,13756
44
- hum/runtime/drivers.py,sha256=g-ZXBPyR3WAG0OEKaNHVnIllQXroJanEsNVRdFCkWKI,15277
44
+ hum/runtime/drivers.py,sha256=Z-kO1-b19NOzdOcTTVif2dV0436wv8rwg0Sfb7uOjCU,17482
45
45
  hum/runtime/evals.py,sha256=KvOYsI7Qzz2hJTD1z1l42ynxyWifdJLNFlAIA84tk9c,8881
46
46
  hum/runtime/executor.py,sha256=VOLe-AA0YB6cSaoGE2ThSpFOHE5_QdBIyysUQ-Mc0kU,14684
47
47
  hum/runtime/gateway.py,sha256=AmO060cfJZJjkmNr0EOGKDCG3pbyX21wJ_-XqtBX6S4,10155
@@ -50,7 +50,7 @@ hum/runtime/grader.py,sha256=bwgu9J6j7K4JTOqIACGdt8zm_jXX6O4uk-It0vfVK8s,5249
50
50
  hum/runtime/hooks.py,sha256=J75DSe5RVT_NDpBY0FRq6gstSDkklrCwmtC9BxRnKMQ,15967
51
51
  hum/runtime/images.py,sha256=4rZ7Sqiw-X0I2mayLA1xkNd8ZwREPf8Iq02kJJyFZX4,11594
52
52
  hum/runtime/lead.py,sha256=Q34siKxfH-EpwqIEB5iMXJNGbVFTbGV1rUVMS8dEyDQ,31037
53
- hum/runtime/llm.py,sha256=qUstGmCUiPwy-aOtrWfQ-SAHt18kSZelJqb85hjLxyY,43509
53
+ hum/runtime/llm.py,sha256=phc4fLlPOxIOdBOJG2S-9MEHaWtG82bO8A20-ivwp3Y,47446
54
54
  hum/runtime/loop.py,sha256=cv22KY6h2VU2nUllQ3_TAX3ZPNUzkdxA_81v5zJtaYY,101142
55
55
  hum/runtime/lsp.py,sha256=1-JhG_KvbfNq_FLnVmVy2NrvK7d3D-2cO8ayWkR9ybc,30319
56
56
  hum/runtime/memory.py,sha256=Z2u9boBWxjaQOOOwY50wfWoGFPPFnKcvfaqOv-O4vWU,14429
@@ -118,10 +118,10 @@ hum/skills/artifact-design/references/reports.md,sha256=5MSv7JGYJHobHXfgAclt_xsf
118
118
  hum/skills/consult-human/SKILL.md,sha256=5rMKwC5C9L4nn-ifTjQGXl9hsYbeqOCHNl0IEog_T-E,6867
119
119
  hum/skills/consult-human/references/examples.md,sha256=hAdTOiJHlv27dTraEyPz0BILqx_8gZj0FPO43b2OMvw,3490
120
120
  hum/skills/consult-human/references/hypotheses.md,sha256=a3Hj3_zdxZZS7-bpxXGu2AEgaOV5Q31iVc2VHJ2yjxQ,3263
121
- hum/tui/cli.mjs,sha256=tOLb7tXUSfk1wNwcGiOA4BbSeDVhb3_ST1y1NGiyHO8,4215247
121
+ hum/tui/cli.mjs,sha256=226j0vquDgYoX3xd0YOAI38TKxYZK0v6jQhqXl_d3Mk,4215247
122
122
  humboldt/__init__.py,sha256=EcK3Ou8qg9DLaYCtm5t2EcU-C8xZxP8yxfGBiQkuW-M,236
123
- hum_cli-0.2.63.dist-info/METADATA,sha256=Zl1JTJ4SQPDaJHGfd1JfDdvDzjIlXXtRsY_z5sYGeco,1638
124
- hum_cli-0.2.63.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
125
- hum_cli-0.2.63.dist-info/entry_points.txt,sha256=L-JBWR79zdIsMdTN5K_PSI4HeJ4kwmo1Bm5oc3lVi5A,104
126
- hum_cli-0.2.63.dist-info/licenses/LICENSE,sha256=-_Jjy2-XAujWZirw6S-ORP1kLKeVuuG8piFwhYLtt8E,742
127
- hum_cli-0.2.63.dist-info/RECORD,,
123
+ hum_cli-0.2.64.dist-info/METADATA,sha256=VJrmZ2VZcQy4PhmO1jbOZhizrkV7Ikp_Id3kNQkYhy0,1638
124
+ hum_cli-0.2.64.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
125
+ hum_cli-0.2.64.dist-info/entry_points.txt,sha256=L-JBWR79zdIsMdTN5K_PSI4HeJ4kwmo1Bm5oc3lVi5A,104
126
+ hum_cli-0.2.64.dist-info/licenses/LICENSE,sha256=-_Jjy2-XAujWZirw6S-ORP1kLKeVuuG8piFwhYLtt8E,742
127
+ hum_cli-0.2.64.dist-info/RECORD,,