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.
Files changed (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. 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,3 @@
1
+ """Interactive Textual TUI for the Minima harness (a port of PI's interactive mode)."""
2
+
3
+ from __future__ import annotations
@@ -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)