minacode 0.12.0__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.
- minacode/__init__.py +21 -0
- minacode/__main__.py +66 -0
- minacode/base.py +567 -0
- minacode/engine.py +1734 -0
- minacode/mcp.py +1252 -0
- minacode/session.py +1077 -0
- minacode/skill.py +104 -0
- minacode/tools.py +2127 -0
- minacode/tui.py +3335 -0
- minacode-0.12.0.dist-info/METADATA +115 -0
- minacode-0.12.0.dist-info/RECORD +15 -0
- minacode-0.12.0.dist-info/WHEEL +5 -0
- minacode-0.12.0.dist-info/entry_points.txt +2 -0
- minacode-0.12.0.dist-info/licenses/LICENSE +28 -0
- minacode-0.12.0.dist-info/top_level.txt +1 -0
minacode/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""minacode: A small terminal coding agent written in Python.
|
|
2
|
+
|
|
3
|
+
The implementation lives in focused submodules (``base``, ``session``,
|
|
4
|
+
``skill``, ``mcp``, ``tools``, ``engine``, ``tui``) plus a ``__main__`` entry
|
|
5
|
+
point. The public names are
|
|
6
|
+
re-exported here so ``import minacode`` keeps exposing the same namespace the
|
|
7
|
+
single-file module used to provide.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from minacode.tui import *
|
|
11
|
+
from minacode.base import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
# Lazily expose the entry point so importing minacode (and running `python -m minacode`)
|
|
16
|
+
# does not eagerly import __main__, which would raise a duplicate-module RuntimeWarning.
|
|
17
|
+
if name == "main":
|
|
18
|
+
from minacode.__main__ import main
|
|
19
|
+
|
|
20
|
+
return main
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
minacode/__main__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""minacode entry point: command-line argument parsing and dispatch.
|
|
2
|
+
|
|
3
|
+
Invoked through the ``minacode`` console script or ``python -m minacode``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from minacode.tui import *
|
|
9
|
+
from minacode.base import __version__
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
if sys.platform == "win32":
|
|
15
|
+
print("Error: minacode does not support native Windows; use WSL instead.", file=sys.stderr)
|
|
16
|
+
return 1
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(prog="minacode")
|
|
19
|
+
parser.add_argument("--config", default=None, help="Path to config TOML")
|
|
20
|
+
parser.add_argument("--init-config", action="store_true", help="Create a default config file")
|
|
21
|
+
parser.add_argument("--yolo", action="store_true", help="Skip confirmations for mutating tools")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--theme", choices=["auto", "light", "dark"], default="", help="Color theme (defaults to runtime.theme, then auto-detect via COLORFGBG)"
|
|
24
|
+
)
|
|
25
|
+
resume = parser.add_mutually_exclusive_group()
|
|
26
|
+
resume.add_argument("--resume", default="", nargs="?", const="latest", help='Resume a session by UID, or "latest"/"last" for this project\'s most recent')
|
|
27
|
+
resume.add_argument("-c", "--last", "--latest", dest="continue_project", action="store_true", help="Resume the latest session in the current project")
|
|
28
|
+
parser.add_argument("-v", "--version", action="store_true", help="Show version")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
if args.version:
|
|
31
|
+
print(__version__)
|
|
32
|
+
return 0
|
|
33
|
+
try:
|
|
34
|
+
if args.init_config:
|
|
35
|
+
path, created = ConfigFile.init(args.config)
|
|
36
|
+
print(("Created" if created else "Exists") + " config: " + path)
|
|
37
|
+
return 0
|
|
38
|
+
if args.resume or args.continue_project:
|
|
39
|
+
data = ConfigFile.load(args.config)
|
|
40
|
+
config = Config.from_dict(data)
|
|
41
|
+
session = Session.load_snapshot(
|
|
42
|
+
args.resume or "latest",
|
|
43
|
+
config=config,
|
|
44
|
+
settings=RuntimeSettings.from_dict(data, yolo=args.yolo, theme=args.theme),
|
|
45
|
+
cwd=os.getcwd(),
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
session = Session.from_config_file(path=args.config, yolo=args.yolo, theme=args.theme)
|
|
49
|
+
Theme.set_mode(Theme.resolve(session.settings.theme))
|
|
50
|
+
command_loop = CommandLoop(Agent(session))
|
|
51
|
+
try:
|
|
52
|
+
return command_loop.run()
|
|
53
|
+
finally:
|
|
54
|
+
command_loop.close_background_output()
|
|
55
|
+
if session.mcp is not None:
|
|
56
|
+
session.mcp.close()
|
|
57
|
+
except ConfigError as error:
|
|
58
|
+
print("ConfigError: " + str(error), file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
except MinacodeError as error:
|
|
61
|
+
print("Error: " + str(error), file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(main())
|
minacode/base.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""minacode base: errors, text helpers, configuration, and shared data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import tomllib
|
|
13
|
+
import concurrent.futures
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import auto
|
|
16
|
+
from typing import Any, ClassVar
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import anthropic
|
|
20
|
+
import openai
|
|
21
|
+
from openai import OpenAI
|
|
22
|
+
from prompt_toolkit.utils import get_cwidth
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import pygments
|
|
26
|
+
from pygments.token import Token
|
|
27
|
+
except ImportError: # pragma: no cover - optional highlighting dependency
|
|
28
|
+
pygments = None
|
|
29
|
+
Token = None # keep the name defined so class-body/token lookups don't NameError
|
|
30
|
+
|
|
31
|
+
__version__ = "0.12.0"
|
|
32
|
+
|
|
33
|
+
Json = dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
HTTP_USER_AGENT = "minacode/" + __version__
|
|
37
|
+
logging.getLogger("fastmcp.client.auth.oauth").setLevel(logging.WARNING)
|
|
38
|
+
# Refresh failures / re-auth fall back to minacode's own handling, which surfaces an
|
|
39
|
+
# actionable "authentication required" message; suppress this logger's ERROR-level
|
|
40
|
+
# traceback spam (incl. the RuntimeError minacode raises as control flow).
|
|
41
|
+
logging.getLogger("mcp.client.auth.oauth2").setLevel(logging.CRITICAL)
|
|
42
|
+
DEFAULT_MAX_CONTEXT_TOKENS = 240 * 1024
|
|
43
|
+
MAX_TOOL_OUTPUT_TOKENS = 6_000
|
|
44
|
+
MODEL_REQUEST_RETRIES = 2
|
|
45
|
+
PROVIDER_API_CHOICES = ("auto", "chat", "anthropic")
|
|
46
|
+
REASONING_LEVELS = ("minimal", "low", "medium", "high", "xhigh")
|
|
47
|
+
REASONING_CHOICES = ("off", *REASONING_LEVELS)
|
|
48
|
+
CHAT_REASONING_CHOICES = ("auto", "off", "reasoning", "reasoning_effort", "thinking", "enable_thinking")
|
|
49
|
+
ANTHROPIC_DEFAULT_MAX_TOKENS = 16_384
|
|
50
|
+
DEEPSEEK_DEFAULT_MAX_TOKENS = 32_768
|
|
51
|
+
DEFAULT_OUTPUT_RESERVE_TOKENS = ANTHROPIC_DEFAULT_MAX_TOKENS
|
|
52
|
+
MIN_CONTEXT_SAFETY_TOKENS = 4_096
|
|
53
|
+
CHAT_REASONING_EFFORT_VALUES: dict[str, dict[str, str | int]] = {
|
|
54
|
+
"thinking": {"minimal": "high", "low": "high", "medium": "high", "high": "max", "xhigh": "max"},
|
|
55
|
+
"enable_thinking": {"minimal": 256, "low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384},
|
|
56
|
+
}
|
|
57
|
+
SELECTION_BACK = object()
|
|
58
|
+
SELECTION_FREE_TEXT = object()
|
|
59
|
+
DISMISSED = "(The user dismissed the question without answering.)"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MinacodeError(Exception): ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigError(MinacodeError): ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ModelError(MinacodeError): ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ModelRequestRetry(MinacodeError): ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ToolError(MinacodeError): ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Text:
|
|
78
|
+
BASE36: ClassVar[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def clean(text: str) -> str:
|
|
82
|
+
return text.encode("utf-8", errors="replace").decode("utf-8")
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def base36(cls, value: int) -> str:
|
|
86
|
+
out = ""
|
|
87
|
+
while value:
|
|
88
|
+
value, digit = divmod(value, 36)
|
|
89
|
+
out = cls.BASE36[digit] + out
|
|
90
|
+
return out or "0"
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def value(cls, value: Any) -> Any:
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return cls.clean(value)
|
|
96
|
+
if isinstance(value, dict):
|
|
97
|
+
return {cls.clean(str(key)): cls.value(item) for key, item in value.items()}
|
|
98
|
+
if isinstance(value, (list, tuple)):
|
|
99
|
+
return [cls.value(item) for item in value]
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def elapsed_since(started_at: float, *, precise: bool = False) -> str:
|
|
104
|
+
raw = max(0.0, time.monotonic() - started_at) if started_at else 0.0
|
|
105
|
+
if raw < 60:
|
|
106
|
+
return f"{raw:.1f}s" if precise else f"{int(raw)}s"
|
|
107
|
+
minutes, seconds = divmod(int(raw), 60)
|
|
108
|
+
return f"{minutes}m{seconds:02d}s"
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def clip_width(text: str, width: int) -> str:
|
|
112
|
+
width = max(0, width)
|
|
113
|
+
if get_cwidth(text) <= width:
|
|
114
|
+
return text
|
|
115
|
+
ellipsis = "." * min(3, width)
|
|
116
|
+
available = width - get_cwidth(ellipsis)
|
|
117
|
+
clipped = []
|
|
118
|
+
used = 0
|
|
119
|
+
for char in text:
|
|
120
|
+
char_width = max(0, get_cwidth(char))
|
|
121
|
+
if used + char_width > available:
|
|
122
|
+
break
|
|
123
|
+
clipped.append(char)
|
|
124
|
+
used += char_width
|
|
125
|
+
return "".join(clipped).rstrip() + ellipsis
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def wrap_styled(
|
|
129
|
+
prefix: list[tuple[str, str]],
|
|
130
|
+
continuation: list[tuple[str, str]],
|
|
131
|
+
content: list[tuple[str, str]],
|
|
132
|
+
width: int | None = None,
|
|
133
|
+
) -> list[list[tuple[str, str]]]:
|
|
134
|
+
logical_lines: list[list[tuple[str, str, int]]] = [[]]
|
|
135
|
+
for style, text in content:
|
|
136
|
+
for char in text:
|
|
137
|
+
if char == "\n":
|
|
138
|
+
logical_lines.append([])
|
|
139
|
+
else:
|
|
140
|
+
logical_lines[-1].append((style, char, get_cwidth(char)))
|
|
141
|
+
|
|
142
|
+
def row_segments(row_prefix: list[tuple[str, str]], cells: list[tuple[str, str, int]]) -> list[tuple[str, str]]:
|
|
143
|
+
row = list(row_prefix)
|
|
144
|
+
for style, char, _char_width in cells:
|
|
145
|
+
if row and row[-1][0] == style:
|
|
146
|
+
row[-1] = (style, row[-1][1] + char)
|
|
147
|
+
else:
|
|
148
|
+
row.append((style, char))
|
|
149
|
+
return row
|
|
150
|
+
|
|
151
|
+
rows: list[list[tuple[str, str]]] = []
|
|
152
|
+
row_prefix = prefix
|
|
153
|
+
for logical in logical_lines:
|
|
154
|
+
remaining = logical
|
|
155
|
+
while True:
|
|
156
|
+
prefix_width = sum(get_cwidth(text) for _style, text in row_prefix)
|
|
157
|
+
available = max(1, width - prefix_width) if width else None
|
|
158
|
+
if available is None or sum(cell_width for _style, _char, cell_width in remaining) <= available:
|
|
159
|
+
rows.append(row_segments(row_prefix, remaining))
|
|
160
|
+
break
|
|
161
|
+
used = 0
|
|
162
|
+
fit = 0
|
|
163
|
+
while fit < len(remaining) and used + remaining[fit][2] <= available:
|
|
164
|
+
used += remaining[fit][2]
|
|
165
|
+
fit += 1
|
|
166
|
+
fit = max(1, fit)
|
|
167
|
+
whitespace = max((index for index in range(fit) if remaining[index][1].isspace()), default=-1)
|
|
168
|
+
cut = whitespace if whitespace > 0 else fit
|
|
169
|
+
rows.append(row_segments(row_prefix, remaining[:cut]))
|
|
170
|
+
remaining = remaining[cut + 1 :] if whitespace > 0 else remaining[cut:]
|
|
171
|
+
row_prefix = continuation
|
|
172
|
+
row_prefix = continuation
|
|
173
|
+
return rows
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class ProviderConfig:
|
|
178
|
+
# fmt: off
|
|
179
|
+
PROFILES: ClassVar[dict[str, dict[str, Any]]] = {
|
|
180
|
+
"api.openai.com": {"chat_reasoning_rules": (("reasoning_effort", ("o1", "o3", "o4", "gpt-5")),), "strict_tools": True},
|
|
181
|
+
"openrouter.ai": {"chat_reasoning": "reasoning"},
|
|
182
|
+
"opencode.ai": {"api_rules": (("anthropic", ("claude-", "qwen3.")),), "chat_reasoning_rules": (("reasoning", ("deepseek-v4",)),)},
|
|
183
|
+
"api.deepseek.com": {"chat_reasoning": "thinking", "max_tokens": DEEPSEEK_DEFAULT_MAX_TOKENS, "prompt_cache_key": False, "strict_tools": True, "strict_beta": True},
|
|
184
|
+
}
|
|
185
|
+
# fmt: on
|
|
186
|
+
|
|
187
|
+
url: str = ""
|
|
188
|
+
key: str = ""
|
|
189
|
+
model: str = ""
|
|
190
|
+
api: str = "auto"
|
|
191
|
+
prompt_cache_key: str = "auto"
|
|
192
|
+
available_models: tuple[str, ...] = ()
|
|
193
|
+
temperature: float | None = None
|
|
194
|
+
max_tokens: int = 0
|
|
195
|
+
strict_tools: bool = False
|
|
196
|
+
reasoning: str = "medium"
|
|
197
|
+
chat_reasoning: str = "auto"
|
|
198
|
+
timeout: int = 180
|
|
199
|
+
extra_body: Json = field(default_factory=dict)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, data: Json) -> "ProviderConfig":
|
|
203
|
+
api = Config.str(data, "api", "auto")
|
|
204
|
+
prompt_cache_key = cls.clean_prompt_cache_key(Config.str(data, "prompt_cache_key", "auto"))
|
|
205
|
+
reasoning = Config.str(data, "reasoning", "medium")
|
|
206
|
+
chat_reasoning = Config.str(data, "chat_reasoning", "auto")
|
|
207
|
+
for key, value, choices in (
|
|
208
|
+
("api", api, PROVIDER_API_CHOICES),
|
|
209
|
+
("reasoning", reasoning, REASONING_CHOICES),
|
|
210
|
+
("chat_reasoning", chat_reasoning, CHAT_REASONING_CHOICES),
|
|
211
|
+
):
|
|
212
|
+
if value not in choices:
|
|
213
|
+
raise ConfigError("provider." + key + " must be one of " + ", ".join(choices))
|
|
214
|
+
return cls(
|
|
215
|
+
url=Config.str(data, "url"),
|
|
216
|
+
key=Config.str(data, "key"),
|
|
217
|
+
model=Config.str(data, "model"),
|
|
218
|
+
api=api,
|
|
219
|
+
prompt_cache_key=prompt_cache_key,
|
|
220
|
+
available_models=Config.str_tuple(data, "available_models"),
|
|
221
|
+
temperature=Config.float(data, "temperature", None),
|
|
222
|
+
max_tokens=max(0, Config.int(data, "max_tokens", 0)),
|
|
223
|
+
strict_tools=Config.bool(data, "strict_tools", False),
|
|
224
|
+
reasoning=reasoning,
|
|
225
|
+
chat_reasoning=chat_reasoning,
|
|
226
|
+
timeout=Config.int(data, "timeout", 180),
|
|
227
|
+
extra_body=Config.table(data, "extra_body"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _stripped_url(self) -> str:
|
|
231
|
+
url = self.url.rstrip("/")
|
|
232
|
+
return url.removesuffix("/chat/completions").removesuffix("/responses").removesuffix("/messages")
|
|
233
|
+
|
|
234
|
+
def base_url(self) -> str:
|
|
235
|
+
# Strict tool calling is a beta feature on some hosts (DeepSeek); route to /beta only when active.
|
|
236
|
+
url = self._stripped_url()
|
|
237
|
+
return url + "/beta" if self.resolved_strict_tools() and self._profile().get("strict_beta") and not url.endswith("/beta") else url
|
|
238
|
+
|
|
239
|
+
def host(self) -> str:
|
|
240
|
+
return (urlparse(self._stripped_url()).hostname or "").lower()
|
|
241
|
+
|
|
242
|
+
def _profile(self) -> Json:
|
|
243
|
+
return self.PROFILES.get(self.host()) or {}
|
|
244
|
+
|
|
245
|
+
def resolved_chat_reasoning(self) -> str:
|
|
246
|
+
return self.profile_value(self.chat_reasoning, "off", "chat_reasoning", "chat_reasoning_rules")
|
|
247
|
+
|
|
248
|
+
def resolved_api(self) -> str:
|
|
249
|
+
return self.profile_value(self.api, "chat", "api", "api_rules")
|
|
250
|
+
|
|
251
|
+
def profile_value(self, configured: str, default: str, profile_attr: str, rules_attr: str) -> str:
|
|
252
|
+
if configured != "auto":
|
|
253
|
+
return configured
|
|
254
|
+
if not (profile := self._profile()):
|
|
255
|
+
return default
|
|
256
|
+
model = self.model.lower()
|
|
257
|
+
for value, prefixes in profile.get(rules_attr, ()):
|
|
258
|
+
if any(model.startswith(prefix) for prefix in prefixes):
|
|
259
|
+
return str(value)
|
|
260
|
+
return str(profile.get(profile_attr, default))
|
|
261
|
+
|
|
262
|
+
def reasoning_effort(self) -> str:
|
|
263
|
+
return self.reasoning if self.reasoning in REASONING_LEVELS else "medium"
|
|
264
|
+
|
|
265
|
+
def resolved_max_tokens(self) -> int:
|
|
266
|
+
# Generic OpenAI-compatible providers keep their own server-side cap; only opted-in profiles get a ceiling.
|
|
267
|
+
return self.max_tokens or int(self._profile().get("max_tokens", 0))
|
|
268
|
+
|
|
269
|
+
def output_token_budget(self) -> int:
|
|
270
|
+
return self.resolved_max_tokens() or DEFAULT_OUTPUT_RESERVE_TOKENS
|
|
271
|
+
|
|
272
|
+
def supports_prompt_cache_key(self) -> bool:
|
|
273
|
+
# Default on for unknown OpenAI-compatible hosts (status quo); profiles opt out
|
|
274
|
+
# (e.g. DeepSeek caches automatically by prefix and ignores the key).
|
|
275
|
+
return bool(self._profile().get("prompt_cache_key", True))
|
|
276
|
+
|
|
277
|
+
def supports_strict_tools(self) -> bool:
|
|
278
|
+
return bool(self._profile().get("strict_tools"))
|
|
279
|
+
|
|
280
|
+
def resolved_strict_tools(self) -> bool:
|
|
281
|
+
# Only emit strict schemas on the chat path of a host known to support strict mode.
|
|
282
|
+
return self.strict_tools and self.supports_strict_tools() and self.resolved_api() == "chat"
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def clean_prompt_cache_key(value: str) -> str:
|
|
286
|
+
value = value.strip()
|
|
287
|
+
if not value:
|
|
288
|
+
return "auto"
|
|
289
|
+
lower = value.lower()
|
|
290
|
+
if lower in {"auto", "off"}:
|
|
291
|
+
return lower
|
|
292
|
+
if len(value) > 64 or any(char.isspace() for char in value):
|
|
293
|
+
raise ConfigError("provider.prompt_cache_key must be auto, off, or a stable key up to 64 chars without whitespace")
|
|
294
|
+
return value
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass
|
|
298
|
+
class RuntimeSettings:
|
|
299
|
+
shell_timeout: int = 60
|
|
300
|
+
# Bash foreground wait budget: if the command hasn't exited within this many seconds the running
|
|
301
|
+
# process is promoted to a background job (see BashTool.stream_process) and control returns to
|
|
302
|
+
# the model with a partial-output payload. Set to 0 to disable promotion (fall back to killing
|
|
303
|
+
# on shell_timeout).
|
|
304
|
+
bash_wait_timeout: int = 10
|
|
305
|
+
max_steps: int = 200
|
|
306
|
+
max_context_tokens: int = DEFAULT_MAX_CONTEXT_TOKENS
|
|
307
|
+
session_retention_days: int = 7
|
|
308
|
+
# Max read-only tool calls from one model batch to execute concurrently; 1 disables parallelism.
|
|
309
|
+
max_parallel_tools: int = 4
|
|
310
|
+
yolo: bool = False
|
|
311
|
+
theme: str = "auto"
|
|
312
|
+
|
|
313
|
+
@classmethod
|
|
314
|
+
def from_dict(cls, data: Json, *, yolo: bool = False, theme: str = "") -> "RuntimeSettings":
|
|
315
|
+
runtime = Config.table(data, "runtime")
|
|
316
|
+
return cls(
|
|
317
|
+
shell_timeout=Config.int(runtime, "shell_timeout", 60),
|
|
318
|
+
bash_wait_timeout=max(0, Config.int(runtime, "bash_wait_timeout", 10)),
|
|
319
|
+
max_steps=max(1, Config.int(runtime, "max_agent_steps", 200)),
|
|
320
|
+
max_context_tokens=max(1, Config.int(runtime, "max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)),
|
|
321
|
+
max_parallel_tools=max(1, Config.int(runtime, "max_parallel_tools", 4)),
|
|
322
|
+
session_retention_days=max(0, Config.int(runtime, "session_retention_days", 7)),
|
|
323
|
+
yolo=yolo or Config.bool(runtime, "yolo", False),
|
|
324
|
+
theme=theme or Config.str(runtime, "theme", "auto"),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass
|
|
329
|
+
class Config:
|
|
330
|
+
active_provider: str = "default"
|
|
331
|
+
providers: dict[str, ProviderConfig] = field(default_factory=lambda: {"default": ProviderConfig()})
|
|
332
|
+
data_dir: str = "~/.minacode"
|
|
333
|
+
mcp: Json = field(default_factory=dict)
|
|
334
|
+
|
|
335
|
+
# Backward compatibility: the data dir moved from ~/.nanocode to ~/.minacode.
|
|
336
|
+
LEGACY_DATA_DIR: ClassVar[str] = "~/.nanocode"
|
|
337
|
+
|
|
338
|
+
def __post_init__(self) -> None:
|
|
339
|
+
# When the data dir is still the new default but does not exist yet and the legacy
|
|
340
|
+
# ~/.nanocode dir does, keep using the legacy dir so existing sessions, skills, and
|
|
341
|
+
# cache are found without a migration step.
|
|
342
|
+
if (
|
|
343
|
+
self.data_dir == "~/.minacode"
|
|
344
|
+
and not os.path.exists(os.path.expanduser(self.data_dir))
|
|
345
|
+
and os.path.exists(os.path.expanduser(self.LEGACY_DATA_DIR))
|
|
346
|
+
):
|
|
347
|
+
self.data_dir = self.LEGACY_DATA_DIR
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def provider(self) -> ProviderConfig:
|
|
351
|
+
return self.providers[self.active_provider]
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def from_dict(cls, data: Json) -> "Config":
|
|
355
|
+
provider_root = cls.table(data, "provider")
|
|
356
|
+
active = cls.str(provider_root, "active", "default")
|
|
357
|
+
providers = {name: ProviderConfig.from_dict(value) for name, value in provider_root.items() if name != "active" and isinstance(value, dict)}
|
|
358
|
+
if not providers:
|
|
359
|
+
providers = {active: ProviderConfig.from_dict(provider_root)}
|
|
360
|
+
if active not in providers:
|
|
361
|
+
raise ConfigError(f"provider.active `{active}` does not exist")
|
|
362
|
+
paths = cls.table(data, "paths")
|
|
363
|
+
return cls(active_provider=active, providers=providers, data_dir=cls.str(paths, "data_dir", "~/.minacode"), mcp=cls.table(data, "mcp"))
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def table(data: Json, key: str) -> Json:
|
|
367
|
+
return value if isinstance((value := data.get(key)), dict) else {}
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def str(data: Json, key: str, default: str = "") -> str:
|
|
371
|
+
return default if (value := data.get(key)) is None else str(value)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def str_tuple(data: Json, key: str) -> tuple[str, ...]:
|
|
375
|
+
value = data.get(key)
|
|
376
|
+
if value is None:
|
|
377
|
+
return ()
|
|
378
|
+
if isinstance(value, str):
|
|
379
|
+
return tuple(item.strip() for item in value.split(",") if item.strip())
|
|
380
|
+
if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
|
|
381
|
+
return tuple(value)
|
|
382
|
+
raise ConfigError(f"config value `{key}` must be a string list")
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def bool(data: Json, key: str, default: bool = False) -> bool:
|
|
386
|
+
value = data.get(key)
|
|
387
|
+
if value is None:
|
|
388
|
+
return default
|
|
389
|
+
if isinstance(value, bool):
|
|
390
|
+
return value
|
|
391
|
+
lower = value.lower() if isinstance(value, str) else ""
|
|
392
|
+
if lower in {"on", "true", "yes", "1", "off", "false", "no", "0"}:
|
|
393
|
+
return lower in {"on", "true", "yes", "1"}
|
|
394
|
+
raise ConfigError(f"config value `{key}` must be boolean")
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def int(data: Json, key: str, default: int) -> int:
|
|
398
|
+
value = data.get(key)
|
|
399
|
+
if value is None:
|
|
400
|
+
return default
|
|
401
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
402
|
+
raise ConfigError(f"config value `{key}` must be integer")
|
|
403
|
+
return value
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def float(data: Json, key: str, default: float | None) -> float | None:
|
|
407
|
+
value = data.get(key)
|
|
408
|
+
if value is None:
|
|
409
|
+
return default
|
|
410
|
+
if value is False or (isinstance(value, str) and value.lower() == "off"):
|
|
411
|
+
return None
|
|
412
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
413
|
+
raise ConfigError(f"config value `{key}` must be number or off")
|
|
414
|
+
return float(value)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class ConfigFile:
|
|
418
|
+
DEFAULT_PATH: ClassVar[str] = os.path.join(os.path.expanduser("~"), ".minacode", "config.toml")
|
|
419
|
+
LEGACY_PATH: ClassVar[str] = os.path.join(os.path.expanduser("~"), ".nanocode", "config.toml")
|
|
420
|
+
# Only the provider block is required; every other key falls back to its built-in default, so the
|
|
421
|
+
# commented lines below just document the common knobs and their defaults.
|
|
422
|
+
DEFAULT_TEXT: ClassVar[str] = """# minacode configuration — unset keys use built-in defaults.
|
|
423
|
+
|
|
424
|
+
[provider]
|
|
425
|
+
active = "default"
|
|
426
|
+
|
|
427
|
+
[provider.default]
|
|
428
|
+
url = ""
|
|
429
|
+
key = ""
|
|
430
|
+
model = ""
|
|
431
|
+
# api = "auto" # auto | anthropic | openai | ...
|
|
432
|
+
# reasoning = "medium"
|
|
433
|
+
# timeout = 180
|
|
434
|
+
# available_models = ["gpt-5", "gpt-5-mini"]
|
|
435
|
+
|
|
436
|
+
# [runtime] # optional overrides (defaults shown)
|
|
437
|
+
# yolo = false
|
|
438
|
+
# max_context_tokens = 245760 # 240K
|
|
439
|
+
# max_agent_steps = 200
|
|
440
|
+
# shell_timeout = 60
|
|
441
|
+
|
|
442
|
+
# [mcp.example] # url (+ auth = "oauth") for remote, or command/args for stdio
|
|
443
|
+
# url = "https://example.com/mcp"
|
|
444
|
+
# auto_connect = false
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def resolve_path(cls, path: str | None) -> str:
|
|
449
|
+
if path:
|
|
450
|
+
return os.path.expanduser(path)
|
|
451
|
+
# Backward compatibility: read the legacy ~/.nanocode/config.toml when the new
|
|
452
|
+
# ~/.minacode/config.toml does not exist yet.
|
|
453
|
+
if not os.path.exists(cls.DEFAULT_PATH) and os.path.exists(cls.LEGACY_PATH):
|
|
454
|
+
return cls.LEGACY_PATH
|
|
455
|
+
return cls.DEFAULT_PATH
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def init(cls, path: str | None = None) -> tuple[str, bool]:
|
|
459
|
+
config_path = cls.resolve_path(path)
|
|
460
|
+
if os.path.exists(config_path):
|
|
461
|
+
return config_path, False
|
|
462
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
463
|
+
with open(config_path, "w", encoding="utf-8") as file:
|
|
464
|
+
file.write(cls.DEFAULT_TEXT)
|
|
465
|
+
return config_path, True
|
|
466
|
+
|
|
467
|
+
@classmethod
|
|
468
|
+
def load(cls, path: str | None = None) -> Json:
|
|
469
|
+
config_path = cls.resolve_path(path)
|
|
470
|
+
try:
|
|
471
|
+
with open(config_path, "rb") as file:
|
|
472
|
+
data = tomllib.load(file)
|
|
473
|
+
except FileNotFoundError as error:
|
|
474
|
+
raise ConfigError(f"config not found: {config_path}; run --init-config") from error
|
|
475
|
+
except tomllib.TOMLDecodeError as error:
|
|
476
|
+
raise ConfigError(f"invalid config {config_path}: {error}") from error
|
|
477
|
+
return data if isinstance(data, dict) else {}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class ModelUsage:
|
|
482
|
+
calls: int = 0
|
|
483
|
+
prompt_tokens: int = 0
|
|
484
|
+
completion_tokens: int = 0
|
|
485
|
+
total_tokens: int = 0
|
|
486
|
+
cached_prompt_tokens: int = 0
|
|
487
|
+
last_prompt_tokens: int = 0
|
|
488
|
+
last_cached_prompt_tokens: int = 0
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def field(usage: Any, *paths: str) -> int:
|
|
492
|
+
"""First present dotted path in `usage` (dict keys or attributes) as an int, else 0."""
|
|
493
|
+
for path in paths:
|
|
494
|
+
raw = usage
|
|
495
|
+
for key in path.split("."):
|
|
496
|
+
raw = raw.get(key) if isinstance(raw, dict) else getattr(raw, key, None)
|
|
497
|
+
if raw is None:
|
|
498
|
+
break
|
|
499
|
+
else:
|
|
500
|
+
return int(raw or 0)
|
|
501
|
+
return 0
|
|
502
|
+
|
|
503
|
+
def add(self, usage: Any) -> None:
|
|
504
|
+
self.calls += 1
|
|
505
|
+
prompt_tokens = self.field(usage, "prompt_tokens", "input_tokens")
|
|
506
|
+
completion_tokens = self.field(usage, "completion_tokens", "output_tokens")
|
|
507
|
+
total_tokens = self.field(usage, "total_tokens") or prompt_tokens + completion_tokens
|
|
508
|
+
# fmt: off
|
|
509
|
+
cached_tokens = self.field(usage, "prompt_cache_hit_tokens", "cached_tokens", "cache_read_input_tokens", "prompt_tokens_details.cached_tokens", "input_tokens_details.cached_tokens")
|
|
510
|
+
# fmt: on
|
|
511
|
+
self.prompt_tokens += prompt_tokens
|
|
512
|
+
self.completion_tokens += completion_tokens
|
|
513
|
+
self.total_tokens += total_tokens
|
|
514
|
+
self.cached_prompt_tokens += cached_tokens
|
|
515
|
+
self.last_prompt_tokens = prompt_tokens
|
|
516
|
+
self.last_cached_prompt_tokens = cached_tokens
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@dataclass
|
|
520
|
+
class UpdateStatus:
|
|
521
|
+
latest: str = ""
|
|
522
|
+
checking: bool = False
|
|
523
|
+
error: str = ""
|
|
524
|
+
|
|
525
|
+
def newer_than(self, current: str) -> bool:
|
|
526
|
+
current_version = self.version_tuple(current)
|
|
527
|
+
latest_version = self.version_tuple(self.latest)
|
|
528
|
+
return bool(current_version and latest_version and latest_version > current_version)
|
|
529
|
+
|
|
530
|
+
@staticmethod
|
|
531
|
+
def version_tuple(value: str) -> tuple[int, ...]:
|
|
532
|
+
match = re.match(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?", value)
|
|
533
|
+
return tuple(int(part or 0) for part in match.groups()) if match else ()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@dataclass
|
|
537
|
+
class SystemInfo:
|
|
538
|
+
# fmt: off
|
|
539
|
+
COMMANDS: ClassVar[tuple[str, ...]] = (
|
|
540
|
+
"bash", "git", "rg", "sed", "grep", "find", "awk", "python3", "jq", "xargs", "cat", "head", "tail", "wc",
|
|
541
|
+
"sort", "uniq", "make", "cmake", "gcc", "g++", "clang", "clang++", "node", "npm", "uv", "pytest",
|
|
542
|
+
)
|
|
543
|
+
# fmt: on
|
|
544
|
+
|
|
545
|
+
cwd: str
|
|
546
|
+
os: str
|
|
547
|
+
arch: str
|
|
548
|
+
commands: tuple[str, ...]
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def detect(cls, cwd: str) -> "SystemInfo":
|
|
552
|
+
return cls(
|
|
553
|
+
cwd=cwd,
|
|
554
|
+
os=platform.system() or sys.platform,
|
|
555
|
+
arch=platform.machine() or "unknown",
|
|
556
|
+
commands=tuple(name for name in cls.COMMANDS if shutil.which(name)),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@dataclass
|
|
561
|
+
class ToolCall:
|
|
562
|
+
id: str
|
|
563
|
+
name: str
|
|
564
|
+
args: list[Any]
|
|
565
|
+
# A malformed-argument error captured while parsing the call. Deferred so it surfaces as a
|
|
566
|
+
# tool result the model can correct from, instead of aborting the whole turn at parse time.
|
|
567
|
+
error: str = ""
|