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.
- mithai/__init__.py +49 -0
- mithai/__main__.py +5 -0
- mithai/adapters/__init__.py +0 -0
- mithai/adapters/api.py +39 -0
- mithai/adapters/base.py +135 -0
- mithai/adapters/cli.py +465 -0
- mithai/adapters/formatters.py +481 -0
- mithai/adapters/slack.py +806 -0
- mithai/adapters/slack_http.py +139 -0
- mithai/adapters/telegram.py +168 -0
- mithai/cli/__init__.py +0 -0
- mithai/cli/agent_cmd.py +434 -0
- mithai/cli/chat_cmd.py +98 -0
- mithai/cli/doctor_cmd.py +328 -0
- mithai/cli/init_cmd.py +450 -0
- mithai/cli/logs_cmd.py +162 -0
- mithai/cli/main.py +68 -0
- mithai/cli/run_cmd.py +766 -0
- mithai/cli/service_cmd.py +415 -0
- mithai/cli/skill_cmd.py +457 -0
- mithai/cli/status_cmd.py +96 -0
- mithai/cli/style.py +208 -0
- mithai/cli/ui_cmd.py +51 -0
- mithai/core/__init__.py +0 -0
- mithai/core/config.py +457 -0
- mithai/core/context.py +35 -0
- mithai/core/engine.py +1052 -0
- mithai/core/heartbeat.py +144 -0
- mithai/core/mcp_manager.py +377 -0
- mithai/core/reflection.py +69 -0
- mithai/core/session.py +163 -0
- mithai/core/skill_loader.py +225 -0
- mithai/core/tool_router.py +225 -0
- mithai/core/verifier.py +74 -0
- mithai/human/__init__.py +0 -0
- mithai/human/mcp.py +120 -0
- mithai/integrations/__init__.py +0 -0
- mithai/integrations/slack.py +533 -0
- mithai/llm/__init__.py +0 -0
- mithai/llm/_bedrock_translate.py +164 -0
- mithai/llm/anthropic.py +187 -0
- mithai/llm/base.py +53 -0
- mithai/llm/bedrock.py +222 -0
- mithai/memory/__init__.py +0 -0
- mithai/memory/base.py +84 -0
- mithai/memory/filesystem.py +132 -0
- mithai/memory/inmemory.py +83 -0
- mithai/memory/redis.py +106 -0
- mithai/memory/s3.py +133 -0
- mithai/skills/http_checker/prompt.md +2 -0
- mithai/skills/http_checker/tools.py +52 -0
- mithai/skills/kubernetes/prompt.md +13 -0
- mithai/skills/kubernetes/tools.py +294 -0
- mithai/skills/memory/prompt.md +20 -0
- mithai/skills/memory/tools.py +92 -0
- mithai/skills/scheduling/prompt.md +29 -0
- mithai/skills/scheduling/tools.py +406 -0
- mithai/skills/sessions/prompt.md +3 -0
- mithai/skills/sessions/tools.py +77 -0
- mithai/skills/shell/prompt.md +4 -0
- mithai/skills/shell/tools.py +100 -0
- mithai/state/__init__.py +0 -0
- mithai/state/base.py +33 -0
- mithai/state/filesystem.py +45 -0
- mithai/state/memory.py +27 -0
- mithai/telemetry/__init__.py +6 -0
- mithai/telemetry/metrics.py +151 -0
- mithai/telemetry/tracer.py +172 -0
- mithai/ui/__init__.py +1 -0
- mithai/ui/app.py +360 -0
- mithai/ui/data.py +179 -0
- mithai/ui/static/htmx.min.js +1 -0
- mithai/ui/templates/approvals.html +331 -0
- mithai/ui/templates/base.html +702 -0
- mithai/ui/templates/config.html +27 -0
- mithai/ui/templates/dashboard.html +104 -0
- mithai/ui/templates/memory.html +75 -0
- mithai/ui/templates/memory_file.html +29 -0
- mithai/ui/templates/session_detail.html +112 -0
- mithai/ui/templates/sessions.html +56 -0
- mithai/ui/templates/skills.html +61 -0
- mithai-0.12.0.dist-info/METADATA +358 -0
- mithai-0.12.0.dist-info/RECORD +87 -0
- mithai-0.12.0.dist-info/WHEEL +4 -0
- mithai-0.12.0.dist-info/entry_points.txt +2 -0
- mithai-0.12.0.dist-info/licenses/AUTHORS +11 -0
- 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
|
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
|
mithai/adapters/base.py
ADDED
|
@@ -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]
|