minima-cli 0.4.9__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.
- minima/__init__.py +5 -0
- minima/api/__init__.py +1 -0
- minima/api/auth.py +39 -0
- minima/api/errors.py +40 -0
- minima/api/routers/__init__.py +1 -0
- minima/api/routers/calibration.py +50 -0
- minima/api/routers/feedback.py +279 -0
- minima/api/routers/health.py +50 -0
- minima/api/routers/models.py +42 -0
- minima/api/routers/recommend.py +66 -0
- minima/api/routers/savings.py +55 -0
- minima/api/routers/strategies.py +33 -0
- minima/catalog/__init__.py +1 -0
- minima/catalog/data/capability_priors.json +210 -0
- minima/catalog/data/model_aliases.json +12 -0
- minima/catalog/merge.py +69 -0
- minima/catalog/refresh.py +54 -0
- minima/catalog/sources/__init__.py +1 -0
- minima/catalog/sources/litellm.py +19 -0
- minima/catalog/sources/openrouter.py +25 -0
- minima/catalog/store.py +86 -0
- minima/config.py +288 -0
- minima/deps.py +35 -0
- minima/llm/__init__.py +1 -0
- minima/llm/anthropic.py +106 -0
- minima/llm/base.py +196 -0
- minima/llm/gemini.py +124 -0
- minima/llm/registry.py +54 -0
- minima/logging.py +28 -0
- minima/main.py +109 -0
- minima/memory/__init__.py +1 -0
- minima/memory/adapter.py +572 -0
- minima/memory/keys.py +83 -0
- minima/memory/records.py +190 -0
- minima/memory/threadpool.py +41 -0
- minima/metrics/__init__.py +1 -0
- minima/metrics/calibration.py +415 -0
- minima/metrics/report.py +116 -0
- minima/metrics/savings.py +98 -0
- minima/recommender/__init__.py +1 -0
- minima/recommender/_pg_pool.py +38 -0
- minima/recommender/_redis_client.py +32 -0
- minima/recommender/aggregate.py +157 -0
- minima/recommender/classify.py +165 -0
- minima/recommender/decisionlog.py +505 -0
- minima/recommender/durablerefs.py +312 -0
- minima/recommender/engine.py +997 -0
- minima/recommender/escalation.py +83 -0
- minima/recommender/propensity.py +189 -0
- minima/recommender/recstore.py +368 -0
- minima/recommender/score.py +318 -0
- minima/recommender/types.py +166 -0
- minima/schemas/__init__.py +1 -0
- minima/schemas/common.py +73 -0
- minima/schemas/feedback.py +34 -0
- minima/schemas/models_catalog.py +36 -0
- minima/schemas/recommend.py +104 -0
- minima/schemas/savings.py +39 -0
- minima/schemas/strategies.py +57 -0
- minima/schemas/workflow.py +43 -0
- minima/seeding/__init__.py +1 -0
- minima/seeding/items.py +42 -0
- minima/seeding/llmrouterbench.py +232 -0
- minima/seeding/routerbench.py +141 -0
- minima/seeding/run_seed.py +56 -0
- minima/seeding/synthetic.py +70 -0
- minima/tenancy/__init__.py +8 -0
- minima/tenancy/context.py +37 -0
- minima/tenancy/passthrough.py +110 -0
- minima/version.py +3 -0
- minima_cli-0.4.9.dist-info/METADATA +275 -0
- minima_cli-0.4.9.dist-info/RECORD +161 -0
- minima_cli-0.4.9.dist-info/WHEEL +4 -0
- minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
- minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
- minima_client/__init__.py +19 -0
- minima_client/autocapture.py +101 -0
- minima_client/client.py +301 -0
- minima_client/errors.py +23 -0
- minima_harness/LICENSE_PI +32 -0
- minima_harness/__init__.py +16 -0
- minima_harness/agent/__init__.py +72 -0
- minima_harness/agent/agent.py +276 -0
- minima_harness/agent/events.py +124 -0
- minima_harness/agent/loop.py +311 -0
- minima_harness/agent/state.py +79 -0
- minima_harness/agent/tools.py +97 -0
- minima_harness/ai/__init__.py +66 -0
- minima_harness/ai/compat.py +71 -0
- minima_harness/ai/errors.py +96 -0
- minima_harness/ai/events.py +117 -0
- minima_harness/ai/openrouter_catalog.py +153 -0
- minima_harness/ai/provider_catalog.py +299 -0
- minima_harness/ai/provider_quirks.py +37 -0
- minima_harness/ai/providers/__init__.py +75 -0
- minima_harness/ai/providers/_common.py +48 -0
- minima_harness/ai/providers/anthropic.py +290 -0
- minima_harness/ai/providers/base.py +65 -0
- minima_harness/ai/providers/faux.py +173 -0
- minima_harness/ai/providers/google.py +221 -0
- minima_harness/ai/providers/openai_compat.py +278 -0
- minima_harness/ai/registry.py +184 -0
- minima_harness/ai/stream.py +82 -0
- minima_harness/ai/tools.py +51 -0
- minima_harness/ai/types.py +204 -0
- minima_harness/ai/usage.py +41 -0
- minima_harness/minima/__init__.py +40 -0
- minima_harness/minima/cache.py +102 -0
- minima_harness/minima/config.py +85 -0
- minima_harness/minima/goals.py +226 -0
- minima_harness/minima/judge.py +144 -0
- minima_harness/minima/mapping.py +147 -0
- minima_harness/minima/meter.py +143 -0
- minima_harness/minima/router.py +220 -0
- minima_harness/minima/runtime.py +544 -0
- minima_harness/minima/signals.py +195 -0
- minima_harness/session/__init__.py +14 -0
- minima_harness/session/format.py +35 -0
- minima_harness/session/store.py +236 -0
- minima_harness/tasks/__init__.py +17 -0
- minima_harness/tasks/task_set.py +78 -0
- minima_harness/tools/__init__.py +7 -0
- minima_harness/tools/_io.py +34 -0
- minima_harness/tools/bash.py +70 -0
- minima_harness/tools/builtin.py +23 -0
- minima_harness/tools/edit.py +50 -0
- minima_harness/tools/find.py +38 -0
- minima_harness/tools/grep.py +73 -0
- minima_harness/tools/ls.py +35 -0
- minima_harness/tools/read.py +38 -0
- minima_harness/tools/tasks.py +75 -0
- minima_harness/tools/write.py +36 -0
- minima_harness/tui/__init__.py +3 -0
- minima_harness/tui/analytics.py +111 -0
- minima_harness/tui/app.py +1927 -0
- minima_harness/tui/bridge.py +103 -0
- minima_harness/tui/cli.py +227 -0
- minima_harness/tui/clipboard.py +60 -0
- minima_harness/tui/commands.py +49 -0
- minima_harness/tui/compaction.py +17 -0
- minima_harness/tui/config_cli.py +141 -0
- minima_harness/tui/config_store.py +237 -0
- minima_harness/tui/context.py +93 -0
- minima_harness/tui/customize.py +95 -0
- minima_harness/tui/diff.py +53 -0
- minima_harness/tui/editor.py +43 -0
- minima_harness/tui/extensions.py +84 -0
- minima_harness/tui/extra_models.py +52 -0
- minima_harness/tui/history.py +71 -0
- minima_harness/tui/mubit.py +295 -0
- minima_harness/tui/overlays.py +593 -0
- minima_harness/tui/packages.py +59 -0
- minima_harness/tui/run_modes.py +66 -0
- minima_harness/tui/theme.py +77 -0
- minima_harness/tui/welcome.py +83 -0
- minima_harness/tui/widgets/__init__.py +3 -0
- minima_harness/tui/widgets/banner.py +38 -0
- minima_harness/tui/widgets/editor.py +83 -0
- minima_harness/tui/widgets/footer.py +73 -0
- minima_harness/tui/widgets/messages.py +151 -0
- minima_harness/tui/widgets/status.py +57 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Per-user credential store for the harness — keyring-first, 0600-file fallback.
|
|
2
|
+
|
|
3
|
+
Mirrors how best-in-class coding-agent CLIs persist secrets (Claude Code: macOS Keychain,
|
|
4
|
+
else ``~/.<tool>/...`` at mode 0600). Secrets go to the OS keyring when a real backend is
|
|
5
|
+
available; otherwise they fall back to ``~/.minima-harness/config.env`` written 0600.
|
|
6
|
+
Non-secret config (URLs) always lives in the file — no point keychaining a URL.
|
|
7
|
+
|
|
8
|
+
The harness itself reads everything from environment variables (provider keys via
|
|
9
|
+
``resolve_api_key``, Mubit via ``os.environ``), so :func:`hydrate_env` materialises stored
|
|
10
|
+
values into ``os.environ`` at startup with ``setdefault`` — keeping the store the *lowest*
|
|
11
|
+
precedence (real shell env and project ``.env`` files still win).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from minima_harness.minima.config import DEFAULT_MINIMA_URL
|
|
20
|
+
from minima_harness.tui.customize import GLOBAL_DIR
|
|
21
|
+
|
|
22
|
+
CONFIG_FILE = GLOBAL_DIR / "config.env"
|
|
23
|
+
KEYRING_SERVICE = "minima-harness"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _keyring(): # noqa: ANN202 - the keyring module type is optional/dynamic
|
|
27
|
+
"""Return the keyring module iff a *real* (non-fail) backend is available, else None."""
|
|
28
|
+
try:
|
|
29
|
+
import keyring
|
|
30
|
+
from keyring.backends import fail
|
|
31
|
+
|
|
32
|
+
if isinstance(keyring.get_keyring(), fail.Keyring):
|
|
33
|
+
return None
|
|
34
|
+
return keyring
|
|
35
|
+
except Exception: # noqa: BLE001 - keyring is optional; any failure → file fallback
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Field:
|
|
41
|
+
"""One configurable value. ``secret`` fields are masked + keyring-eligible."""
|
|
42
|
+
|
|
43
|
+
key: str
|
|
44
|
+
label: str
|
|
45
|
+
secret: bool = True
|
|
46
|
+
optional: bool = False
|
|
47
|
+
default: str = ""
|
|
48
|
+
aliases: tuple[str, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class Section:
|
|
53
|
+
title: str
|
|
54
|
+
note: str
|
|
55
|
+
fields: tuple[Field, ...] = field(default_factory=tuple)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _provider_fields() -> tuple[Field, ...]:
|
|
59
|
+
"""Build the LLM-provider key fields from the provider catalog (single source of truth)."""
|
|
60
|
+
from minima_harness.ai.provider_catalog import config_providers
|
|
61
|
+
|
|
62
|
+
fields: list[Field] = []
|
|
63
|
+
for p in config_providers():
|
|
64
|
+
primary, *alts = p.env_vars
|
|
65
|
+
label = f"{p.display_name} — {p.blurb}" if p.blurb else f"{p.display_name} API key"
|
|
66
|
+
fields.append(Field(primary, label, optional=True, aliases=tuple(alts)))
|
|
67
|
+
return tuple(fields)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SECTIONS: tuple[Section, ...] = (
|
|
71
|
+
Section(
|
|
72
|
+
title="LLM provider keys",
|
|
73
|
+
note="Keys to RUN the chosen model — set any one (or several). More providers "
|
|
74
|
+
"(Fireworks, DeepInfra, Cerebras, Perplexity, Cohere, …) work by exporting their env "
|
|
75
|
+
"var; local runtimes (Ollama, vLLM, LM Studio) need no key.",
|
|
76
|
+
fields=_provider_fields(),
|
|
77
|
+
),
|
|
78
|
+
Section(
|
|
79
|
+
title="Mubit / Minima routing",
|
|
80
|
+
note="Mubit memory backend + the Minima recommender endpoint.",
|
|
81
|
+
fields=(
|
|
82
|
+
Field("MUBIT_API_KEY", "Mubit API key (memory + routing auth)"),
|
|
83
|
+
Field(
|
|
84
|
+
"MINIMA_URL",
|
|
85
|
+
"Minima endpoint URL",
|
|
86
|
+
secret=False,
|
|
87
|
+
optional=True,
|
|
88
|
+
default=DEFAULT_MINIMA_URL,
|
|
89
|
+
),
|
|
90
|
+
Field(
|
|
91
|
+
"MINIMA_API_KEY",
|
|
92
|
+
"Minima auth (optional; falls back to MUBIT_API_KEY)",
|
|
93
|
+
optional=True,
|
|
94
|
+
),
|
|
95
|
+
Field("MUBIT_ENDPOINT", "Mubit endpoint URL", secret=False, optional=True),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def all_fields() -> list[Field]:
|
|
102
|
+
return [f for section in SECTIONS for f in section.fields]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def field_for(key: str) -> Field | None:
|
|
106
|
+
return next((f for f in all_fields() if f.key == key), None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def backend_name() -> str:
|
|
110
|
+
"""The active secrets backend label, for display."""
|
|
111
|
+
return "keyring" if _keyring() is not None else "file"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mask(value: str | None) -> str:
|
|
115
|
+
"""Show only the last 4 chars of a secret (never the whole thing)."""
|
|
116
|
+
if not value:
|
|
117
|
+
return ""
|
|
118
|
+
if len(value) <= 4:
|
|
119
|
+
return "•" * len(value)
|
|
120
|
+
return "•" * 4 + value[-4:]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- file backend (env-format, mode 0600) ---------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_file() -> dict[str, str]:
|
|
127
|
+
out: dict[str, str] = {}
|
|
128
|
+
if not CONFIG_FILE.is_file():
|
|
129
|
+
return out
|
|
130
|
+
for raw in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
|
|
131
|
+
line = raw.strip()
|
|
132
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
133
|
+
continue
|
|
134
|
+
k, _, v = line.partition("=")
|
|
135
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_file(data: dict[str, str]) -> None:
|
|
140
|
+
GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
lines = [f"{k}={v}" for k, v in sorted(data.items()) if v != ""]
|
|
142
|
+
body = "\n".join(["# minima-harness config — managed by `minima-harness config`", *lines])
|
|
143
|
+
# O_CREAT with 0600 so the file is owner-only from the moment it exists.
|
|
144
|
+
fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
145
|
+
try:
|
|
146
|
+
os.write(fd, (body + "\n").encode("utf-8"))
|
|
147
|
+
finally:
|
|
148
|
+
os.close(fd)
|
|
149
|
+
try: # tighten perms even if the file pre-existed with looser ones
|
|
150
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _file_set(key: str, value: str) -> None:
|
|
156
|
+
data = _read_file()
|
|
157
|
+
data[key] = value
|
|
158
|
+
_write_file(data)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _file_delete(key: str) -> None:
|
|
162
|
+
data = _read_file()
|
|
163
|
+
if key in data:
|
|
164
|
+
del data[key]
|
|
165
|
+
_write_file(data)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- public get / set / unset ----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get(key: str) -> str | None:
|
|
172
|
+
"""Read a stored value: keyring first for secrets, then the file."""
|
|
173
|
+
f = field_for(key)
|
|
174
|
+
secret = f.secret if f else True
|
|
175
|
+
if secret:
|
|
176
|
+
kr = _keyring()
|
|
177
|
+
if kr is not None:
|
|
178
|
+
try:
|
|
179
|
+
val = kr.get_password(KEYRING_SERVICE, key)
|
|
180
|
+
if val:
|
|
181
|
+
return val
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
pass
|
|
184
|
+
return _read_file().get(key)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def set_value(key: str, value: str) -> str:
|
|
188
|
+
"""Persist ``value``. Returns the backend used: ``"keyring"`` or ``"file"``."""
|
|
189
|
+
f = field_for(key)
|
|
190
|
+
secret = f.secret if f else True
|
|
191
|
+
if secret:
|
|
192
|
+
kr = _keyring()
|
|
193
|
+
if kr is not None:
|
|
194
|
+
try:
|
|
195
|
+
kr.set_password(KEYRING_SERVICE, key, value)
|
|
196
|
+
_file_delete(key) # don't leave a stale plaintext copy behind
|
|
197
|
+
return "keyring"
|
|
198
|
+
except Exception: # noqa: BLE001
|
|
199
|
+
pass
|
|
200
|
+
_file_set(key, value)
|
|
201
|
+
return "file"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def unset(key: str) -> None:
|
|
205
|
+
kr = _keyring()
|
|
206
|
+
if kr is not None:
|
|
207
|
+
try:
|
|
208
|
+
kr.delete_password(KEYRING_SERVICE, key)
|
|
209
|
+
except Exception: # noqa: BLE001
|
|
210
|
+
pass
|
|
211
|
+
_file_delete(key)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def location(key: str) -> str:
|
|
215
|
+
"""Where ``key`` is stored: ``"keyring"`` | ``"file"`` | ``"—"`` (unset)."""
|
|
216
|
+
f = field_for(key)
|
|
217
|
+
secret = f.secret if f else True
|
|
218
|
+
if secret:
|
|
219
|
+
kr = _keyring()
|
|
220
|
+
if kr is not None:
|
|
221
|
+
try:
|
|
222
|
+
if kr.get_password(KEYRING_SERVICE, key):
|
|
223
|
+
return "keyring"
|
|
224
|
+
except Exception: # noqa: BLE001
|
|
225
|
+
pass
|
|
226
|
+
return "file" if key in _read_file() else "—"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def hydrate_env() -> None:
|
|
230
|
+
"""Load stored config into ``os.environ`` (setdefault → real env / project files win)."""
|
|
231
|
+
for f in all_fields():
|
|
232
|
+
val = get(f.key)
|
|
233
|
+
if not val:
|
|
234
|
+
continue
|
|
235
|
+
os.environ.setdefault(f.key, val)
|
|
236
|
+
for alias in f.aliases:
|
|
237
|
+
os.environ.setdefault(alias, val)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from minima_harness.session.format import EntryType
|
|
6
|
+
|
|
7
|
+
CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md")
|
|
8
|
+
GLOBAL_DIR = Path.home() / ".minima-harness"
|
|
9
|
+
|
|
10
|
+
BASE_SYSTEM = (
|
|
11
|
+
"You are an interactive coding agent running in the user's terminal. Use the provided "
|
|
12
|
+
"tools (read, write, edit, bash, grep, find, ls) to explore and modify the codebase. "
|
|
13
|
+
"Be concise and direct; explain only when asked."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
SUMMARY_SYSTEM = (
|
|
17
|
+
"You compact a coding-agent conversation. Summarize the work done so far: key decisions, "
|
|
18
|
+
"file paths touched, current state, and open questions. Be concise. Output only the summary."
|
|
19
|
+
)
|
|
20
|
+
SUMMARY_USER = "Summarize the conversation above for context continuity."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read(path: Path) -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
text = path.read_text(encoding="utf-8")
|
|
26
|
+
return text.strip() or None
|
|
27
|
+
except OSError: # noqa: BLE001
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_agents_md(cwd: Path) -> str:
|
|
32
|
+
"""Concatenate AGENTS.md/CLAUDE.md from the global dir + cwd's parent chain (root → cwd)."""
|
|
33
|
+
chunks: list[str] = []
|
|
34
|
+
for name in CONTEXT_FILES:
|
|
35
|
+
g = _read(GLOBAL_DIR / name)
|
|
36
|
+
if g:
|
|
37
|
+
chunks.append(f"# ({name}, global)\n{g}")
|
|
38
|
+
|
|
39
|
+
parts: list[Path] = []
|
|
40
|
+
node = cwd.resolve()
|
|
41
|
+
while True:
|
|
42
|
+
parts.append(node)
|
|
43
|
+
if node.parent == node:
|
|
44
|
+
break
|
|
45
|
+
node = node.parent
|
|
46
|
+
for d in reversed(parts): # rootward first, cwd last
|
|
47
|
+
for name in CONTEXT_FILES:
|
|
48
|
+
t = _read(d / name)
|
|
49
|
+
if t:
|
|
50
|
+
chunks.append(f"# ({name}, {d.name})\n{t}")
|
|
51
|
+
return "\n\n".join(chunks)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_system_prompt_parts(cwd: Path) -> list[tuple[str, str]]:
|
|
55
|
+
"""The local system prompt as labeled parts: always ``("base", ...)``, plus
|
|
56
|
+
``("agents.md", ...)`` when project context exists.
|
|
57
|
+
|
|
58
|
+
``build_system_prompt`` joins these into one string; splitting them lets the prompt
|
|
59
|
+
inspector show the base prompt and project context (AGENTS.md/CLAUDE.md) as distinct
|
|
60
|
+
layers without duplicating the assembly logic.
|
|
61
|
+
"""
|
|
62
|
+
replace = _read(cwd / "SYSTEM.md") or _read(GLOBAL_DIR / "SYSTEM.md")
|
|
63
|
+
append = _read(cwd / "APPEND_SYSTEM.md") or _read(GLOBAL_DIR / "APPEND_SYSTEM.md")
|
|
64
|
+
base = replace if replace else BASE_SYSTEM
|
|
65
|
+
if append:
|
|
66
|
+
base = f"{base}\n\n{append}"
|
|
67
|
+
parts: list[tuple[str, str]] = [("base", base)]
|
|
68
|
+
agents = load_agents_md(cwd)
|
|
69
|
+
if agents:
|
|
70
|
+
parts.append(("agents.md", agents))
|
|
71
|
+
return parts
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_system_prompt(cwd: Path) -> str:
|
|
75
|
+
"""Base prompt + AGENTS.md context + SYSTEM.md (replace) / APPEND_SYSTEM.md (append)."""
|
|
76
|
+
parts = build_system_prompt_parts(cwd)
|
|
77
|
+
prompt = parts[0][1]
|
|
78
|
+
for _name, text in parts[1:]: # only "agents.md" follows base today
|
|
79
|
+
prompt = f"{prompt}\n\n# Project context\n{text}"
|
|
80
|
+
return prompt
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_session_override(store) -> str:
|
|
84
|
+
"""Read the most recent session-level system-prompt override (a SYSTEM entry)."""
|
|
85
|
+
for entry in reversed(store.entries):
|
|
86
|
+
if entry.type == EntryType.SYSTEM and "override" in entry.payload:
|
|
87
|
+
return entry.payload.get("override", "")
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_session_override(store, text: str) -> None:
|
|
92
|
+
"""Persist a session-level system-prompt override."""
|
|
93
|
+
store.append(EntryType.SYSTEM, {"override": text})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
GLOBAL_DIR = Path.home() / ".minima-harness"
|
|
7
|
+
PACKAGES_DIR = GLOBAL_DIR / "packages"
|
|
8
|
+
|
|
9
|
+
# Theme palette keys a theme JSON file may set.
|
|
10
|
+
_THEME_KEYS = ("user", "assistant", "tool", "warning", "muted", "accent")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def package_roots() -> list[Path]:
|
|
14
|
+
"""Installed package roots (``~/.minima-harness/packages/*/``)."""
|
|
15
|
+
if not PACKAGES_DIR.is_dir():
|
|
16
|
+
return []
|
|
17
|
+
return [d for d in sorted(PACKAGES_DIR.iterdir()) if d.is_dir()]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _theme_dirs(cwd: Path) -> list[Path]:
|
|
21
|
+
return [GLOBAL_DIR / "themes", cwd / ".pi" / "themes", *(p / "themes" for p in package_roots())]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _prompt_dirs(cwd: Path) -> list[Path]:
|
|
25
|
+
return [
|
|
26
|
+
GLOBAL_DIR / "prompts",
|
|
27
|
+
cwd / ".pi" / "prompts",
|
|
28
|
+
*(p / "prompts" for p in package_roots()),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _skill_dirs(cwd: Path) -> list[Path]:
|
|
33
|
+
# Agent Skills standard: ~/.agents/skills and .agents/skills (cwd); plus our dirs + packages.
|
|
34
|
+
return [
|
|
35
|
+
GLOBAL_DIR / "skills",
|
|
36
|
+
Path.home() / ".agents" / "skills",
|
|
37
|
+
cwd / ".agents" / "skills",
|
|
38
|
+
cwd / ".pi" / "skills",
|
|
39
|
+
*(p / "skills" for p in package_roots()),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_file_themes(cwd: Path) -> dict[str, dict[str, str]]:
|
|
44
|
+
"""Discover ``*.json`` theme files → {name: palette} (palettes keyed by _THEME_KEYS)."""
|
|
45
|
+
out: dict[str, dict[str, str]] = {}
|
|
46
|
+
for base in _theme_dirs(cwd):
|
|
47
|
+
if not base.is_dir():
|
|
48
|
+
continue
|
|
49
|
+
for f in sorted(base.glob("*.json")):
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
52
|
+
except Exception: # noqa: BLE001 - one bad file must not break themes
|
|
53
|
+
continue
|
|
54
|
+
if isinstance(data, dict):
|
|
55
|
+
palette = {k: str(v) for k, v in data.items() if k in _THEME_KEYS}
|
|
56
|
+
if palette:
|
|
57
|
+
out[f.stem] = palette
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_templates(cwd: Path) -> dict[str, str]:
|
|
62
|
+
"""Discover ``*.md`` prompt templates → {name (stem): body}."""
|
|
63
|
+
out: dict[str, str] = {}
|
|
64
|
+
for base in _prompt_dirs(cwd):
|
|
65
|
+
if not base.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
for f in sorted(base.glob("*.md")):
|
|
68
|
+
try:
|
|
69
|
+
body = f.read_text(encoding="utf-8").strip()
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
continue
|
|
72
|
+
if body:
|
|
73
|
+
out[f.stem] = body
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_skills(cwd: Path) -> dict[str, str]:
|
|
78
|
+
"""Discover ``<dir>/<name>/SKILL.md`` skill packages → {name: body}."""
|
|
79
|
+
out: dict[str, str] = {}
|
|
80
|
+
for base in _skill_dirs(cwd):
|
|
81
|
+
if not base.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
for d in sorted(base.iterdir()):
|
|
84
|
+
if not d.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
skill = d / "SKILL.md"
|
|
87
|
+
if d.name in out or not skill.is_file():
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
body = skill.read_text(encoding="utf-8").strip()
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
continue
|
|
93
|
+
if body:
|
|
94
|
+
out[d.name] = body
|
|
95
|
+
return out
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Render a mutating tool call (edit/write) as a unified diff for the approval modal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_MAX_LINES = 240
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_tool_diff(tool_name: str, args: Any) -> str:
|
|
13
|
+
"""Unified-diff preview of what an edit/write tool would change. Pure (reads the file)."""
|
|
14
|
+
if tool_name == "write":
|
|
15
|
+
return _write_diff(args)
|
|
16
|
+
if tool_name == "edit":
|
|
17
|
+
return _edit_diff(args)
|
|
18
|
+
return f"{tool_name}: {args}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_lines(path: str) -> list[str] | None:
|
|
22
|
+
try:
|
|
23
|
+
return Path(path).expanduser().read_text(encoding="utf-8").splitlines()
|
|
24
|
+
except Exception: # noqa: BLE001 - missing/binary file -> treat as new
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _edit_diff(args: Any) -> str:
|
|
29
|
+
path = getattr(args, "path", "?")
|
|
30
|
+
old = getattr(args, "old_string", "").splitlines()
|
|
31
|
+
new = getattr(args, "new_string", "").splitlines()
|
|
32
|
+
diff = difflib.unified_diff(old, new, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="")
|
|
33
|
+
return _truncate("\n".join(diff) or f"edit {path} (no textual change)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_diff(args: Any) -> str:
|
|
37
|
+
path = getattr(args, "path", "?")
|
|
38
|
+
new = getattr(args, "content", "").splitlines()
|
|
39
|
+
current = _read_lines(path)
|
|
40
|
+
if current is None:
|
|
41
|
+
body = "\n".join(f"+{line}" for line in new)
|
|
42
|
+
return _truncate(f"--- /dev/null\n+++ b/{path} (new file)\n{body}")
|
|
43
|
+
diff = difflib.unified_diff(
|
|
44
|
+
current, new, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm=""
|
|
45
|
+
)
|
|
46
|
+
return _truncate("\n".join(diff) or f"write {path} (no change)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _truncate(text: str, n: int = _MAX_LINES) -> str:
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
if len(lines) <= n:
|
|
52
|
+
return text
|
|
53
|
+
return "\n".join(lines[:n]) + f"\n… (+{len(lines) - n} more lines)"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_submission(text: str) -> dict:
|
|
8
|
+
"""Classify raw editor text into a command / bash / message submission."""
|
|
9
|
+
t = text.strip()
|
|
10
|
+
if t.startswith("/"):
|
|
11
|
+
name, _, args = t[1:].partition(" ")
|
|
12
|
+
return {"kind": "command", "name": name, "args": args.strip()}
|
|
13
|
+
if t.startswith("!!"):
|
|
14
|
+
return {"kind": "bash", "command": t[2:], "feed": False}
|
|
15
|
+
if t.startswith("!"):
|
|
16
|
+
return {"kind": "bash", "command": t[1:], "feed": True}
|
|
17
|
+
return {"kind": "message", "text": expand_at_files(t)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def expand_at_files(text: str) -> str:
|
|
21
|
+
"""Inline-expand ``@path`` tokens that point at real files into fenced content."""
|
|
22
|
+
out: list[str] = []
|
|
23
|
+
for token in text.split():
|
|
24
|
+
if token.startswith("@") and len(token) > 1:
|
|
25
|
+
p = Path(token[1:]).expanduser()
|
|
26
|
+
if p.is_file():
|
|
27
|
+
try:
|
|
28
|
+
out.append(f'<file path="{p}">\n{p.read_text(encoding="utf-8")}\n</file>')
|
|
29
|
+
continue
|
|
30
|
+
except OSError: # noqa: BLE001
|
|
31
|
+
pass
|
|
32
|
+
out.append(token)
|
|
33
|
+
return " ".join(out)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run_bash(command: str) -> str:
|
|
37
|
+
proc = await asyncio.create_subprocess_shell(
|
|
38
|
+
command,
|
|
39
|
+
stdout=asyncio.subprocess.PIPE,
|
|
40
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
41
|
+
)
|
|
42
|
+
out, _ = await proc.communicate()
|
|
43
|
+
return out.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from minima_harness.tui.commands import Command
|
|
9
|
+
from minima_harness.tui.customize import GLOBAL_DIR, package_roots
|
|
10
|
+
|
|
11
|
+
_log = logging.getLogger("minima_harness.tui.extensions")
|
|
12
|
+
|
|
13
|
+
# Extension event-hook keys (the fanout maps AgentEvent types onto these).
|
|
14
|
+
HOOK_KEYS = ("text", "tool_start", "tool_end", "turn", "finish")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExtensionAPI:
|
|
18
|
+
"""Surface an extension uses to add tools, commands, and event hooks.
|
|
19
|
+
|
|
20
|
+
A Python extension is a module in ``~/.minima-harness/extensions/`` (or
|
|
21
|
+
``.pi/extensions/``) that defines ``register(api: ExtensionAPI)``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, name: str) -> None:
|
|
25
|
+
self.name = name
|
|
26
|
+
self.tools: list = []
|
|
27
|
+
self.commands: dict[str, Command] = {}
|
|
28
|
+
self.hooks: dict[str, list[Callable]] = {k: [] for k in HOOK_KEYS}
|
|
29
|
+
|
|
30
|
+
def tool(self, tool) -> None: # noqa: ANN001
|
|
31
|
+
"""Register an :class:`AgentTool`."""
|
|
32
|
+
self.tools.append(tool)
|
|
33
|
+
|
|
34
|
+
def command(self, name: str, *, description: str = "") -> Callable:
|
|
35
|
+
"""Register an async slash-command handler ``async def(app, args) -> str|None``."""
|
|
36
|
+
|
|
37
|
+
def deco(fn: Callable) -> Callable:
|
|
38
|
+
self.commands[name] = Command(name=name, handler=fn, description=description)
|
|
39
|
+
return fn
|
|
40
|
+
|
|
41
|
+
return deco
|
|
42
|
+
|
|
43
|
+
def on(self, event_key: str) -> Callable:
|
|
44
|
+
"""Register an event hook (``text``/``tool_start``/``tool_end``/``turn``/``finish``)."""
|
|
45
|
+
|
|
46
|
+
def deco(fn: Callable) -> Callable:
|
|
47
|
+
self.hooks.setdefault(event_key, []).append(fn)
|
|
48
|
+
return fn
|
|
49
|
+
|
|
50
|
+
return deco
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_extensions(cwd: Path) -> list[ExtensionAPI]:
|
|
54
|
+
"""Discover and load Python extension modules from the extensions dirs."""
|
|
55
|
+
dirs = [
|
|
56
|
+
GLOBAL_DIR / "extensions",
|
|
57
|
+
cwd / ".pi" / "extensions",
|
|
58
|
+
*(p / "extensions" for p in package_roots()),
|
|
59
|
+
]
|
|
60
|
+
apis: list[ExtensionAPI] = []
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
for base in dirs:
|
|
63
|
+
if not base.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
for f in sorted(base.glob("*.py")):
|
|
66
|
+
if f.name.startswith("_") or f.stem in seen:
|
|
67
|
+
continue
|
|
68
|
+
api = ExtensionAPI(f.stem)
|
|
69
|
+
try:
|
|
70
|
+
spec = importlib.util.spec_from_file_location(f"minima_harness_ext.{f.stem}", f)
|
|
71
|
+
if spec is None or spec.loader is None:
|
|
72
|
+
continue
|
|
73
|
+
mod = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(mod)
|
|
75
|
+
register = getattr(mod, "register", None)
|
|
76
|
+
if register is None:
|
|
77
|
+
continue
|
|
78
|
+
register(api)
|
|
79
|
+
except Exception: # noqa: BLE001 - one broken extension must not break startup
|
|
80
|
+
_log.warning("extension_load_failed: %s", f, exc_info=True)
|
|
81
|
+
continue
|
|
82
|
+
seen.add(f.stem)
|
|
83
|
+
apis.append(api)
|
|
84
|
+
return apis
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from minima_harness.ai.types import Model, ModelCost
|
|
8
|
+
from minima_harness.tui.customize import GLOBAL_DIR
|
|
9
|
+
|
|
10
|
+
_log = logging.getLogger("minima_harness.tui.extra_models")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_extra_models(cwd: Path) -> list[Model]:
|
|
14
|
+
"""Read ``models.json`` (global + project) → OpenAI-compatible Model list."""
|
|
15
|
+
out: list[Model] = []
|
|
16
|
+
for path in (GLOBAL_DIR / "models.json", cwd / ".pi" / "models.json"):
|
|
17
|
+
if not path.is_file():
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
except Exception: # noqa: BLE001
|
|
22
|
+
continue
|
|
23
|
+
entries = data.get("models", []) if isinstance(data, dict) else data
|
|
24
|
+
for m in entries:
|
|
25
|
+
if not isinstance(m, dict) or "id" not in m:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
out.append(
|
|
29
|
+
Model(
|
|
30
|
+
id=m["id"],
|
|
31
|
+
provider=m.get("provider", "openai-compat"),
|
|
32
|
+
api="openai-completions",
|
|
33
|
+
name=str(m.get("name") or m["id"]),
|
|
34
|
+
cost=ModelCost(
|
|
35
|
+
input=float(m.get("input_cost", 0.0)),
|
|
36
|
+
output=float(m.get("output_cost", 0.0)),
|
|
37
|
+
),
|
|
38
|
+
context_window=int(m.get("context_window", 128_000)),
|
|
39
|
+
max_tokens=int(m.get("max_tokens", 4096)),
|
|
40
|
+
base_url=m.get("base_url"),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
except Exception: # noqa: BLE001
|
|
44
|
+
_log.warning("bad models.json entry: %s", m)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register_extra_models(cwd: Path) -> None:
|
|
49
|
+
from minima_harness.ai import register_model
|
|
50
|
+
|
|
51
|
+
for model in load_extra_models(cwd):
|
|
52
|
+
register_model(model)
|