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,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from minima_harness.agent.tools import AgentTool
|
|
4
|
+
from minima_harness.tools.bash import bash_tool
|
|
5
|
+
from minima_harness.tools.edit import edit_tool
|
|
6
|
+
from minima_harness.tools.find import find_tool
|
|
7
|
+
from minima_harness.tools.grep import grep_tool
|
|
8
|
+
from minima_harness.tools.ls import ls_tool
|
|
9
|
+
from minima_harness.tools.read import read_tool
|
|
10
|
+
from minima_harness.tools.write import write_tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_toolset() -> list[AgentTool]:
|
|
14
|
+
"""PI's seven default coding tools, in a stable order."""
|
|
15
|
+
return [
|
|
16
|
+
read_tool(),
|
|
17
|
+
write_tool(),
|
|
18
|
+
edit_tool(),
|
|
19
|
+
bash_tool(),
|
|
20
|
+
grep_tool(),
|
|
21
|
+
find_tool(),
|
|
22
|
+
ls_tool(),
|
|
23
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from minima_harness.agent.tools import AgentTool, ToolResult, error_result
|
|
8
|
+
from minima_harness.ai.types import TextContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EditParams(BaseModel):
|
|
12
|
+
path: str
|
|
13
|
+
old_string: str
|
|
14
|
+
new_string: str
|
|
15
|
+
replace_all: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
19
|
+
assert isinstance(params, EditParams)
|
|
20
|
+
p = Path(params.path).expanduser()
|
|
21
|
+
if not p.exists():
|
|
22
|
+
return error_result(f"edit: no such file: {p}")
|
|
23
|
+
text = p.read_text(encoding="utf-8")
|
|
24
|
+
count = text.count(params.old_string)
|
|
25
|
+
if count == 0:
|
|
26
|
+
return error_result(f"edit: old_string not found in {p}")
|
|
27
|
+
if count > 1 and not params.replace_all:
|
|
28
|
+
return error_result(
|
|
29
|
+
f"edit: old_string matches {count} times in {p}; "
|
|
30
|
+
"add more surrounding context or set replace_all=True"
|
|
31
|
+
)
|
|
32
|
+
new = text.replace(params.old_string, params.new_string)
|
|
33
|
+
p.write_text(new, encoding="utf-8")
|
|
34
|
+
replaced = count if params.replace_all else 1
|
|
35
|
+
return ToolResult(
|
|
36
|
+
content=[TextContent(text=f"edited {p}: {replaced} replacement(s)")],
|
|
37
|
+
details={"replacements": replaced},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def edit_tool() -> AgentTool:
|
|
42
|
+
return AgentTool(
|
|
43
|
+
name="edit",
|
|
44
|
+
description=(
|
|
45
|
+
"Replace an exact string in a file. Errors if old_string is absent or "
|
|
46
|
+
"(without replace_all) appears more than once — add context to disambiguate."
|
|
47
|
+
),
|
|
48
|
+
parameters=EditParams,
|
|
49
|
+
execute=_execute,
|
|
50
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from minima_harness.agent.tools import AgentTool, ToolResult
|
|
9
|
+
from minima_harness.ai.types import TextContent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FindParams(BaseModel):
|
|
13
|
+
pattern: str
|
|
14
|
+
path: str = "."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
18
|
+
assert isinstance(params, FindParams)
|
|
19
|
+
root = Path(params.path).expanduser()
|
|
20
|
+
pat = str(root / params.pattern)
|
|
21
|
+
matches = sorted(glob.glob(pat, recursive=True))
|
|
22
|
+
body = "\n".join(matches) if matches else "(no matches)"
|
|
23
|
+
return ToolResult(
|
|
24
|
+
content=[TextContent(text=body)],
|
|
25
|
+
details={"count": len(matches)},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_tool() -> AgentTool:
|
|
30
|
+
return AgentTool(
|
|
31
|
+
name="find",
|
|
32
|
+
description=(
|
|
33
|
+
"Find files matching a glob pattern (supports ** for recursive search). "
|
|
34
|
+
"Returns file paths only."
|
|
35
|
+
),
|
|
36
|
+
parameters=FindParams,
|
|
37
|
+
execute=_execute,
|
|
38
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from minima_harness.agent.tools import AgentTool, ToolResult, error_result
|
|
11
|
+
from minima_harness.ai.types import TextContent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GrepParams(BaseModel):
|
|
15
|
+
pattern: str
|
|
16
|
+
path: str = "."
|
|
17
|
+
include: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _re_walk(root: Path, pattern: str, include: str | None) -> str:
|
|
21
|
+
try:
|
|
22
|
+
regex = re.compile(pattern)
|
|
23
|
+
except re.error as exc:
|
|
24
|
+
return f"(invalid regex: {exc})"
|
|
25
|
+
glob_pat = include or "*"
|
|
26
|
+
hits: list[str] = []
|
|
27
|
+
for file in root.rglob(glob_pat):
|
|
28
|
+
if not file.is_file():
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
text = file.read_text(encoding="utf-8", errors="replace")
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
for i, line in enumerate(text.splitlines(), start=1):
|
|
35
|
+
if regex.search(line):
|
|
36
|
+
hits.append(f"{file}:{i}:{line}")
|
|
37
|
+
return "\n".join(hits) if hits else "(no matches)"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
41
|
+
assert isinstance(params, GrepParams)
|
|
42
|
+
root = Path(params.path).expanduser()
|
|
43
|
+
if not root.exists():
|
|
44
|
+
return error_result(f"grep: no such path: {root}")
|
|
45
|
+
|
|
46
|
+
if shutil.which("rg"):
|
|
47
|
+
cmd = ["rg", "-n", "--color=never"]
|
|
48
|
+
if params.include:
|
|
49
|
+
cmd += ["-g", params.include]
|
|
50
|
+
cmd += ["--", params.pattern, str(root)]
|
|
51
|
+
proc = await asyncio.create_subprocess_exec(
|
|
52
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
53
|
+
)
|
|
54
|
+
out, err = await proc.communicate()
|
|
55
|
+
if proc.returncode not in (0, 1): # 1 = no matches, 0 = matches
|
|
56
|
+
return error_result(f"grep: {err.decode('utf-8', 'replace').strip()}")
|
|
57
|
+
body = out.decode("utf-8", "replace").strip() or "(no matches)"
|
|
58
|
+
else:
|
|
59
|
+
body = _re_walk(root, params.pattern, params.include)
|
|
60
|
+
|
|
61
|
+
return ToolResult(content=[TextContent(text=body)], details={"path": str(root)})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def grep_tool() -> AgentTool:
|
|
65
|
+
return AgentTool(
|
|
66
|
+
name="grep",
|
|
67
|
+
description=(
|
|
68
|
+
"Search file contents for a regex pattern. Uses ripgrep when available, "
|
|
69
|
+
"else a pure-Python recursive walk. Returns file:line:match lines."
|
|
70
|
+
),
|
|
71
|
+
parameters=GrepParams,
|
|
72
|
+
execute=_execute,
|
|
73
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from minima_harness.agent.tools import AgentTool, ToolResult, error_result
|
|
8
|
+
from minima_harness.ai.types import TextContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LsParams(BaseModel):
|
|
12
|
+
path: str = "."
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
16
|
+
assert isinstance(params, LsParams)
|
|
17
|
+
root = Path(params.path).expanduser()
|
|
18
|
+
if not root.exists():
|
|
19
|
+
return error_result(f"ls: no such path: {root}")
|
|
20
|
+
entries = sorted(root.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
21
|
+
if not entries:
|
|
22
|
+
return ToolResult(content=[TextContent(text="(empty)")])
|
|
23
|
+
lines = [(f"{e.name}/" if e.is_dir() else e.name) for e in entries]
|
|
24
|
+
return ToolResult(content=[TextContent(text="\n".join(lines))], details={"count": len(lines)})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ls_tool() -> AgentTool:
|
|
28
|
+
return AgentTool(
|
|
29
|
+
name="ls",
|
|
30
|
+
description=(
|
|
31
|
+
"List entries in a directory. Directories are suffixed with / and sorted first."
|
|
32
|
+
),
|
|
33
|
+
parameters=LsParams,
|
|
34
|
+
execute=_execute,
|
|
35
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from minima_harness.agent.tools import AgentTool, ToolResult, error_result
|
|
8
|
+
from minima_harness.ai.types import TextContent
|
|
9
|
+
from minima_harness.tools._io import read_lines
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ReadParams(BaseModel):
|
|
13
|
+
path: str
|
|
14
|
+
offset: int = Field(default=1, ge=1)
|
|
15
|
+
limit: int = Field(default=2000, ge=1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
19
|
+
assert isinstance(params, ReadParams)
|
|
20
|
+
p = Path(params.path).expanduser()
|
|
21
|
+
if not p.exists():
|
|
22
|
+
return error_result(f"read: no such file: {p}")
|
|
23
|
+
if p.is_dir():
|
|
24
|
+
return error_result(f"read: is a directory: {p}")
|
|
25
|
+
body, n = read_lines(p, offset=params.offset, limit=params.limit)
|
|
26
|
+
return ToolResult(content=[TextContent(text=body or "(empty)")], details={"lines_read": n})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_tool() -> AgentTool:
|
|
30
|
+
return AgentTool(
|
|
31
|
+
name="read",
|
|
32
|
+
description=(
|
|
33
|
+
"Read a text file from the local filesystem. Returns lines with 1-based "
|
|
34
|
+
"line numbers. Use `offset` and `limit` to page through large files."
|
|
35
|
+
),
|
|
36
|
+
parameters=ReadParams,
|
|
37
|
+
execute=_execute,
|
|
38
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The ``tasks`` tool — the agent's live goal checklist (like Claude Code's TodoWrite).
|
|
2
|
+
|
|
3
|
+
Bound to the session's :class:`~minima_harness.minima.goals.GoalStore` via :func:`tasks_tool`.
|
|
4
|
+
The model calls it to lay out a plan (``set``) and tick items off (``update``) as it works. The
|
|
5
|
+
harness re-injects the list into the system prompt each turn so it stays the "north star".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from minima_harness.agent.tools import AgentTool, ToolResult, error_result
|
|
15
|
+
from minima_harness.ai.types import TextContent
|
|
16
|
+
from minima_harness.minima.goals import GoalStore
|
|
17
|
+
|
|
18
|
+
_MARK = {"completed": "[x]", "in_progress": "[~]", "blocked": "[!]", "pending": "[ ]"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskItem(BaseModel):
|
|
22
|
+
content: str # imperative: "Add OAuth login"
|
|
23
|
+
active_form: str = "" # present-continuous: "Adding OAuth login"
|
|
24
|
+
status: str = "pending" # pending | in_progress | completed | blocked
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TasksParams(BaseModel):
|
|
28
|
+
op: Literal["set", "update", "list"]
|
|
29
|
+
tasks: list[TaskItem] | None = None # op=set: replace the whole list
|
|
30
|
+
task_id: str | None = None # op=update: which task
|
|
31
|
+
status: str | None = None # op=update: new status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render(store: GoalStore) -> str:
|
|
35
|
+
goal = store.goal
|
|
36
|
+
if goal is None or not goal.tasks:
|
|
37
|
+
return "(no tasks)"
|
|
38
|
+
done, total = goal.progress()
|
|
39
|
+
head = f"{done}/{total} done" + (f" · {goal.title}" if goal.title else "")
|
|
40
|
+
lines = [head] + [f" {_MARK.get(t.status, '[ ]')} {t.id} {t.content}" for t in goal.tasks]
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tasks_tool(store: GoalStore) -> AgentTool:
|
|
45
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
46
|
+
assert isinstance(params, TasksParams)
|
|
47
|
+
if params.op == "set":
|
|
48
|
+
store.set_tasks([t.model_dump() for t in (params.tasks or [])])
|
|
49
|
+
elif params.op == "update":
|
|
50
|
+
if not params.task_id or not params.status:
|
|
51
|
+
return error_result("tasks update needs task_id and status")
|
|
52
|
+
if not store.update_task(params.task_id, params.status):
|
|
53
|
+
return error_result(
|
|
54
|
+
f"no task {params.task_id!r} (or bad status); use op=list to see ids"
|
|
55
|
+
)
|
|
56
|
+
# op=list falls through to render
|
|
57
|
+
done, total = store.goal.progress() if store.goal else (0, 0)
|
|
58
|
+
return ToolResult(
|
|
59
|
+
content=[TextContent(text=_render(store))],
|
|
60
|
+
details={"completed": done, "total": total},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return AgentTool(
|
|
64
|
+
name="tasks",
|
|
65
|
+
description=(
|
|
66
|
+
"Maintain the goal checklist. Use it for multi-step work (3+ steps); skip it for "
|
|
67
|
+
"trivial or single-step requests. op='set' replaces the whole list (each task: "
|
|
68
|
+
"content imperative + optional active_form); op='update' sets one task's status by "
|
|
69
|
+
"task_id (pending|in_progress|completed|blocked); op='list' shows the current list. "
|
|
70
|
+
"Keep exactly ONE task in_progress at a time, and mark a task completed ONLY when it "
|
|
71
|
+
"is actually done and verified — never on intent."
|
|
72
|
+
),
|
|
73
|
+
parameters=TasksParams,
|
|
74
|
+
execute=_execute,
|
|
75
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from minima_harness.agent.tools import AgentTool, ToolResult
|
|
8
|
+
from minima_harness.ai.types import TextContent
|
|
9
|
+
from minima_harness.tools._io import write_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WriteParams(BaseModel):
|
|
13
|
+
path: str
|
|
14
|
+
content: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _execute(tool_call_id: str, params, signal, on_update) -> ToolResult: # noqa: ANN001
|
|
18
|
+
assert isinstance(params, WriteParams)
|
|
19
|
+
p = Path(params.path).expanduser()
|
|
20
|
+
n = write_text(p, params.content)
|
|
21
|
+
return ToolResult(
|
|
22
|
+
content=[TextContent(text=f"wrote {n} lines to {p}")],
|
|
23
|
+
details={"bytes": len(params.content)},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_tool() -> AgentTool:
|
|
28
|
+
return AgentTool(
|
|
29
|
+
name="write",
|
|
30
|
+
description=(
|
|
31
|
+
"Create or overwrite a file on the local filesystem. Parent directories "
|
|
32
|
+
"are created automatically. Pass the full intended file contents."
|
|
33
|
+
),
|
|
34
|
+
parameters=WriteParams,
|
|
35
|
+
execute=_execute,
|
|
36
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from minima_harness.session import SessionManager, SessionStore
|
|
7
|
+
from minima_harness.session.format import EntryType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cost_position_for(per_model: dict[str, int]) -> float | None:
|
|
11
|
+
"""Mean normalized price-ladder position over used models (0=cheapest..1=priciest).
|
|
12
|
+
|
|
13
|
+
The local TUI mirror of the server's routing-optimality ``cost_position``: lower means
|
|
14
|
+
the agent is routinely landing on cheaper models within the available pool. Returns
|
|
15
|
+
None when prices can't be resolved (offline / unknown models). Pure + registry-driven.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
from minima_harness.ai.registry import all_models, find_model_by_id
|
|
19
|
+
except Exception: # noqa: BLE001 - registry unavailable
|
|
20
|
+
return None
|
|
21
|
+
prices = [m.cost.input + m.cost.output for m in all_models()]
|
|
22
|
+
if not prices:
|
|
23
|
+
return None
|
|
24
|
+
lo, hi = min(prices), max(prices)
|
|
25
|
+
if hi <= lo:
|
|
26
|
+
return None
|
|
27
|
+
weighted = 0.0
|
|
28
|
+
counted = 0
|
|
29
|
+
for model_id, count in per_model.items():
|
|
30
|
+
model = find_model_by_id(model_id)
|
|
31
|
+
if model is None:
|
|
32
|
+
continue
|
|
33
|
+
price = model.cost.input + model.cost.output
|
|
34
|
+
weighted += ((price - lo) / (hi - lo)) * count
|
|
35
|
+
counted += count
|
|
36
|
+
return round(weighted / counted, 4) if counted else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def aggregate_sessions(cwd: Path, n: int = 10) -> dict[str, Any]:
|
|
40
|
+
"""Aggregate stats from the last ``n`` local session files for this project."""
|
|
41
|
+
mgr = SessionManager()
|
|
42
|
+
summaries = mgr.list_sessions(cwd)
|
|
43
|
+
stats: dict[str, Any] = {
|
|
44
|
+
"sessions": 0,
|
|
45
|
+
"prompts": 0,
|
|
46
|
+
"total_in": 0,
|
|
47
|
+
"total_out": 0,
|
|
48
|
+
"total_cost": 0.0,
|
|
49
|
+
"per_model": {},
|
|
50
|
+
}
|
|
51
|
+
ape_sum = 0.0 # Σ |actual−est| / actual (mean absolute percentage error)
|
|
52
|
+
pred_n = 0 # rows with both an estimate and a realized cost
|
|
53
|
+
band_n = 0 # rows that carried a predicted cost band
|
|
54
|
+
band_hits = 0 # rows where the realized cost landed inside the band
|
|
55
|
+
for s in summaries[:n]: # list_sessions is sorted most-recent-first → take the front n
|
|
56
|
+
store = SessionStore.file_backed(s.path)
|
|
57
|
+
stats["sessions"] += 1
|
|
58
|
+
for e in store.entries:
|
|
59
|
+
if e.type == EntryType.USER:
|
|
60
|
+
stats["prompts"] += 1
|
|
61
|
+
elif e.type == EntryType.ASSISTANT:
|
|
62
|
+
p = e.payload
|
|
63
|
+
stats["total_in"] += p.get("in_tokens", 0)
|
|
64
|
+
stats["total_out"] += p.get("out_tokens", 0)
|
|
65
|
+
actual = p.get("cost", 0.0)
|
|
66
|
+
stats["total_cost"] += actual
|
|
67
|
+
model = p.get("model", "?")
|
|
68
|
+
stats["per_model"][model] = stats["per_model"].get(model, 0) + 1
|
|
69
|
+
# Predictability (guard with .get so pre-Phase-4 rows are simply skipped).
|
|
70
|
+
est = p.get("est_cost")
|
|
71
|
+
if est is not None and actual > 0:
|
|
72
|
+
ape_sum += abs(actual - est) / max(actual, 1e-9)
|
|
73
|
+
pred_n += 1
|
|
74
|
+
low, high = p.get("est_cost_low"), p.get("est_cost_high")
|
|
75
|
+
if low is not None and high is not None and actual > 0:
|
|
76
|
+
band_n += 1
|
|
77
|
+
if low <= actual <= high:
|
|
78
|
+
band_hits += 1
|
|
79
|
+
stats["cost_position"] = cost_position_for(stats["per_model"])
|
|
80
|
+
stats["pred_n"] = pred_n
|
|
81
|
+
stats["cost_mape"] = round(ape_sum / pred_n, 4) if pred_n else None
|
|
82
|
+
stats["band_n"] = band_n
|
|
83
|
+
stats["band_hit_rate"] = round(band_hits / band_n, 4) if band_n else None
|
|
84
|
+
return stats
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_stats(stats: dict[str, Any]) -> str:
|
|
88
|
+
lines = [
|
|
89
|
+
f"sessions: {stats['sessions']}",
|
|
90
|
+
f"prompts: {stats['prompts']}",
|
|
91
|
+
f"tokens: ↑{stats['total_in']} ↓{stats['total_out']}",
|
|
92
|
+
f"cost: ${stats['total_cost']:.4f}",
|
|
93
|
+
]
|
|
94
|
+
if stats["per_model"]:
|
|
95
|
+
model_lines = ", ".join(f"{k}×{v}" for k, v in sorted(stats["per_model"].items()))
|
|
96
|
+
lines.append(f"models: {model_lines}")
|
|
97
|
+
if stats.get("cost_position") is not None:
|
|
98
|
+
lines.append(
|
|
99
|
+
f"cost position: {stats['cost_position']:.2f} (0=cheapest · 1=priciest in pool)"
|
|
100
|
+
)
|
|
101
|
+
if stats.get("cost_mape") is not None:
|
|
102
|
+
lines.append(
|
|
103
|
+
f"cost predictability: MAPE {stats['cost_mape']:.0%} "
|
|
104
|
+
f"over {stats['pred_n']} est-vs-actual turn(s)"
|
|
105
|
+
)
|
|
106
|
+
if stats.get("band_hit_rate") is not None:
|
|
107
|
+
lines.append(
|
|
108
|
+
f"in-range: {stats['band_hit_rate']:.0%} of actuals landed in the predicted "
|
|
109
|
+
f"band ({stats['band_n']} turn(s))"
|
|
110
|
+
)
|
|
111
|
+
return "\n".join(lines)
|