mithai 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.
Files changed (87) hide show
  1. mithai/__init__.py +49 -0
  2. mithai/__main__.py +5 -0
  3. mithai/adapters/__init__.py +0 -0
  4. mithai/adapters/api.py +39 -0
  5. mithai/adapters/base.py +135 -0
  6. mithai/adapters/cli.py +465 -0
  7. mithai/adapters/formatters.py +481 -0
  8. mithai/adapters/slack.py +806 -0
  9. mithai/adapters/slack_http.py +139 -0
  10. mithai/adapters/telegram.py +168 -0
  11. mithai/cli/__init__.py +0 -0
  12. mithai/cli/agent_cmd.py +434 -0
  13. mithai/cli/chat_cmd.py +98 -0
  14. mithai/cli/doctor_cmd.py +328 -0
  15. mithai/cli/init_cmd.py +450 -0
  16. mithai/cli/logs_cmd.py +162 -0
  17. mithai/cli/main.py +68 -0
  18. mithai/cli/run_cmd.py +766 -0
  19. mithai/cli/service_cmd.py +415 -0
  20. mithai/cli/skill_cmd.py +457 -0
  21. mithai/cli/status_cmd.py +96 -0
  22. mithai/cli/style.py +208 -0
  23. mithai/cli/ui_cmd.py +51 -0
  24. mithai/core/__init__.py +0 -0
  25. mithai/core/config.py +457 -0
  26. mithai/core/context.py +35 -0
  27. mithai/core/engine.py +1052 -0
  28. mithai/core/heartbeat.py +144 -0
  29. mithai/core/mcp_manager.py +377 -0
  30. mithai/core/reflection.py +69 -0
  31. mithai/core/session.py +163 -0
  32. mithai/core/skill_loader.py +225 -0
  33. mithai/core/tool_router.py +225 -0
  34. mithai/core/verifier.py +74 -0
  35. mithai/human/__init__.py +0 -0
  36. mithai/human/mcp.py +120 -0
  37. mithai/integrations/__init__.py +0 -0
  38. mithai/integrations/slack.py +533 -0
  39. mithai/llm/__init__.py +0 -0
  40. mithai/llm/_bedrock_translate.py +164 -0
  41. mithai/llm/anthropic.py +187 -0
  42. mithai/llm/base.py +53 -0
  43. mithai/llm/bedrock.py +222 -0
  44. mithai/memory/__init__.py +0 -0
  45. mithai/memory/base.py +84 -0
  46. mithai/memory/filesystem.py +132 -0
  47. mithai/memory/inmemory.py +83 -0
  48. mithai/memory/redis.py +106 -0
  49. mithai/memory/s3.py +133 -0
  50. mithai/skills/http_checker/prompt.md +2 -0
  51. mithai/skills/http_checker/tools.py +52 -0
  52. mithai/skills/kubernetes/prompt.md +13 -0
  53. mithai/skills/kubernetes/tools.py +294 -0
  54. mithai/skills/memory/prompt.md +20 -0
  55. mithai/skills/memory/tools.py +92 -0
  56. mithai/skills/scheduling/prompt.md +29 -0
  57. mithai/skills/scheduling/tools.py +406 -0
  58. mithai/skills/sessions/prompt.md +3 -0
  59. mithai/skills/sessions/tools.py +77 -0
  60. mithai/skills/shell/prompt.md +4 -0
  61. mithai/skills/shell/tools.py +100 -0
  62. mithai/state/__init__.py +0 -0
  63. mithai/state/base.py +33 -0
  64. mithai/state/filesystem.py +45 -0
  65. mithai/state/memory.py +27 -0
  66. mithai/telemetry/__init__.py +6 -0
  67. mithai/telemetry/metrics.py +151 -0
  68. mithai/telemetry/tracer.py +172 -0
  69. mithai/ui/__init__.py +1 -0
  70. mithai/ui/app.py +360 -0
  71. mithai/ui/data.py +179 -0
  72. mithai/ui/static/htmx.min.js +1 -0
  73. mithai/ui/templates/approvals.html +331 -0
  74. mithai/ui/templates/base.html +702 -0
  75. mithai/ui/templates/config.html +27 -0
  76. mithai/ui/templates/dashboard.html +104 -0
  77. mithai/ui/templates/memory.html +75 -0
  78. mithai/ui/templates/memory_file.html +29 -0
  79. mithai/ui/templates/session_detail.html +112 -0
  80. mithai/ui/templates/sessions.html +56 -0
  81. mithai/ui/templates/skills.html +61 -0
  82. mithai-0.12.0.dist-info/METADATA +358 -0
  83. mithai-0.12.0.dist-info/RECORD +87 -0
  84. mithai-0.12.0.dist-info/WHEEL +4 -0
  85. mithai-0.12.0.dist-info/entry_points.txt +2 -0
  86. mithai-0.12.0.dist-info/licenses/AUTHORS +11 -0
  87. mithai-0.12.0.dist-info/licenses/LICENSE +190 -0
mithai/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """mithai — AI agent framework for organizations."""
2
+
3
+ import platform
4
+ import sys
5
+ from importlib.metadata import version as _pkg_version
6
+ from pathlib import Path
7
+
8
+ try:
9
+ __version__ = _pkg_version("mithai")
10
+ except Exception:
11
+ __version__ = "0.0.0-dev"
12
+
13
+
14
+ def _platform_tag() -> str:
15
+ """Return a platform tag like 'darwin-arm64'."""
16
+ system = platform.system().lower()
17
+ arch = platform.machine()
18
+ if arch == "x86_64":
19
+ arch = "amd64"
20
+ return f"{system}-{arch}"
21
+
22
+
23
+ def get_version_string() -> str:
24
+ """Return version string with platform info."""
25
+ return f"{__version__} ({_platform_tag()})"
26
+
27
+
28
+ def get_bundled_path() -> Path:
29
+ """Return the base path for bundled data files.
30
+
31
+ When running from a PyInstaller binary, data files are extracted
32
+ to sys._MEIPASS. When running from source, use the repo root. When
33
+ installed from a wheel, bundled skills live under the mithai package.
34
+ """
35
+ if getattr(sys, "_MEIPASS", None):
36
+ return Path(sys._MEIPASS)
37
+
38
+ package_dir = Path(__file__).resolve().parent
39
+
40
+ # Wheel install: pyproject force-includes top-level skills as mithai/skills.
41
+ if (package_dir / "skills").exists():
42
+ return package_dir
43
+
44
+ # Source checkout: repo root is two levels up from src/mithai/.
45
+ source_root = package_dir.parent.parent
46
+ if (source_root / "skills").exists():
47
+ return source_root
48
+
49
+ return package_dir
mithai/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running mithai as: python -m mithai"""
2
+
3
+ from mithai.cli.main import cli
4
+
5
+ cli()
File without changes
mithai/adapters/api.py ADDED
@@ -0,0 +1,39 @@
1
+ """Headless API adapter — process stays alive while /api/trigger handles all input."""
2
+
3
+ import logging
4
+ import threading
5
+
6
+ from mithai.adapters.base import Adapter, MessageHandler, OutgoingMessage
7
+ from mithai.human.mcp import HumanRequest
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class APIAdapter(Adapter):
13
+ """Blocks forever so the embedded API server (MITHAI_UI_PORT) can serve all traffic.
14
+
15
+ No stdin, no Slack connection required. Responses are logged at INFO level.
16
+ Human approval requests are auto-denied (no interactive terminal).
17
+ """
18
+
19
+ def __init__(self):
20
+ self._stop_event = threading.Event()
21
+
22
+ def start(self, on_message: MessageHandler, on_channel_join=None,
23
+ on_observe=None, on_bot_reply=None) -> None:
24
+ self._stop_event.wait()
25
+
26
+ def stop(self) -> None:
27
+ self._stop_event.set()
28
+
29
+ def send(self, message: OutgoingMessage) -> None:
30
+ text = message.text
31
+ preview = text[:200] + ("…" if len(text) > 200 else "")
32
+ logger.info("[api] → %s", preview)
33
+
34
+ def request_human_approval(self, request: HumanRequest, channel_id: str) -> bool:
35
+ logger.warning(
36
+ "api adapter: auto-denying approval for '%s' (no interactive terminal)",
37
+ request.tool_name,
38
+ )
39
+ return False
@@ -0,0 +1,135 @@
1
+ """Abstract messaging adapter interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Callable, Optional
8
+ from uuid import uuid4
9
+
10
+ if TYPE_CHECKING:
11
+ from mithai.human.mcp import HumanRequest
12
+
13
+ # Callback type: (message, adapter) -> response text
14
+ MessageHandler = Callable[["IncomingMessage", "Adapter"], str]
15
+
16
+ # Callback type: (channel_id, channel_name) -> response text to post (or None)
17
+ ChannelJoinHandler = Callable[[str, str], Optional[str]]
18
+
19
+ # Callback type: (message,) -> None — called for observed messages (no reply)
20
+ ChannelObserveHandler = Callable[["IncomingMessage"], None]
21
+
22
+ # Callback type: (channel_id, user_id, text, message_id) -> None — log bot reply to channel_context
23
+ BotReplyHandler = Callable[[str, str, str, str], None]
24
+
25
+
26
+ @dataclass
27
+ class ImageAttachment:
28
+ """A base64-encoded image attached to a message."""
29
+ data: str # base64-encoded bytes
30
+ media_type: str # e.g. "image/png", "image/jpeg"
31
+
32
+
33
+ @dataclass
34
+ class IncomingMessage:
35
+ """Platform-agnostic incoming message."""
36
+
37
+ text: str
38
+ channel_id: str
39
+ user_id: str
40
+ platform: str = ""
41
+ message_id: str = field(default_factory=lambda: uuid4().hex[:12])
42
+ thread_id: str | None = None # Slack thread_ts, etc.
43
+ images: list[ImageAttachment] = field(default_factory=list)
44
+ non_image_files: list[str] = field(default_factory=list) # filenames of skipped non-image attachments
45
+ extra_system_prompt: str = "" # schedule-specific instructions, appended to system prompt for this turn only
46
+
47
+
48
+ @dataclass
49
+ class OutgoingMessage:
50
+ """Platform-agnostic response to send."""
51
+
52
+ text: str
53
+ channel_id: str
54
+
55
+
56
+ class Adapter(ABC):
57
+ """
58
+ Abstract messaging adapter.
59
+
60
+ Each platform (Slack, Telegram, CLI) implements this.
61
+ Adapters handle receiving messages, sending responses,
62
+ and presenting Human MCP requests.
63
+ """
64
+
65
+ @abstractmethod
66
+ def start(self, on_message: MessageHandler, on_channel_join: "ChannelJoinHandler | None" = None,
67
+ on_observe: "ChannelObserveHandler | None" = None,
68
+ on_bot_reply: "BotReplyHandler | None" = None) -> None:
69
+ """
70
+ Start listening for messages.
71
+
72
+ on_message(message, adapter) is called for each incoming message.
73
+ The adapter passes itself so the engine can route Human MCP
74
+ approvals back through the correct platform.
75
+
76
+ on_bot_reply is called after the bot sends a reply (used by Slack
77
+ adapters to log outgoing messages to channel context).
78
+ """
79
+ ...
80
+
81
+ @abstractmethod
82
+ def stop(self) -> None:
83
+ """Clean shutdown."""
84
+ ...
85
+
86
+ @abstractmethod
87
+ def send(self, message: OutgoingMessage) -> None:
88
+ """Send a message to the platform."""
89
+ ...
90
+
91
+ @abstractmethod
92
+ def request_human_approval(self, request: HumanRequest, channel_id: str) -> bool:
93
+ """
94
+ Present a Human MCP request and wait for response.
95
+
96
+ For "approve": show action details, wait for yes/no.
97
+ For "confirm": show action details, wait for confirmation text.
98
+
99
+ Returns True if approved, False if denied or timed out.
100
+ """
101
+ ...
102
+
103
+ # ── Optional status callbacks (no-op by default) ──
104
+ # Engine calls these to let the adapter show progress feedback.
105
+
106
+ def on_thinking_start(self) -> None:
107
+ """Called before an LLM call starts."""
108
+
109
+ def on_thinking_end(self, elapsed_s: float) -> None:
110
+ """Called when an LLM call completes."""
111
+
112
+ def on_tool_start(self, tool_name: str, tool_input: dict) -> None:
113
+ """Called before a tool is executed."""
114
+
115
+ def before_tool_call(self, tool_name: str, tool_input: dict) -> str | None:
116
+ """Return a synthetic tool result to skip execution, or None to continue."""
117
+ return None
118
+
119
+ def on_tool_end(self, tool_name: str, elapsed_s: float, approved: bool) -> None:
120
+ """Called after a tool finishes executing."""
121
+
122
+ def on_tool_result(self, tool_name: str, tool_input: dict, result: str) -> None:
123
+ """Called after a tool returns a result."""
124
+
125
+ def on_synthesizing(self) -> None:
126
+ """Called before the follow-up LLM call after tool results."""
127
+
128
+ def fetch_thread_context(self, channel_id: str, thread_ts: str) -> list[str] | None:
129
+ """Fetch historical messages from a thread for backfill context.
130
+
131
+ Called when the agent is @mentioned in a thread for the first time and
132
+ needs to see what was discussed above. Returns formatted 'name: text'
133
+ lines oldest-first, or None if not supported by this adapter.
134
+ """
135
+ return None
mithai/adapters/cli.py ADDED
@@ -0,0 +1,465 @@
1
+ """CLI/terminal adapter for local development and testing."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+
7
+ from prompt_toolkit import PromptSession
8
+ from prompt_toolkit.completion import Completer, Completion
9
+ from prompt_toolkit.formatted_text import ANSI
10
+ from rich.console import Console
11
+ from rich.live import Live
12
+ from rich.markdown import Markdown
13
+ from rich.panel import Panel
14
+ from rich.text import Text
15
+
16
+ from mithai.adapters.base import Adapter, IncomingMessage, MessageHandler, OutgoingMessage
17
+ from mithai.adapters.formatters import CLIFormatter
18
+ from mithai.human.mcp import HumanRequest
19
+
20
+ _console = Console()
21
+
22
+ # Slash commands with descriptions for autocomplete
23
+ _SLASH_COMMANDS = {
24
+ "/help": "Show available commands",
25
+ "/skills": "List loaded skills and tool counts",
26
+ "/sessions": "List recent conversation sessions",
27
+ "/memory": "Show memory files",
28
+ "/clear": "Clear the terminal screen",
29
+ "/status": "Show engine status",
30
+ }
31
+
32
+
33
+ class _SlashCompleter(Completer):
34
+ """Tab-complete slash commands when input starts with '/'."""
35
+
36
+ def get_completions(self, document, complete_event):
37
+ text = document.text_before_cursor
38
+ if not text.startswith("/"):
39
+ return
40
+ for cmd, desc in _SLASH_COMMANDS.items():
41
+ if cmd.startswith(text):
42
+ yield Completion(cmd, start_position=-len(text), display_meta=desc)
43
+
44
+
45
+ # ── In-chat slash commands ──
46
+
47
+ def _handle_slash_command(text: str, engine=None) -> str | None:
48
+ """Handle in-chat slash commands. Returns response text or None if not a command."""
49
+ cmd = text.strip().lower()
50
+ parts = cmd.split(None, 1)
51
+ command = parts[0]
52
+
53
+ if command == "/help":
54
+ return (
55
+ "**Chat commands:**\n"
56
+ "| Command | Description |\n"
57
+ "|---------|-------------|\n"
58
+ "| `/help` | Show this help |\n"
59
+ "| `/skills` | List loaded skills and tool counts |\n"
60
+ "| `/sessions` | List recent conversation sessions |\n"
61
+ "| `/memory` | Show memory files |\n"
62
+ "| `/clear` | Clear the screen |\n"
63
+ "| `/status` | Show engine status |\n"
64
+ "| `quit` | Exit the chat |"
65
+ )
66
+
67
+ if command == "/skills" and engine:
68
+ lines = ["**Loaded skills:**\n"]
69
+ for name, skill in sorted(engine._skills.items()):
70
+ tool_count = len(skill.tools)
71
+ mcp_count = sum(
72
+ len(e.get("tools", [])) if isinstance(e.get("tools"), list) else 0
73
+ for e in skill.mcp_tools
74
+ )
75
+ has_wildcard = any(e.get("tools") == "*" for e in skill.mcp_tools)
76
+ label = f"{tool_count} tools"
77
+ if mcp_count:
78
+ label += f" + {mcp_count} MCP"
79
+ if has_wildcard:
80
+ label += " + MCP(*)"
81
+ lines.append(f"- **{name}** — {label}")
82
+ return "\n".join(lines)
83
+
84
+ if command == "/sessions" and engine:
85
+ sessions = engine._sessions.list_sessions(limit=10)
86
+ if not sessions:
87
+ return "*No sessions yet.*"
88
+ lines = ["**Recent sessions:**\n"]
89
+ for s in sessions:
90
+ ts = s["updated_at"][:16].replace("T", " ") if s.get("updated_at") else "?"
91
+ preview = s.get("last_message", "")[:60]
92
+ lines.append(f"- `{s['session_id']}` ({s['turn_count']} turns, {ts}) — {preview}")
93
+ return "\n".join(lines)
94
+
95
+ if command == "/memory" and engine and engine._memory:
96
+ files = engine._memory.list("**/*")
97
+ if not files:
98
+ return "*No memory files.*"
99
+ lines = ["**Memory files:**\n"]
100
+ for f in sorted(files):
101
+ lines.append(f"- `{f}`")
102
+ return "\n".join(lines)
103
+
104
+ if command == "/clear":
105
+ _console.clear()
106
+ return None # No output, just clear
107
+
108
+ if command == "/status" and engine:
109
+ parts_list = []
110
+ parts_list.append(f"**Agent:** {engine._agent_id or 'default'}")
111
+ parts_list.append(f"**Skills:** {len(engine._skills)} loaded")
112
+ if engine._mcp_manager:
113
+ servers = list(engine._mcp_manager._configs.keys())
114
+ connected = [
115
+ name for name, entry in engine._mcp_manager._sessions.items()
116
+ ]
117
+ parts_list.append(f"**MCP servers:** {', '.join(servers) if servers else 'none'}")
118
+ if connected:
119
+ parts_list.append(f"**MCP connected:** {', '.join(connected)}")
120
+ if engine._memory:
121
+ parts_list.append("**Memory:** active")
122
+ llm_model = engine._llm_config.get("model", "?")
123
+ parts_list.append(f"**LLM:** {engine._llm_config.get('provider', '?')} / {llm_model}")
124
+ return "\n".join(parts_list)
125
+
126
+ return None # Not a recognized command
127
+
128
+
129
+ def _flush_stdin():
130
+ """Discard any buffered stdin input (keystrokes during LLM processing).
131
+
132
+ When the LLM takes seconds to respond, users may press Enter or type
133
+ while waiting. Those keystrokes accumulate in the terminal buffer and
134
+ would be read as empty prompts (you> you> you> ...) or auto-deny
135
+ approval prompts. Flushing clears that buffer.
136
+ """
137
+ try:
138
+ import termios
139
+ termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
140
+ except (ImportError, termios.error, ValueError, OSError):
141
+ # Windows or non-tty — best-effort: drain anything readable
142
+ try:
143
+ import select
144
+ while select.select([sys.stdin], [], [], 0)[0]:
145
+ sys.stdin.readline()
146
+ except Exception:
147
+ pass
148
+
149
+
150
+ class CLIAdapter(Adapter):
151
+ """
152
+ Interactive terminal REPL, with piped-stdin support.
153
+
154
+ When stdin is a TTY the adapter runs an interactive REPL.
155
+ When stdin is piped (e.g. ``echo "msg" | mithai run``) it reads one
156
+ message, runs the full conversation loop, writes the final plain-text
157
+ response to stdout, and exits.
158
+ """
159
+
160
+ _SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
161
+
162
+ def __init__(self):
163
+ self._running = False
164
+ self._engine = None
165
+ self._formatter = CLIFormatter()
166
+ self._thinking_start: float | None = None
167
+ self._live: Live | None = None
168
+ self._piped = not sys.stdin.isatty()
169
+ if not self._piped:
170
+ self._prompt_session = PromptSession(
171
+ completer=_SlashCompleter(),
172
+ complete_while_typing=True,
173
+ )
174
+
175
+ def set_engine(self, engine) -> None:
176
+ """Give the adapter a reference to the engine for slash commands."""
177
+ self._engine = engine
178
+
179
+ def start(self, on_message: MessageHandler, on_channel_join=None, on_observe=None,
180
+ on_bot_reply=None) -> None:
181
+ if self._piped:
182
+ self._start_piped(on_message)
183
+ else:
184
+ self._start_interactive(on_message)
185
+
186
+ def _start_piped(self, on_message: MessageHandler) -> None:
187
+ """Read one message from stdin, process through the full conversation loop,
188
+ write the final plain-text response to stdout, and exit.
189
+
190
+ Stdin may be plain text or a JSON object. JSON form:
191
+ {"text": "...", "system_prompt_append": "...", "channel_id": "..."}
192
+ Unknown keys are ignored. Plain text is used as-is.
193
+ """
194
+ raw = sys.stdin.read().strip()
195
+ if not raw:
196
+ return
197
+
198
+ text = raw
199
+ extra_system_prompt = ""
200
+ channel_id = "cli"
201
+ try:
202
+ data = json.loads(raw)
203
+ if isinstance(data, dict):
204
+ text = data.get("text", raw)
205
+ extra_system_prompt = data.get("system_prompt_append", "")
206
+ channel_id = data.get("channel_id", "cli")
207
+ except (ValueError, TypeError):
208
+ pass # not JSON — treat as plain text
209
+
210
+ message = IncomingMessage(
211
+ text=text,
212
+ channel_id=channel_id,
213
+ user_id="local",
214
+ platform="cli",
215
+ extra_system_prompt=extra_system_prompt,
216
+ )
217
+
218
+ try:
219
+ response = on_message(message, self)
220
+ except Exception as exc:
221
+ print(f"Error: {exc}", file=sys.stderr)
222
+ raise SystemExit(1) from exc
223
+
224
+ if response:
225
+ print(response, flush=True)
226
+
227
+ def _start_interactive(self, on_message: MessageHandler) -> None:
228
+ """Run the interactive REPL (original behavior)."""
229
+ self._running = True
230
+ _console.print(
231
+ "\n [bright_magenta bold]mithai[/] [muted]ready · type [white]quit[/white] to exit · [white]/help[/white] for commands[/]\n"
232
+ )
233
+
234
+ # Build ANSI prompt using Rich's rendering
235
+ prompt_ansi = ANSI("\x1b[1;96myou>\x1b[0m ")
236
+
237
+ while self._running:
238
+ try:
239
+ text = self._prompt_session.prompt(prompt_ansi).strip()
240
+ except (EOFError, KeyboardInterrupt):
241
+ _console.print()
242
+ break
243
+
244
+ if not text:
245
+ continue
246
+ if text.lower() in ("quit", "exit", "q"):
247
+ break
248
+
249
+ # Handle in-chat slash commands
250
+ if text.startswith("/"):
251
+ result = _handle_slash_command(text, engine=self._engine)
252
+ if result:
253
+ _console.print()
254
+ try:
255
+ _console.print(Panel(
256
+ Markdown(result),
257
+ title="[bright_magenta]mithai[/]",
258
+ border_style="bright_magenta",
259
+ padding=(1, 2),
260
+ ))
261
+ except Exception:
262
+ _console.print(result)
263
+ _console.print()
264
+ continue
265
+
266
+ message = IncomingMessage(
267
+ text=text,
268
+ channel_id="cli",
269
+ user_id="local",
270
+ platform="cli",
271
+ )
272
+
273
+ response = on_message(message, self)
274
+
275
+ # Flush any keystrokes buffered while the LLM was processing
276
+ _flush_stdin()
277
+
278
+ _console.print()
279
+ for chunk in self._formatter.format(response):
280
+ try:
281
+ md = Markdown(chunk)
282
+ _console.print(Panel(
283
+ md,
284
+ title="[bright_magenta]mithai[/]",
285
+ border_style="bright_magenta",
286
+ padding=(1, 2),
287
+ ))
288
+ except Exception:
289
+ _console.print(f"[bright_magenta]mithai>[/] {chunk}")
290
+ _console.print()
291
+
292
+ def stop(self) -> None:
293
+ self._running = False
294
+
295
+ def send(self, message: OutgoingMessage) -> None:
296
+ if self._piped:
297
+ # In piped mode, write plain text to stdout (no Rich formatting)
298
+ print(message.text, flush=True)
299
+ return
300
+ for chunk in self._formatter.format(message.text):
301
+ try:
302
+ md = Markdown(chunk)
303
+ _console.print(Panel(
304
+ md,
305
+ title="[bright_magenta]mithai[/]",
306
+ border_style="bright_magenta",
307
+ padding=(1, 2),
308
+ ))
309
+ except Exception:
310
+ _console.print(f"[bright_magenta]mithai>[/] {chunk}")
311
+
312
+ def request_human_approval(self, request: HumanRequest, channel_id: str) -> bool:
313
+ if self._piped:
314
+ print(
315
+ f"Error: tool '{request.tool_name}' requires human approval "
316
+ "but stdin is not a terminal — auto-denying.",
317
+ file=sys.stderr,
318
+ )
319
+ return False
320
+ # Stop any active spinner before showing approval dialog
321
+ self._stop_live()
322
+ # Flush buffered keystrokes so stale Enter presses don't auto-deny
323
+ _flush_stdin()
324
+
325
+ _console.print()
326
+ level_color = "yellow" if request.level == "approve" else "red"
327
+ _console.print(Panel(
328
+ request.description,
329
+ title=f"[{level_color} bold]Human Approval Required [{request.level.upper()}][/]",
330
+ border_style=level_color,
331
+ padding=(1, 2),
332
+ ))
333
+
334
+ if request.level == "confirm":
335
+ confirm_text = _extract_confirm_token(request)
336
+ _console.print(
337
+ f" Type [bold]{confirm_text}[/] to confirm, or anything else to deny:"
338
+ )
339
+ try:
340
+ answer = _console.input(" [yellow]>[/] ").strip()
341
+ except (EOFError, KeyboardInterrupt):
342
+ _console.print(" [red]Denied.[/]")
343
+ return False
344
+ approved = answer == confirm_text
345
+ else:
346
+ try:
347
+ answer = _console.input(" [yellow]Approve? [y/N]:[/] ").strip().lower()
348
+ except (EOFError, KeyboardInterrupt):
349
+ _console.print(" [red]Denied.[/]")
350
+ return False
351
+ approved = answer in ("y", "yes")
352
+
353
+ if approved:
354
+ _console.print(" [green]Approved.[/]\n")
355
+ else:
356
+ _console.print(" [red]Denied.[/]\n")
357
+ return approved
358
+
359
+
360
+ # ── Status callbacks for progress feedback ──
361
+
362
+ def on_thinking_start(self) -> None:
363
+ if self._piped:
364
+ return
365
+ self._thinking_start = time.monotonic()
366
+ self._live = Live(
367
+ Text(" ● thinking...", style="bright_magenta"),
368
+ console=_console,
369
+ refresh_per_second=4,
370
+ transient=True,
371
+ )
372
+ self._live.start()
373
+ self._spin_thread_running = True
374
+ import threading
375
+ self._spin_thread = threading.Thread(target=self._spin_loop, daemon=True)
376
+ self._spin_thread.start()
377
+
378
+ def _spin_loop(self) -> None:
379
+ """Update the spinner with elapsed time."""
380
+ idx = 0
381
+ while self._spin_thread_running and self._live:
382
+ elapsed = time.monotonic() - (self._thinking_start or time.monotonic())
383
+ frame = self._SPINNER_FRAMES[idx % len(self._SPINNER_FRAMES)]
384
+ try:
385
+ self._live.update(
386
+ Text(f" {frame} thinking... {elapsed:.0f}s", style="bright_magenta")
387
+ )
388
+ except Exception:
389
+ break
390
+ idx += 1
391
+ time.sleep(0.15)
392
+
393
+ def _stop_live(self) -> None:
394
+ """Stop the live spinner."""
395
+ self._spin_thread_running = False
396
+ if self._live:
397
+ try:
398
+ self._live.stop()
399
+ except Exception:
400
+ pass
401
+ self._live = None
402
+
403
+ def on_thinking_end(self, elapsed_s: float) -> None:
404
+ self._stop_live()
405
+
406
+ def on_tool_start(self, tool_name: str, tool_input: dict) -> None:
407
+ if self._piped:
408
+ return
409
+ self._stop_live()
410
+ # Show a compact preview of what's being called
411
+ preview = ""
412
+ if "command" in tool_input:
413
+ preview = f" `{tool_input['command'][:60]}`"
414
+ elif "repo" in tool_input:
415
+ preview = f" {tool_input['repo']}"
416
+ elif "namespace" in tool_input:
417
+ preview = f" ns={tool_input['namespace']}"
418
+ _console.print(f" [cyan]▸[/] [dim]tool:[/] {tool_name}{preview}")
419
+ self._thinking_start = time.monotonic()
420
+
421
+ def on_tool_end(self, tool_name: str, elapsed_s: float, approved: bool) -> None:
422
+ if self._piped:
423
+ return
424
+ if approved:
425
+ _console.print(f" [green]✓[/] [dim]{tool_name}[/] [muted]({elapsed_s:.1f}s)[/]")
426
+ else:
427
+ _console.print(f" [red]✗[/] [dim]{tool_name} denied[/]")
428
+
429
+ def on_synthesizing(self) -> None:
430
+ if self._piped:
431
+ return
432
+ self._thinking_start = time.monotonic()
433
+ self._live = Live(
434
+ Text(" ● synthesizing response...", style="bright_magenta"),
435
+ console=_console,
436
+ refresh_per_second=4,
437
+ transient=True,
438
+ )
439
+ self._live.start()
440
+ self._spin_thread_running = True
441
+ import threading
442
+ self._spin_thread = threading.Thread(target=self._synth_spin_loop, daemon=True)
443
+ self._spin_thread.start()
444
+
445
+ def _synth_spin_loop(self) -> None:
446
+ idx = 0
447
+ while self._spin_thread_running and self._live:
448
+ elapsed = time.monotonic() - (self._thinking_start or time.monotonic())
449
+ frame = self._SPINNER_FRAMES[idx % len(self._SPINNER_FRAMES)]
450
+ try:
451
+ self._live.update(
452
+ Text(f" {frame} synthesizing response... {elapsed:.0f}s", style="bright_magenta")
453
+ )
454
+ except Exception:
455
+ break
456
+ idx += 1
457
+ time.sleep(0.15)
458
+
459
+
460
+ def _extract_confirm_token(request: HumanRequest) -> str:
461
+ """Extract a confirmation token from tool input for the confirm level."""
462
+ for value in request.tool_input.values():
463
+ if isinstance(value, str) and value:
464
+ return value
465
+ return request.tool_name.split("__")[-1]