mini-code-cli 0.1.1.2__py3-none-any.whl → 0.1.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mini_code_cli/__init__.py CHANGED
@@ -11,22 +11,41 @@ to ``"unknown"`` so importing the package can never fail over a version lookup.
11
11
  __all__ = ["__version__"]
12
12
 
13
13
 
14
+ def _version_from_file(path):
15
+ """Return the ``[project] version`` in ``path``.
16
+
17
+ ``tomllib`` only exists from Python 3.11, and the scan below is not a
18
+ nicety for 3.10: without it this function used to skip the file entirely on
19
+ that interpreter and report the *installed* metadata instead, so a checkout
20
+ with a stale editable install showed the wrong version in the banner (found
21
+ on the 3.10 CI leg). Both paths read the same key, and the section tracking
22
+ matters: a ``version`` key in some other table must not win.
23
+ """
24
+ try:
25
+ import tomllib # Python >= 3.11
26
+ except ImportError: # pragma: no cover - exercised on the 3.10 CI leg
27
+ tomllib = None
28
+ if tomllib is not None:
29
+ with open(path, "rb") as f:
30
+ return str(tomllib.load(f)["project"]["version"])
31
+ section = None
32
+ with open(path, encoding="utf-8") as f:
33
+ for line in f:
34
+ stripped = line.strip()
35
+ if stripped.startswith("["):
36
+ section = stripped
37
+ elif section == "[project]" and stripped.startswith("version"):
38
+ return stripped.split("=", 1)[1].strip().strip('"')
39
+ raise KeyError(f"no version in the [project] table of {path}")
40
+
41
+
14
42
  def _read_version():
15
43
  """Best-effort package version: pyproject.toml, then installed metadata."""
16
44
  try:
17
45
  from pathlib import Path
18
46
 
19
47
  pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
20
- try:
21
- import tomllib # Python >= 3.11
22
- except ImportError: # pragma: no cover - older interpreters
23
- tomllib = None
24
- if tomllib is not None:
25
- try:
26
- with open(pyproject, "rb") as f:
27
- return str(tomllib.load(f)["project"]["version"])
28
- except Exception:
29
- pass
48
+ return _version_from_file(pyproject)
30
49
  except Exception:
31
50
  pass
32
51
  try:
@@ -0,0 +1,12 @@
1
+ """Allow ``python -m mini_code_cli`` to start the agent.
2
+
3
+ The console script ``mini-code`` (see ``[project.scripts]``) is the normal entry
4
+ point; this module exists so the package also runs straight from a checkout or
5
+ an installed wheel without the script on ``PATH`` -- which is what a reader
6
+ tries first after ``pip install -e .``.
7
+ """
8
+
9
+ from .agent_loop import main
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -31,7 +31,7 @@ from . import prompts
31
31
  from . import shell_safety_utils
32
32
  from . import tools
33
33
  from . import utils
34
- from .utils import oneline
34
+ from .utils import oneline, plural
35
35
  from . import ui
36
36
  from . import subagents
37
37
  from . import skills
@@ -118,7 +118,12 @@ def _safe_int(value, default=0):
118
118
  # ---------------------------------------------------------------------------
119
119
 
120
120
  def parse_args():
121
- parser = argparse.ArgumentParser(description="Agent loop for interacting with a language model")
121
+ # ``prog`` is pinned so ``--help`` reads the same whether the agent was
122
+ # started through the ``mini-code`` script, ``python -m mini_code_cli`` or
123
+ # a test harness (without it, the usage line said ``__main__.py``).
124
+ parser = argparse.ArgumentParser(
125
+ prog="mini-code",
126
+ description="Agent loop for interacting with a language model")
122
127
  # MODEL / LLM related
123
128
  parser.add_argument("--url", type=str, default=f"{DEFAULT_MODEL_URL}", help=f"API host, default={DEFAULT_MODEL_URL}")
124
129
  parser.add_argument("--max-tokens", type=int, help="Maximum tokens for a single LLM response (the reply budget), default=None. Also used as the output headroom reserved when deriving the compaction limit from the model's context window.")
@@ -298,11 +303,6 @@ def _in_flight_tail(messages):
298
303
  return []
299
304
 
300
305
 
301
- def _plural(count, noun, suffix="s"):
302
- """``"1 message"`` / ``"4 messages"`` -- for messages the user reads."""
303
- return f"{count} {noun}" + ("" if count == 1 else suffix)
304
-
305
-
306
306
  def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
307
307
  history_index=0, token_limit=None, reserved_output_tokens=0,
308
308
  current_tokens=None, plan_hook=None, console=None):
@@ -358,12 +358,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
358
358
  # stake; a compaction note that stays silent about that is exactly what makes
359
359
  # the rollover look like it might have eaten the message.
360
360
  tail = _in_flight_tail(messages)
361
- kept_note = (f" The current turn ({_plural(len(tail), 'message')}) is kept "
361
+ kept_note = (f" The current turn ({plural(len(tail), 'message')}) is kept "
362
362
  f"verbatim." if tail else "")
363
363
 
364
364
  try:
365
365
  console.note(
366
- f"Compacting the conversation: {_plural(len(messages), 'message')} "
366
+ f"Compacting the conversation: {plural(len(messages), 'message')} "
367
367
  f"-> a summary ({reason}).{kept_note}", "warn")
368
368
  summary_prompt = prompts.compaction_prompt(messages)
369
369
 
@@ -404,7 +404,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
404
404
  # may still be too large.
405
405
  console.note(
406
406
  f"Could not compact history: the summariser returned an empty "
407
- f"summary twice. Keeping all {_plural(len(messages), 'message')} "
407
+ f"summary twice. Keeping all {plural(len(messages), 'message')} "
408
408
  f"and history_{history_index}.json as they are -- nothing was "
409
409
  f"lost, but the next request may still be too large "
410
410
  f"({reason}).", "warn")
@@ -449,7 +449,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
449
449
  # and *where it went* (which segment file was closed and which was
450
450
  # started).
451
451
  console.note(f"Summary preview: {utils.oneline(summary, 120)}", "warn")
452
- kept = (f"summary + {_plural(len(tail), 'kept message')}" if tail
452
+ kept = (f"summary + {plural(len(tail), 'kept message')}" if tail
453
453
  else "summary only")
454
454
  # Phrased as "old segment -> new segment" rather than "N messages -> M
455
455
  # messages": the replacement can be *longer* than what it replaced (a
@@ -457,9 +457,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
457
457
  # counts that sometimes grows reads like a bug.
458
458
  console.note(
459
459
  f"Compacted history_{history_index}.json "
460
- f"({_plural(len(messages), 'message')}) into a summary; "
460
+ f"({plural(len(messages), 'message')}) into a summary; "
461
461
  f"history_{history_index + 1}.json now starts with "
462
- f"{_plural(len(compacted), 'message')} ({kept}).", "warn")
462
+ f"{plural(len(compacted), 'message')} ({kept}).", "warn")
463
463
  # A new segment (history_index + 1) is started on compaction: the
464
464
  # compacted messages are its snapshot, and every later turn appends
465
465
  # to it. The caller records a single high-level 'summary' turn for
@@ -468,7 +468,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
468
468
  except Exception as e:
469
469
  console.note(
470
470
  f"Failed to compact history: {e}. Keeping all "
471
- f"{_plural(len(messages), 'message')} unchanged.", "error")
471
+ f"{plural(len(messages), 'message')} unchanged.", "error")
472
472
 
473
473
  return messages, False, history_index
474
474
 
@@ -1960,7 +1960,9 @@ class AgentSession:
1960
1960
  # just the turns. In verbose mode everything is printed permanently.
1961
1961
  transient = not self.console.verbose
1962
1962
  func = self.tools_dict[name]["function"]
1963
- result = call_function_with_timeout(func, tool_params, name, timeout_seconds=self.args.tool_timeout)
1963
+ result = call_function_with_timeout(func, tool_params, name,
1964
+ timeout_seconds=self.args.tool_timeout,
1965
+ console=self.console)
1964
1966
 
1965
1967
  self.console.tool_call_display(name, tool_params, result, transient=transient)
1966
1968
  self._append_tool_result(call_id, str(result))
@@ -2105,8 +2107,8 @@ class AgentSession:
2105
2107
  ("session id", self.unique_id),
2106
2108
  ("saved under", f"{self.cache_dir}/{self.unique_id}/"),
2107
2109
  ("history", f"{where} (segment {self.history_index}), "
2108
- f"{_plural(len(self.messages), 'message')}"),
2109
- ("conversation", _plural(turns, "recorded turn")),
2110
+ f"{plural(len(self.messages), 'message')}"),
2111
+ ("conversation", plural(turns, "recorded turn")),
2110
2112
  ("tokens", f"{count('prompt_tokens'):,} in / "
2111
2113
  f"{count('completion_tokens'):,} out "
2112
2114
  f"({count('total_tokens'):,} total)"),
@@ -2176,7 +2178,7 @@ class AgentSession:
2176
2178
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
2177
2179
  self._record_turn(
2178
2180
  f"summary: history_{new_index - 1}.json "
2179
- f"({_plural(len(closing_messages), 'message')}) compacted into a "
2181
+ f"({plural(len(closing_messages), 'message')}) compacted into a "
2180
2182
  f"summary; history_{self.history_index}.json started",
2181
2183
  self.messages)
2182
2184
 
mini_code_cli/runner.py CHANGED
@@ -18,13 +18,19 @@ class _TimedOut(Exception):
18
18
  pass
19
19
 
20
20
 
21
- def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
21
+ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT,
22
+ console=None):
22
23
  """Call ``func(**tool_params)``, aborting after ``timeout_seconds``.
23
24
 
24
25
  On timeout -- or if the tool raises *any* other exception -- a friendly
25
26
  ``Error:`` string is returned so the failure reaches the model as a tool
26
27
  result (letting it recover or retry) instead of propagating and crashing the
27
28
  whole agent loop. A timeout is reported distinctly from a generic failure.
29
+
30
+ ``console`` is the session's console. It defaults to the shared one so a
31
+ bare call still works, but the agent loop passes its own: a sub-agent's
32
+ console redirects its output, and reporting to the module-level console
33
+ would print a background child's tool errors onto the user's terminal.
28
34
  """
29
35
  def _handler(signum, frame):
30
36
  raise _TimedOut()
@@ -35,30 +41,32 @@ def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_
35
41
  # signal only works in the main thread; if we cannot arm it, just run
36
42
  # the tool without the wall-clock guard rather than failing outright --
37
43
  # but still shield the loop from a tool that raises.
38
- return _call_tool_safely(func, tool_params, name)
44
+ return _call_tool_safely(func, tool_params, name, console)
39
45
 
46
+ console = console if console is not None else ui.console
40
47
  signal.alarm(int(timeout_seconds) if timeout_seconds else 0)
41
48
  try:
42
49
  return func(**tool_params)
43
50
  except _TimedOut:
44
- ui.console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
51
+ console.note(f"Function {name} timed out after {timeout_seconds} seconds.", "error")
45
52
  return f"Error: Function {name} timed out after {timeout_seconds} seconds."
46
53
  except Exception as e: # noqa: BLE001 -- a failing tool must not crash the loop
47
- ui.console.note(f"Tool {name} raised an error: {e}", "error")
54
+ console.note(f"Tool {name} raised an error: {e}", "error")
48
55
  return f"Error: tool {name} raised an error: {e}"
49
56
  finally:
50
57
  signal.alarm(0)
51
58
  signal.signal(signal.SIGALRM, previous)
52
59
 
53
60
 
54
- def _call_tool_safely(func, tool_params, name):
61
+ def _call_tool_safely(func, tool_params, name, console=None):
55
62
  """Run a tool with no wall-clock guard (SIGALRM unavailable) but still
56
63
  shield the loop from a tool that raises.
57
64
 
58
65
  Used as the fallback when we could not arm SIGALRM (e.g. a non-main thread).
59
66
  """
67
+ console = console if console is not None else ui.console
60
68
  try:
61
69
  return func(**tool_params)
62
70
  except Exception as e: # noqa: BLE001 -- never let a tool exception propagate
63
- ui.console.note(f"Tool {name} raised an error: {e}", "error")
71
+ console.note(f"Tool {name} raised an error: {e}", "error")
64
72
  return f"Error: tool {name} raised an error: {e}"
mini_code_cli/skills.py CHANGED
@@ -294,7 +294,10 @@ def run_skill(allowed_dir, skill_name, script_name, args=None):
294
294
  argv = shlex.split(args) if args else []
295
295
  cmd = [sys.executable, script_path] + argv
296
296
  try:
297
- result = subprocess.run(cmd, capture_output=True, text=True,
297
+ # Run *inside* the skill's own directory: a script that writes or reads
298
+ # a relative path then stays in the skill folder instead of landing in
299
+ # whatever directory the agent process happens to have inherited.
300
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=base,
298
301
  timeout=DEFAULT_TOOL_TIMEOUT, check=False)
299
302
  except Exception as e:
300
303
  return f"Error: {e}"
mini_code_cli/ui.py CHANGED
@@ -29,7 +29,7 @@ import shutil
29
29
  import sys
30
30
  import threading
31
31
 
32
- from .utils import oneline
32
+ from .utils import oneline, plural
33
33
 
34
34
  # --- ANSI SGR codes ---------------------------------------------------------
35
35
  _RESET = "\033[0m"
@@ -348,12 +348,6 @@ class Console:
348
348
  styled_lines.append(text)
349
349
  self.print_box(styled_lines, title=title, fg=fg)
350
350
 
351
- def truncate(self, text, limit):
352
- text = str(text)
353
- if len(text) > limit:
354
- return text[:limit] + "... (truncated)"
355
- return text
356
-
357
351
  @staticmethod
358
352
  def _oneline(text, limit=None):
359
353
  """Collapse ``text`` to one line (delegates to :func:`utils.oneline`)."""
@@ -570,7 +564,7 @@ class Console:
570
564
  """Render one attempted tool call: its name, parameters and result."""
571
565
  self.emit(self.style(f"{self.glyph('gear')} {name}", fg="magenta", bold=True))
572
566
  for key, value in (params or {}).items():
573
- snippet = self.truncate(self._oneline(str(value), 100), 100)
567
+ snippet = self._oneline(str(value), 100)
574
568
  key_str = self.style(f" {key}:", fg="gray", dim=True) if self.color_enabled \
575
569
  else f" {key}:"
576
570
  self.emit(f"{key_str} {snippet}")
@@ -586,7 +580,7 @@ class Console:
586
580
  first = text.splitlines()[0] if text.strip() else "(no output)"
587
581
  arrow = self.style(self.glyph("arrow"), fg=color) if self.color_enabled \
588
582
  else self.glyph("arrow")
589
- self.emit(f" {arrow} {self.truncate(first, 300)}")
583
+ self.emit(f" {arrow} {self._oneline(first, 300)}")
590
584
 
591
585
  def tool_call_display(self, name, params=None, result=None, transient=True):
592
586
  """Render one attempted tool call.
@@ -624,7 +618,7 @@ class Console:
624
618
  f"total={usage.get('total_tokens', 0)}")
625
619
  if llm_calls is not None:
626
620
  calls = int(llm_calls)
627
- text += f" | {calls} LLM call{'' if calls == 1 else 's'}"
621
+ text += f" | {plural(calls, 'LLM call')}"
628
622
  label = self.style("tokens", fg="cyan", dim=True) if self.color_enabled else "tokens"
629
623
  return f"{label}: {text}"
630
624
 
@@ -683,7 +677,7 @@ class Console:
683
677
  self.emit(self.style(header, bold=True, fg="cyan"))
684
678
  for turn, what, where in rows:
685
679
  turn_cell = self.style(turn.rjust(turn_w), fg="magenta", bold=True) if self.color_enabled else turn.rjust(turn_w)
686
- what_cell = self.style(self.truncate(what, what_w), dim=True) if self.color_enabled else what.ljust(what_w)
680
+ what_cell = self.style(self._oneline(what, what_w), dim=True) if self.color_enabled else what.ljust(what_w)
687
681
  where_cell = self.style(where, fg="gray", dim=True) if self.color_enabled else where
688
682
  self.emit(f"{turn_cell} {what_cell} {where_cell}")
689
683
  self.emit(self.style("(commands: /history <n> to inspect, /reset <n> to rewind)", dim=True, fg="cyan"))
mini_code_cli/utils.py CHANGED
@@ -1,6 +1,5 @@
1
1
  import requests
2
2
  import json
3
- import sys
4
3
 
5
4
 
6
5
  def oneline(text, limit=None):
@@ -23,6 +22,17 @@ def oneline(text, limit=None):
23
22
  return one
24
23
 
25
24
 
25
+ def plural(count, noun, suffix="s"):
26
+ """``"1 message"`` / ``"4 messages"`` -- for text a human reads.
27
+
28
+ Lives here, next to :func:`oneline`, because both the agent loop (compaction
29
+ notes, the good-bye box) and the console (usage lines) build user-facing
30
+ counts; three inline ``'' if n == 1 else 's'`` expressions had already
31
+ drifted before this was factored out.
32
+ """
33
+ return f"{count} {noun}" + ("" if count == 1 else suffix)
34
+
35
+
26
36
  def _coerce_usage_int(value):
27
37
  """Best-effort: coerce a raw usage field to a non-negative int.
28
38
 
@@ -131,11 +141,14 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
131
141
  if api_key:
132
142
  headers["Authorization"] = f"Bearer {api_key}"
133
143
 
144
+ # Note: the request timeout is a *transport* concern and is passed to
145
+ # ``requests.post(timeout=...)`` below, not put in the JSON body -- it is
146
+ # not a field of the chat-completions API, and strict gateways reject
147
+ # unexpected keys.
134
148
  payload = {
135
149
  "model": model,
136
150
  "messages": messages,
137
151
  "temperature": temperature,
138
- "timeout": timeout,
139
152
  }
140
153
 
141
154
  if max_tokens is not None:
@@ -159,7 +172,10 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
159
172
  )
160
173
  response.raise_for_status()
161
174
 
162
- result = response.json().get("choices", [{}])[0].get("message", {})
175
+ # Parse the body once: a second ``response.json()`` call re-decodes it
176
+ # and can raise on a server that sends a slightly different body.
177
+ body = response.json()
178
+ result = body.get("choices", [{}])[0].get("message", {})
163
179
  content = result.get("content", "")
164
180
  tool_calls = result.get("tool_calls", [])
165
181
  # Thinking-mode servers return the chain of thought next to ``content``.
@@ -172,7 +188,7 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
172
188
  reasoning_content = result.get("reasoning_content")
173
189
 
174
190
  # Token usage, when the server reports it (many local servers do not).
175
- usage = response.json().get("usage", {}) or {}
191
+ usage = body.get("usage", {}) or {}
176
192
  prompt_tokens = _coerce_usage_int(usage.get("prompt_tokens", 0))
177
193
  completion_tokens = _coerce_usage_int(usage.get("completion_tokens", 0))
178
194
  total_tokens = _coerce_usage_int(
@@ -187,28 +203,51 @@ def call_openai_server(messages, max_tokens=2048, temperature=0.0, model="defaul
187
203
  }, reasoning_content
188
204
 
189
205
  except requests.exceptions.RequestException as e:
190
- print(f"HTTP Error: {e}", file=sys.stderr)
191
- # Only probe the response further if we actually got one back. A
192
- # request-level failure (timeout, connection refused, DNS) leaves
193
- # ``response`` as None, and there is nothing to inspect then.
194
- if response is not None:
195
- try:
196
- # Print the status code / raw body to see the detailed error.
197
- print(f"Status Code: {response.status_code}", file=sys.stderr)
198
- print(f"Response Body: {response.text}", file=sys.stderr)
199
- # If the response is JSON, parse and pretty-print it.
200
- try:
201
- error_details = response.json()
202
- print("Parsed Error Details:", file=sys.stderr)
203
- print(json.dumps(error_details, indent=2), file=sys.stderr)
204
- except json.JSONDecodeError:
205
- print("Response body is not valid JSON.", file=sys.stderr)
206
- except Exception as e:
207
- print(f"Problem with (error) response: {e}", file=sys.stderr)
206
+ _report_request_failure(e, response)
208
207
  return None, None, None, None
209
208
  except (json.JSONDecodeError, KeyError, IndexError) as e:
210
- print(f"Error processing response: {e}", file=sys.stderr)
209
+ _report(f"Error processing the model's response: {e}")
211
210
  return None, None, None, None
212
211
  except Exception as e: # noqa: BLE001 -- a network/parse hiccup must not crash the loop
213
- print(f"Unexpected error calling OpenAI server: {e}", file=sys.stderr)
212
+ _report(f"Unexpected error calling the model server: {e}")
214
213
  return None, None, None, None
214
+
215
+
216
+ def _report(message, level="error"):
217
+ """Report a request failure through the console, never straight to stderr.
218
+
219
+ ``ui`` imports :func:`oneline` from this module, so the console is imported
220
+ lazily here to keep the import graph acyclic. A single note -- rather than a
221
+ multi-line ``print`` -- keeps the transient region intact: this can fire
222
+ while the "… thinking" spinner owns the bottom line, and writing multi-line
223
+ text underneath it left the screen mangled.
224
+ """
225
+ from . import ui # local: ui imports oneline from here
226
+ ui.console.note(oneline(message, 300), level)
227
+
228
+
229
+ def _report_request_failure(error, response):
230
+ """Explain an HTTP failure, folding the server's own words into one line.
231
+
232
+ Only the response is inspected more closely when there *is* one: a
233
+ request-level failure (timeout, refused connection, DNS) leaves ``response``
234
+ as None, and there is nothing to inspect then. The body is the useful part
235
+ (servers put the real reason there), so it is kept -- collapsed and
236
+ truncated -- instead of dumping the whole payload into the terminal.
237
+ """
238
+ if response is None:
239
+ _report(f"HTTP error talking to the model server: {error}")
240
+ return
241
+ status = getattr(response, "status_code", "?")
242
+ detail = ""
243
+ try:
244
+ body = response.text or ""
245
+ try:
246
+ body = json.dumps(response.json())
247
+ except json.JSONDecodeError:
248
+ pass # not JSON: the raw body is the best we have
249
+ if body.strip():
250
+ detail = f": {oneline(body, 200)}"
251
+ except Exception as e: # a broken response must not break the report
252
+ detail = f" (and the error body was unreadable: {e})"
253
+ _report(f"HTTP {status} from the model server: {error}{detail}")