minmo 0.1.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.
minmo/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from minmo.agent import VoiceAgent
2
+ from minmo.errors import (
3
+ MinmoError,
4
+ MinmoSchemaError,
5
+ MinmoDeployError,
6
+ MinmoAuthError,
7
+ )
8
+
9
+ __all__ = [
10
+ "VoiceAgent",
11
+ "MinmoError",
12
+ "MinmoSchemaError",
13
+ "MinmoDeployError",
14
+ "MinmoAuthError",
15
+ ]
minmo/agent.py ADDED
@@ -0,0 +1,179 @@
1
+ import json
2
+ import os
3
+ import socket
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Callable, Optional
10
+
11
+ from minmo.errors import MinmoDeployError, MinmoSchemaError
12
+ from minmo.logging import write_session_log
13
+ from minmo.schema import function_to_tool_schema
14
+
15
+ _VALID_TRANSPORTS = ("http", "client")
16
+
17
+
18
+ @dataclass
19
+ class Tool:
20
+ name: str
21
+ description: str
22
+ parameters: dict
23
+ func: Callable
24
+ transport: str = "http"
25
+
26
+
27
+ class VoiceAgent:
28
+ def __init__(
29
+ self,
30
+ prompt: str,
31
+ api_key: str,
32
+ name: str = "minmo-agent",
33
+ llm: Optional[dict] = None,
34
+ provider=None,
35
+ provider_options: Optional[dict] = None,
36
+ log_path: Optional[str] = None,
37
+ on_session_end: Optional[Callable] = None,
38
+ ):
39
+ if not api_key:
40
+ raise ValueError("api_key is required and cannot be empty.")
41
+
42
+ if llm is not None:
43
+ missing = [
44
+ key for key in ("base_url", "model", "api_key") if not llm.get(key)
45
+ ]
46
+ if missing:
47
+ raise ValueError(
48
+ f"llm is missing required key(s): {', '.join(missing)}. "
49
+ "minmo requires base_url, model, and api_key when llm is given."
50
+ )
51
+
52
+ self.prompt = prompt
53
+ self.api_key = api_key
54
+ self.name = name
55
+ self.llm = llm
56
+ self.provider_options = provider_options or {}
57
+ self.log_path = log_path
58
+ self.on_session_end = on_session_end
59
+ self.tools: dict[str, Tool] = {}
60
+ self.agent_id: Optional[str] = None
61
+
62
+ if provider is None:
63
+ from minmo.providers.assemblyai import AssemblyAIProvider
64
+
65
+ provider = AssemblyAIProvider()
66
+ self.provider = provider
67
+
68
+ def tool(
69
+ self, func: Optional[Callable] = None, *, transport: str = "http"
70
+ ) -> Callable:
71
+ if transport not in _VALID_TRANSPORTS:
72
+ raise MinmoSchemaError(
73
+ f"transport must be one of {_VALID_TRANSPORTS!r}, got {transport!r}."
74
+ )
75
+
76
+ def register(f: Callable) -> Callable:
77
+ schema = function_to_tool_schema(f)
78
+ self.tools[schema["name"]] = Tool(
79
+ name=schema["name"],
80
+ description=schema["description"],
81
+ parameters=schema["parameters"],
82
+ func=f,
83
+ transport=transport,
84
+ )
85
+ return f
86
+
87
+ if func is not None:
88
+ return register(func)
89
+ return register
90
+
91
+ def deploy(self, local: bool = False, host_url: Optional[str] = None) -> dict:
92
+ self.provider.validate_tools(list(self.tools.values()))
93
+
94
+ needs_base = any(t.transport == "http" for t in self.tools.values())
95
+
96
+ base = None
97
+ if needs_base:
98
+ if local:
99
+ base = self._start_local_server_and_tunnel()
100
+ else:
101
+ base = host_url or os.environ.get("MINMO_HOST_URL")
102
+ if not base:
103
+ raise MinmoDeployError(
104
+ "local=False requires host_url or the MINMO_HOST_URL env var, "
105
+ "pointing at your already-deployed tool server, since at least one "
106
+ "registered tool uses transport='http'."
107
+ )
108
+
109
+ self._snapshot_history()
110
+
111
+ record = self.provider.deploy(
112
+ name=self.name,
113
+ prompt=self.prompt,
114
+ tools=list(self.tools.values()),
115
+ base=base,
116
+ llm=self.llm,
117
+ provider_options=self.provider_options,
118
+ api_key=self.api_key,
119
+ )
120
+ self.agent_id = record.get("id")
121
+ return record
122
+
123
+ def mint_token(self) -> str:
124
+ if not getattr(self, "agent_id", None):
125
+ raise MinmoDeployError(
126
+ "mint_token() requires a prior successful deploy() call."
127
+ )
128
+ return self.provider.mint_token(self.agent_id, self.api_key)
129
+
130
+ def log_session(
131
+ self, session_id: str, transcript: list, tool_calls: list
132
+ ) -> Optional[dict]:
133
+ if not self.log_path:
134
+ return None
135
+ record = write_session_log(self.log_path, session_id, transcript, tool_calls)
136
+ if self.on_session_end:
137
+ self.on_session_end(record)
138
+ return record
139
+
140
+ def _snapshot_history(self) -> None:
141
+ history_dir = Path(".minmo") / "history"
142
+ history_dir.mkdir(parents=True, exist_ok=True)
143
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + "Z"
144
+ snapshot = {
145
+ "prompt": self.prompt,
146
+ "tool_schemas": [
147
+ {
148
+ "name": t.name,
149
+ "description": t.description,
150
+ "parameters": t.parameters,
151
+ }
152
+ for t in self.tools.values()
153
+ ],
154
+ "timestamp": timestamp,
155
+ }
156
+ (history_dir / f"{timestamp}.json").write_text(json.dumps(snapshot, indent=2))
157
+
158
+ def _start_local_server_and_tunnel(self) -> str:
159
+ import uvicorn
160
+ from pyngrok import ngrok
161
+
162
+ from minmo.server import create_tool_server
163
+
164
+ app = create_tool_server(self.tools)
165
+
166
+ sock = socket.socket()
167
+ sock.bind(("", 0))
168
+ port = sock.getsockname()[1]
169
+ sock.close()
170
+
171
+ config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
172
+ server = uvicorn.Server(config)
173
+ thread = threading.Thread(target=server.run, daemon=True)
174
+ thread.start()
175
+ while not server.started:
176
+ time.sleep(0.05)
177
+
178
+ tunnel = ngrok.connect(port, "http")
179
+ return tunnel.public_url
minmo/cli.py ADDED
@@ -0,0 +1,99 @@
1
+ import importlib.util
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from minmo.agent import VoiceAgent
9
+
10
+ MAIN_PY_TEMPLATE = '''\
11
+ from minmo import VoiceAgent
12
+
13
+ agent = VoiceAgent(
14
+ prompt="You are a friendly voice assistant. Keep answers short.",
15
+ api_key="YOUR_ASSEMBLYAI_API_KEY",
16
+ )
17
+
18
+
19
+ @agent.tool
20
+ def get_weather(city: str) -> str:
21
+ """Get the current weather for a city."""
22
+ return f"It's sunny in {city}."
23
+
24
+
25
+ if __name__ == "__main__":
26
+ result = agent.deploy(local=True)
27
+ print(result)
28
+ '''
29
+
30
+ ENV_EXAMPLE_TEMPLATE = "ASSEMBLYAI_API_KEY=\n"
31
+
32
+ REQUIREMENTS_TEMPLATE = "minmo\n"
33
+
34
+
35
+ @click.group()
36
+ def main():
37
+ """minmo — build voice agents without the boilerplate."""
38
+
39
+
40
+ @main.command()
41
+ def init():
42
+ """Scaffold a new minmo project in the current directory."""
43
+ Path("main.py").write_text(MAIN_PY_TEMPLATE)
44
+ Path(".env.example").write_text(ENV_EXAMPLE_TEMPLATE)
45
+ Path("requirements.txt").write_text(REQUIREMENTS_TEMPLATE)
46
+ click.echo("Scaffolded main.py, .env.example, requirements.txt")
47
+
48
+
49
+ def _load_agent_from_main() -> VoiceAgent:
50
+ main_path = Path.cwd() / "main.py"
51
+ if not main_path.exists():
52
+ raise click.ClickException(
53
+ "No main.py found in the current directory. Run `minmo init` first."
54
+ )
55
+
56
+ spec = importlib.util.spec_from_file_location("minmo_user_main", main_path)
57
+ module = importlib.util.module_from_spec(spec)
58
+ spec.loader.exec_module(module) # type: ignore[union-attr]
59
+
60
+ for value in vars(module).values():
61
+ if isinstance(value, VoiceAgent):
62
+ return value
63
+
64
+ raise click.ClickException("No VoiceAgent instance found in main.py.")
65
+
66
+
67
+ @main.command()
68
+ @click.option(
69
+ "--local/--remote",
70
+ default=True,
71
+ help="Run a local tool server via ngrok tunnel (default), or deploy against an "
72
+ "already-hosted tool server (set MINMO_HOST_URL or use main.py's host_url).",
73
+ )
74
+ def deploy(local):
75
+ """Deploy the VoiceAgent defined in main.py."""
76
+ agent = _load_agent_from_main()
77
+ record = agent.deploy(local=local)
78
+ click.echo(json.dumps(record, indent=2))
79
+
80
+
81
+ @main.command()
82
+ def logs():
83
+ """Tail the most recent session log for the VoiceAgent in main.py."""
84
+ agent = _load_agent_from_main()
85
+ if not agent.log_path:
86
+ raise click.ClickException(
87
+ "The VoiceAgent in main.py has no log_path configured."
88
+ )
89
+
90
+ log_dir = Path(agent.log_path)
91
+ files = sorted(log_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
92
+ if not files:
93
+ raise click.ClickException(f"No session logs found in {log_dir}.")
94
+
95
+ click.echo(files[-1].read_text())
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
minmo/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ class MinmoError(Exception):
2
+ """Base class for all minmo errors."""
3
+
4
+
5
+ class MinmoSchemaError(MinmoError):
6
+ """Raised when a @agent.tool function can't be turned into a JSON-Schema tool."""
7
+
8
+
9
+ class MinmoDeployError(MinmoError):
10
+ """Raised when deploying an agent fails (missing config, AssemblyAI rejected the request)."""
11
+
12
+
13
+ class MinmoAuthError(MinmoError):
14
+ """Raised when the AssemblyAI API key is missing, empty, or rejected."""
minmo/logging.py ADDED
@@ -0,0 +1,18 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from pathlib import Path
4
+
5
+
6
+ def write_session_log(
7
+ log_path: str, session_id: str, transcript: list, tool_calls: list
8
+ ) -> dict:
9
+ record = {
10
+ "session_id": session_id,
11
+ "transcript": transcript,
12
+ "tool_calls": tool_calls,
13
+ "ended_at": datetime.now(timezone.utc).isoformat(),
14
+ }
15
+ log_dir = Path(log_path)
16
+ log_dir.mkdir(parents=True, exist_ok=True)
17
+ (log_dir / f"{session_id}.json").write_text(json.dumps(record, indent=2))
18
+ return record
File without changes
@@ -0,0 +1,95 @@
1
+ from typing import TYPE_CHECKING, Optional
2
+
3
+ import requests
4
+
5
+ from minmo.errors import MinmoAuthError, MinmoDeployError
6
+
7
+ if TYPE_CHECKING:
8
+ from minmo.agent import Tool
9
+
10
+ BASE_URL = "https://agents.assemblyai.com/v1"
11
+ DEFAULT_VOICE_ID = "alba"
12
+ DEFAULT_TOKEN_EXPIRY_SECONDS = 60 # AssemblyAI requires 1-600; docs recommend 60-300
13
+
14
+
15
+ class AssemblyAIProvider:
16
+ """A VoiceProvider implementation backed by AssemblyAI's Voice Agent API. See providers/base.py."""
17
+
18
+ def tool_url(self, base: str, tool_name: str) -> str:
19
+ return f"{base.rstrip('/')}/tools/{tool_name}"
20
+
21
+ def validate_tools(self, tools: "list[Tool]") -> None:
22
+ pass
23
+
24
+ def deploy(
25
+ self,
26
+ *,
27
+ name: str,
28
+ prompt: str,
29
+ tools: "list[Tool]",
30
+ base: Optional[str],
31
+ llm: Optional[dict],
32
+ provider_options: dict,
33
+ api_key: str,
34
+ ) -> dict:
35
+ voice_id = provider_options.get("voice_id", DEFAULT_VOICE_ID)
36
+ config: dict = {
37
+ "name": name,
38
+ "system_prompt": prompt,
39
+ "voice": {"voice_id": voice_id},
40
+ }
41
+ if tools:
42
+ config["tools"] = [self._tool_config(t, base) for t in tools]
43
+ if llm:
44
+ config["llm"] = llm
45
+
46
+ resp = requests.post(
47
+ f"{BASE_URL}/agents",
48
+ headers={"Authorization": api_key, "Content-Type": "application/json"},
49
+ json=config,
50
+ timeout=30,
51
+ )
52
+ if resp.status_code == 401:
53
+ raise MinmoAuthError(f"AssemblyAI rejected the API key: {resp.text}")
54
+ if resp.status_code >= 400:
55
+ raise MinmoDeployError(
56
+ f"AssemblyAI deploy failed ({resp.status_code}): {resp.text}"
57
+ )
58
+ record = resp.json()
59
+ record["info"] = (
60
+ f"Hosted on AssemblyAI's servers as agent '{record.get('id')}' — it stays "
61
+ "there until you delete or redeploy it. To use it, call mint_token() to get "
62
+ "a short-lived client token, then open a voice session with that token "
63
+ "(browser SDK, phone bridge, etc). AssemblyAI reaches your http-transport "
64
+ f"tools by calling {base or '(no http tools registered)'} whenever the model "
65
+ "wants to use one."
66
+ )
67
+ return record
68
+
69
+ def _tool_config(self, tool: "Tool", base: Optional[str]) -> dict:
70
+ common = {
71
+ "name": tool.name,
72
+ "description": tool.description,
73
+ "parameters": tool.parameters,
74
+ }
75
+ if tool.transport == "client":
76
+ return {**common, "type": "function"}
77
+ return {
78
+ **common,
79
+ "http": {"url": self.tool_url(base, tool.name), "http_method": "POST"},
80
+ }
81
+
82
+ def mint_token(self, agent_id: str, api_key: str) -> str:
83
+ resp = requests.get(
84
+ f"{BASE_URL}/token",
85
+ headers={"Authorization": api_key},
86
+ params={"expires_in_seconds": DEFAULT_TOKEN_EXPIRY_SECONDS},
87
+ timeout=30,
88
+ )
89
+ if resp.status_code == 401:
90
+ raise MinmoAuthError(f"AssemblyAI rejected the API key: {resp.text}")
91
+ if resp.status_code >= 400:
92
+ raise MinmoDeployError(
93
+ f"Token mint failed ({resp.status_code}): {resp.text}"
94
+ )
95
+ return resp.json()["token"]
@@ -0,0 +1,56 @@
1
+ from typing import TYPE_CHECKING, Optional, Protocol
2
+
3
+ if TYPE_CHECKING:
4
+ from minmo.agent import Tool
5
+
6
+
7
+ class VoiceProvider(Protocol):
8
+ """Everything minmo needs from a voice-agent backend.
9
+
10
+ AssemblyAI and Hume are the implementations today (see assemblyai.py,
11
+ hume.py). A future provider (OpenAI Realtime, ElevenLabs) implements
12
+ this same protocol as its own module — nothing outside providers/
13
+ changes.
14
+ """
15
+
16
+ def tool_url(self, base: str, tool_name: str) -> str:
17
+ """Build the public URL the provider should call for a given tool."""
18
+ ...
19
+
20
+ def validate_tools(self, tools: "list[Tool]") -> None:
21
+ """Raise MinmoDeployError early if `tools` is incompatible with what
22
+ this provider supports (e.g. a provider with no HTTP-tool concept
23
+ rejecting transport="http" tools). Called by VoiceAgent.deploy() before
24
+ any expensive/side-effecting base-URL resolution (local server, ngrok
25
+ tunnel, host_url check) so an unsupported tool set fails fast. A
26
+ provider with no such restrictions is a no-op.
27
+ """
28
+ ...
29
+
30
+ def deploy(
31
+ self,
32
+ *,
33
+ name: str,
34
+ prompt: str,
35
+ tools: "list[Tool]",
36
+ base: Optional[str],
37
+ llm: Optional[dict],
38
+ provider_options: dict,
39
+ api_key: str,
40
+ ) -> dict:
41
+ """Build AND publish the agent config, in however many network calls
42
+ this provider's API needs. Returns the created agent record (must
43
+ include an "id" the caller can store as agent_id).
44
+
45
+ `tools` is the raw list of registered Tool objects (name/description/
46
+ parameters/func/transport); `base` is the public URL the tool server
47
+ is reachable at, or None if no registered tool needs one (i.e. every
48
+ tool is transport="client"). The provider is responsible for turning
49
+ each Tool into whatever wire shape its transport needs — nothing
50
+ outside providers/ should know that shape.
51
+ """
52
+ ...
53
+
54
+ def mint_token(self, agent_id: str, api_key: str) -> str:
55
+ """Mint a short-lived client token for browser/embedded use."""
56
+ ...
@@ -0,0 +1,231 @@
1
+ import json
2
+ from typing import TYPE_CHECKING, Optional
3
+
4
+ import requests
5
+
6
+ from minmo.errors import MinmoAuthError, MinmoDeployError
7
+
8
+ if TYPE_CHECKING:
9
+ from minmo.agent import Tool
10
+
11
+ BASE_URL = "https://api.hume.ai/v0/evi"
12
+ TOKEN_URL = "https://api.hume.ai/oauth2-cc/token"
13
+ DEFAULT_VOICE = {"name": "Ava Song", "provider": "HUME_AI"}
14
+
15
+
16
+ class HumeProvider:
17
+ """A VoiceProvider implementation backed by Hume AI's EVI API. See providers/base.py.
18
+
19
+ Hume has no HTTP-tool concept — every tool must be transport="client".
20
+ Requires provider_options={"secret_key": ...} in addition to the usual
21
+ api_key, since Hume issues both separately. EVI3 configs also require a
22
+ voice; provider_options["voice"] (a {"name", "provider"} dict) overrides
23
+ DEFAULT_VOICE if given.
24
+
25
+ Hume's prompts, tools, and configs are all name-unique, versioned
26
+ resources: POST to the collection endpoint with a name that already
27
+ exists returns 409. So every redeploy (same agent name, same tool
28
+ names) must add a new version to the existing resource instead of
29
+ creating a fresh one — see _get_or_create_version().
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self._secret_key: Optional[str] = None
34
+
35
+ def tool_url(self, base: str, tool_name: str) -> str:
36
+ raise MinmoDeployError(
37
+ "HumeProvider has no HTTP-tool support — this method should never be called."
38
+ )
39
+
40
+ def validate_tools(self, tools: "list[Tool]") -> None:
41
+ http_tools = [t for t in tools if t.transport == "http"]
42
+ if http_tools:
43
+ names = ", ".join(t.name for t in http_tools)
44
+ raise MinmoDeployError(
45
+ f"HumeProvider only supports transport='client' tools; "
46
+ f"{names} use transport='http', which Hume cannot call. "
47
+ "Register these with @agent.tool(transport='client') instead."
48
+ )
49
+
50
+ def deploy(
51
+ self,
52
+ *,
53
+ name: str,
54
+ prompt: str,
55
+ tools: "list[Tool]",
56
+ base: Optional[str],
57
+ llm: Optional[dict],
58
+ provider_options: dict,
59
+ api_key: str,
60
+ ) -> dict:
61
+ self.validate_tools(tools)
62
+
63
+ secret_key = provider_options.get("secret_key")
64
+ if not secret_key:
65
+ raise MinmoDeployError(
66
+ "HumeProvider requires provider_options={'secret_key': ...} "
67
+ "(Hume issues an api_key and a secret_key separately)."
68
+ )
69
+ self._secret_key = secret_key
70
+
71
+ headers = {"X-Hume-Api-Key": api_key, "Content-Type": "application/json"}
72
+
73
+ prompt_name = f"{name} prompt"
74
+ prompt_record = self._get_or_create_version(
75
+ resource="prompts",
76
+ headers=headers,
77
+ name=prompt_name,
78
+ create_body={"name": prompt_name, "text": prompt},
79
+ version_body={"text": prompt},
80
+ )
81
+ prompt_ref = {"id": prompt_record["id"], "version": prompt_record["version"]}
82
+
83
+ tool_refs = []
84
+ for tool in tools:
85
+ tool_record = self._get_or_create_version(
86
+ resource="tools",
87
+ headers=headers,
88
+ name=tool.name,
89
+ create_body={
90
+ "name": tool.name,
91
+ "description": tool.description,
92
+ "parameters": json.dumps(tool.parameters),
93
+ },
94
+ version_body={
95
+ "description": tool.description,
96
+ "parameters": json.dumps(tool.parameters),
97
+ },
98
+ already_created={"prompt": prompt_ref},
99
+ )
100
+ tool_refs.append(
101
+ {"id": tool_record["id"], "version": tool_record["version"]}
102
+ )
103
+
104
+ config_body: dict = {
105
+ "evi_version": "3",
106
+ "prompt": prompt_ref,
107
+ "voice": provider_options.get("voice", DEFAULT_VOICE),
108
+ }
109
+ if tool_refs:
110
+ config_body["tools"] = tool_refs
111
+
112
+ warnings = []
113
+ if llm:
114
+ config_body["language_model"] = {"model_resource": llm.get("model")}
115
+ warnings.append(
116
+ "llm.base_url and llm.api_key are not applicable to Hume's language_model "
117
+ "config (Hume selects from its own supported provider list by name) and "
118
+ "were ignored; only llm.model was passed through."
119
+ )
120
+
121
+ config_record = self._get_or_create_version(
122
+ resource="configs",
123
+ headers=headers,
124
+ name=name,
125
+ create_body={**config_body, "name": name},
126
+ version_body=config_body,
127
+ already_created={"prompt": prompt_ref, "tools": tool_refs},
128
+ )
129
+
130
+ result = dict(config_record)
131
+ result["info"] = (
132
+ f"Hosted on Hume as a versioned config resource named '{name}' (id "
133
+ f"{config_record.get('id')}, version {config_record.get('version')}) — "
134
+ "redeploying with the same name adds a new version instead of replacing it. "
135
+ "To use it, call mint_token() (needs the secret_key you passed in "
136
+ "provider_options) to get an OAuth access token, then start an EVI session "
137
+ "with it. All tools are transport='client', so your own app code (not "
138
+ "minmo) must handle each tool.call over that session."
139
+ )
140
+ if warnings:
141
+ result["warnings"] = warnings
142
+ return result
143
+
144
+ def _get_or_create_version(
145
+ self,
146
+ resource: str,
147
+ headers: dict,
148
+ name: str,
149
+ create_body: dict,
150
+ version_body: dict,
151
+ already_created: Optional[dict] = None,
152
+ ) -> dict:
153
+ """Hume's prompts/tools/configs are name-unique, versioned resources.
154
+
155
+ If a resource with this name already exists, POST a new version onto
156
+ it (.../{resource}/{id}); otherwise POST the collection endpoint to
157
+ create it fresh. Makes deploy() safe to call repeatedly with the same
158
+ agent/tool names, which is the normal redeploy path.
159
+ """
160
+ list_resp = requests.get(
161
+ f"{BASE_URL}/{resource}",
162
+ headers=headers,
163
+ params={"name": name, "page_size": 1},
164
+ timeout=30,
165
+ )
166
+ if list_resp.status_code == 401:
167
+ raise MinmoAuthError(f"Hume rejected the API key: {list_resp.text}")
168
+ if list_resp.status_code == 404:
169
+ # Hume returns 404 (not 200 + empty page) when nothing matches the name filter.
170
+ existing = []
171
+ elif list_resp.status_code >= 400:
172
+ raise MinmoDeployError(
173
+ f"Hume list-{resource} request failed ({list_resp.status_code}): {list_resp.text}"
174
+ )
175
+ else:
176
+ existing = list_resp.json().get(f"{resource}_page", [])
177
+
178
+ if existing:
179
+ existing_id = existing[0]["id"]
180
+ return self._post(
181
+ f"{BASE_URL}/{resource}/{existing_id}",
182
+ headers,
183
+ version_body,
184
+ already_created=already_created,
185
+ )
186
+ return self._post(
187
+ f"{BASE_URL}/{resource}",
188
+ headers,
189
+ create_body,
190
+ already_created=already_created,
191
+ )
192
+
193
+ def _post(
194
+ self,
195
+ url: str,
196
+ headers: dict,
197
+ body: dict,
198
+ already_created: Optional[dict] = None,
199
+ ) -> dict:
200
+ resp = requests.post(url, headers=headers, json=body, timeout=30)
201
+ if resp.status_code == 401:
202
+ raise MinmoAuthError(f"Hume rejected the API key: {resp.text}")
203
+ if resp.status_code >= 400:
204
+ context = (
205
+ f" Already created on Hume: {already_created}."
206
+ if already_created
207
+ else ""
208
+ )
209
+ raise MinmoDeployError(
210
+ f"Hume request to {url} failed ({resp.status_code}): {resp.text}.{context}"
211
+ )
212
+ return resp.json()
213
+
214
+ def mint_token(self, agent_id: str, api_key: str) -> str:
215
+ if not self._secret_key:
216
+ raise MinmoDeployError(
217
+ "mint_token() requires a prior deploy() call (secret_key not set)."
218
+ )
219
+ resp = requests.post(
220
+ TOKEN_URL,
221
+ auth=(api_key, self._secret_key),
222
+ data={"grant_type": "client_credentials"},
223
+ timeout=30,
224
+ )
225
+ if resp.status_code == 401:
226
+ raise MinmoAuthError(f"Hume rejected the credentials: {resp.text}")
227
+ if resp.status_code >= 400:
228
+ raise MinmoDeployError(
229
+ f"Hume token mint failed ({resp.status_code}): {resp.text}"
230
+ )
231
+ return resp.json()["access_token"]
@@ -0,0 +1,139 @@
1
+ import uuid
2
+ from typing import TYPE_CHECKING, Optional
3
+
4
+ import requests
5
+
6
+ from minmo.errors import MinmoAuthError, MinmoDeployError
7
+
8
+ if TYPE_CHECKING:
9
+ from minmo.agent import Tool
10
+
11
+ BASE_URL = "https://api.openai.com/v1/realtime"
12
+
13
+
14
+ class OpenAIRealtimeProvider:
15
+ """A VoiceProvider implementation backed by OpenAI's Realtime API. See providers/base.py.
16
+
17
+ OpenAI Realtime has no persistent agent resource and no HTTP-tool concept.
18
+ deploy() makes no network call: it validates tools, requires
19
+ provider_options={"model": ..., "voice": ...} (no defaults — the caller
20
+ must choose), and stores the built session config in-memory keyed by a
21
+ locally generated id — this id is valid only for the lifetime of this
22
+ provider instance, not a server-side resource, and cannot be persisted
23
+ across processes or looked up by a different OpenAIRealtimeProvider instance.
24
+ mint_token() does the one real network call (POST /v1/realtime/client_secrets),
25
+ which both mints the ephemeral client token and (implicitly, on OpenAI's side)
26
+ starts the session bound to that config.
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._sessions: dict[str, dict] = {}
31
+
32
+ def tool_url(self, base: str, tool_name: str) -> str:
33
+ raise MinmoDeployError(
34
+ "OpenAIRealtimeProvider has no HTTP-tool support — this method should never be called."
35
+ )
36
+
37
+ def validate_tools(self, tools: "list[Tool]") -> None:
38
+ http_tools = [t for t in tools if t.transport == "http"]
39
+ if http_tools:
40
+ names = ", ".join(t.name for t in http_tools)
41
+ raise MinmoDeployError(
42
+ f"OpenAIRealtimeProvider only supports transport='client' tools; "
43
+ f"{names} use transport='http', which OpenAI Realtime cannot call. "
44
+ "Register these with @agent.tool(transport='client') instead."
45
+ )
46
+
47
+ def deploy(
48
+ self,
49
+ *,
50
+ name: str,
51
+ prompt: str,
52
+ tools: "list[Tool]",
53
+ base: Optional[str],
54
+ llm: Optional[dict],
55
+ provider_options: dict,
56
+ api_key: str,
57
+ ) -> dict:
58
+ self.validate_tools(tools)
59
+
60
+ model = provider_options.get("model")
61
+ if not model:
62
+ raise MinmoDeployError(
63
+ "OpenAIRealtimeProvider requires provider_options={'model': ..., 'voice': ...} "
64
+ "(no default model — pick the realtime model you want)."
65
+ )
66
+ voice = provider_options.get("voice")
67
+ if not voice:
68
+ raise MinmoDeployError(
69
+ "OpenAIRealtimeProvider requires provider_options={'model': ..., 'voice': ...} "
70
+ "(no default voice — pick the voice you want)."
71
+ )
72
+
73
+ warnings = []
74
+ if llm:
75
+ warnings.append(
76
+ "llm is ignored for OpenAIRealtimeProvider: the realtime model configured via "
77
+ "provider_options['model'] IS the LLM, there is no separate one to plug in."
78
+ )
79
+
80
+ session_config: dict = {
81
+ "type": "realtime",
82
+ "model": model,
83
+ "instructions": prompt,
84
+ "audio": {"output": {"voice": voice}},
85
+ }
86
+ if tools:
87
+ session_config["tools"] = [self._tool_config(t) for t in tools]
88
+
89
+ session_id = uuid.uuid4().hex
90
+ self._sessions[session_id] = session_config
91
+
92
+ result: dict = {
93
+ "id": session_id,
94
+ "hosted": False,
95
+ "info": (
96
+ f"Not hosted anywhere — '{session_id}' only exists in this process's "
97
+ "memory, on this OpenAIRealtimeProvider instance. Nothing is saved on "
98
+ "OpenAI's side yet. To use it, call mint_token() now, in this same "
99
+ "process, to actually send the config to OpenAI and get back a "
100
+ "short-lived client token; open a Realtime session with that token. "
101
+ "All tools are transport='client', so your own app code handles each "
102
+ "tool call over that session."
103
+ ),
104
+ }
105
+ if warnings:
106
+ result["warnings"] = warnings
107
+ return result
108
+
109
+ def _tool_config(self, tool: "Tool") -> dict:
110
+ return {
111
+ "type": "function",
112
+ "name": tool.name,
113
+ "description": tool.description,
114
+ "parameters": tool.parameters,
115
+ }
116
+
117
+ def mint_token(self, agent_id: str, api_key: str) -> str:
118
+ config = self._sessions.get(agent_id)
119
+ if config is None:
120
+ raise MinmoDeployError(
121
+ f"mint_token() called with unknown id {agent_id!r} — requires a prior deploy() call on this provider instance."
122
+ )
123
+
124
+ resp = requests.post(
125
+ f"{BASE_URL}/client_secrets",
126
+ headers={
127
+ "Authorization": f"Bearer {api_key}",
128
+ "Content-Type": "application/json",
129
+ },
130
+ json={"session": config},
131
+ timeout=30,
132
+ )
133
+ if resp.status_code == 401:
134
+ raise MinmoAuthError(f"OpenAI rejected the API key: {resp.text}")
135
+ if resp.status_code >= 400:
136
+ raise MinmoDeployError(
137
+ f"OpenAI Realtime client_secrets request failed ({resp.status_code}): {resp.text}"
138
+ )
139
+ return resp.json()["value"]
minmo/schema.py ADDED
@@ -0,0 +1,81 @@
1
+ import inspect
2
+ import typing
3
+ from typing import Callable, Optional, Union, get_args, get_origin
4
+
5
+ from minmo.errors import MinmoSchemaError
6
+
7
+ _PRIMITIVE_MAP = {
8
+ str: {"type": "string"},
9
+ int: {"type": "integer"},
10
+ float: {"type": "number"},
11
+ bool: {"type": "boolean"},
12
+ }
13
+
14
+
15
+ def _hint_to_json_schema(hint, param_name: str, func_name: str) -> tuple[dict, bool]:
16
+ """Returns (schema, required)."""
17
+ origin = get_origin(hint)
18
+
19
+ if origin is Union:
20
+ args = [a for a in get_args(hint) if a is not type(None)]
21
+ if len(args) != 1:
22
+ raise MinmoSchemaError(
23
+ f"{func_name}: parameter '{param_name}' has an unsupported Union hint; "
24
+ "only Optional[T] is supported."
25
+ )
26
+ schema, _ = _hint_to_json_schema(args[0], param_name, func_name)
27
+ return schema, False
28
+
29
+ if origin in (list, typing.List):
30
+ (item_hint,) = get_args(hint) or (str,)
31
+ if item_hint is not str:
32
+ raise MinmoSchemaError(
33
+ f"{func_name}: parameter '{param_name}' uses list[{item_hint}] — "
34
+ "only list[str] is supported."
35
+ )
36
+ return {"type": "array", "items": {"type": "string"}}, True
37
+
38
+ if hint in _PRIMITIVE_MAP:
39
+ return dict(_PRIMITIVE_MAP[hint]), True
40
+
41
+ raise MinmoSchemaError(
42
+ f"{func_name}: parameter '{param_name}' has unsupported type hint {hint!r}. "
43
+ "Supported: str, int, float, bool, list[str], Optional[...] of those."
44
+ )
45
+
46
+
47
+ def function_to_tool_schema(func: Callable) -> dict:
48
+ func_name = func.__name__
49
+ doc = inspect.getdoc(func)
50
+ if not doc:
51
+ raise MinmoSchemaError(
52
+ f"{func_name}: missing docstring — the first line becomes the tool description."
53
+ )
54
+ description = doc.strip().splitlines()[0]
55
+
56
+ hints = typing.get_type_hints(func)
57
+ signature = inspect.signature(func)
58
+
59
+ properties = {}
60
+ required = []
61
+ for param_name in signature.parameters:
62
+ if param_name not in hints:
63
+ raise MinmoSchemaError(
64
+ f"{func_name}: parameter '{param_name}' is missing a type hint."
65
+ )
66
+ schema, is_required = _hint_to_json_schema(
67
+ hints[param_name], param_name, func_name
68
+ )
69
+ properties[param_name] = schema
70
+ if is_required:
71
+ required.append(param_name)
72
+
73
+ return {
74
+ "name": func_name,
75
+ "description": description,
76
+ "parameters": {
77
+ "type": "object",
78
+ "properties": properties,
79
+ "required": required,
80
+ },
81
+ }
minmo/server.py ADDED
@@ -0,0 +1,28 @@
1
+ import jsonschema
2
+ from fastapi import FastAPI, Request
3
+
4
+ from minmo.agent import Tool
5
+
6
+
7
+ def _make_handler(tool: Tool):
8
+ async def handler(request: Request):
9
+ payload = await request.json() if await request.body() else {}
10
+ try:
11
+ jsonschema.validate(payload, tool.parameters)
12
+ result = tool.func(**payload)
13
+ return {"result": result}
14
+ except (
15
+ Exception
16
+ ) as exc: # noqa: BLE001 - intentional: surface any tool error to the model
17
+ return {"error": str(exc)}
18
+
19
+ return handler
20
+
21
+
22
+ def create_tool_server(tools: dict[str, Tool]) -> FastAPI:
23
+ app = FastAPI()
24
+ for tool in tools.values():
25
+ if tool.transport != "http":
26
+ continue
27
+ app.add_api_route(f"/tools/{tool.name}", _make_handler(tool), methods=["POST"])
28
+ return app
minmo/testing.py ADDED
@@ -0,0 +1,68 @@
1
+ import json
2
+
3
+ from openai import OpenAI
4
+
5
+ from minmo.agent import VoiceAgent
6
+ from minmo.errors import MinmoError
7
+
8
+
9
+ def simulate(agent: VoiceAgent, transcript: list[str]) -> dict:
10
+ if not agent.llm:
11
+ raise MinmoError(
12
+ "simulate() requires VoiceAgent(llm={'base_url', 'model', 'api_key'}) — "
13
+ "there is no way to call AssemblyAI's managed model directly outside a live voice session."
14
+ )
15
+
16
+ client = OpenAI(base_url=agent.llm["base_url"], api_key=agent.llm["api_key"])
17
+ model = agent.llm["model"]
18
+
19
+ tool_defs = [
20
+ {
21
+ "type": "function",
22
+ "function": {
23
+ "name": t.name,
24
+ "description": t.description,
25
+ "parameters": t.parameters,
26
+ },
27
+ }
28
+ for t in agent.tools.values()
29
+ ] or None
30
+
31
+ messages: list[dict] = [{"role": "system", "content": agent.prompt}]
32
+ tool_call_log: list[dict] = []
33
+
34
+ for line in transcript:
35
+ messages.append({"role": "user", "content": line})
36
+
37
+ while True:
38
+ response = client.chat.completions.create(
39
+ model=model, messages=messages, tools=tool_defs
40
+ )
41
+ message = response.choices[0].message
42
+ messages.append(message.model_dump())
43
+
44
+ if not message.tool_calls:
45
+ break
46
+
47
+ for call in message.tool_calls:
48
+ tool = agent.tools[call.function.name]
49
+ args = json.loads(call.function.arguments)
50
+ try:
51
+ output = {"result": tool.func(**args)}
52
+ except (
53
+ Exception
54
+ ) as exc: # noqa: BLE001 - mirrors server.py's structured-error behavior
55
+ output = {"error": str(exc)}
56
+
57
+ tool_call_log.append(
58
+ {"name": call.function.name, "input": args, "output": output}
59
+ )
60
+ messages.append(
61
+ {
62
+ "role": "tool",
63
+ "tool_call_id": call.id,
64
+ "content": json.dumps(output),
65
+ }
66
+ )
67
+
68
+ return {"conversation": messages, "tool_calls": tool_call_log}
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.5
2
+ Name: minmo
3
+ Version: 0.1.0
4
+ Summary: Boilerplate-free SDK for building voice agents on AssemblyAI's Voice Agent API
5
+ Project-URL: Homepage, https://github.com/a-elhaag/minmo
6
+ Author: Anas Elhaag
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: click>=8.1
11
+ Requires-Dist: fastapi>=0.110
12
+ Requires-Dist: jsonschema>=4.0
13
+ Requires-Dist: openai>=1.30
14
+ Requires-Dist: pyngrok>=7.0
15
+ Requires-Dist: requests>=2.31
16
+ Requires-Dist: uvicorn>=0.29
17
+ Provides-Extra: dev
18
+ Requires-Dist: black>=24.0; extra == 'dev'
19
+ Requires-Dist: httpx>=0.27; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: responses>=0.25; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # minmo
25
+
26
+ [![CI](https://github.com/a-elhaag/minmo/actions/workflows/ci.yml/badge.svg)](https://github.com/a-elhaag/minmo/actions/workflows/ci.yml)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
29
+
30
+ **Boilerplate-free SDK for building voice agents** on [AssemblyAI's Voice Agent API](https://assemblyai.com/docs/voice-agents/voice-agent-api), with support for Hume and OpenAI Realtime too.
31
+
32
+ Define a prompt, register Python functions as tools, deploy. minmo handles
33
+ JSON-Schema generation, local tool hosting + tunneling, and talking to each
34
+ provider's REST API — so you write the assistant, not the plumbing.
35
+
36
+ 📖 **[Full documentation](https://a-elhaag.github.io/minmo/)**
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ (Set `NGROK_AUTHTOKEN` in your environment if you plan to use `local=True` —
45
+ see [pyngrok's docs](https://pyngrok.readthedocs.io/) for how to get one.)
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from minmo import VoiceAgent
51
+
52
+ agent = VoiceAgent(
53
+ prompt="You are a friendly voice assistant. Keep answers short.",
54
+ api_key="YOUR_ASSEMBLYAI_API_KEY",
55
+ )
56
+
57
+
58
+ @agent.tool
59
+ def get_weather(city: str) -> str:
60
+ """Get the current weather for a city."""
61
+ return f"It's sunny in {city}."
62
+
63
+
64
+ result = agent.deploy(local=True)
65
+ print(result) # the created AssemblyAI agent record, including its id
66
+ ```
67
+
68
+ That's it — `deploy(local=True)` starts a local tool server, tunnels it
69
+ publicly, and registers `get_weather` as a real tool your live voice agent
70
+ can call.
71
+
72
+ `result` always includes an `"info"` field — a plain-language paragraph
73
+ telling you whether the agent is hosted on the provider's servers or only
74
+ exists locally, and how to actually connect to it (usually via
75
+ `mint_token()`). Worth printing after every deploy, especially since this
76
+ differs per provider — see below.
77
+
78
+ ## Testing tool logic without burning session minutes
79
+
80
+ ```python
81
+ from minmo import VoiceAgent
82
+ from minmo.testing import simulate
83
+
84
+ agent = VoiceAgent(
85
+ prompt="You are a friendly voice assistant.",
86
+ api_key="YOUR_ASSEMBLYAI_API_KEY",
87
+ llm={"base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini", "api_key": "YOUR_OPENAI_KEY"},
88
+ )
89
+
90
+
91
+ @agent.tool
92
+ def get_weather(city: str) -> str:
93
+ """Get the current weather for a city."""
94
+ return f"It's sunny in {city}."
95
+
96
+
97
+ result = simulate(agent, transcript=["What's the weather in Paris?"])
98
+ print(result["tool_calls"])
99
+ ```
100
+
101
+ `simulate()` calls the `llm` you configured directly (any OpenAI-compatible
102
+ endpoint) and runs tool calls against your local Python functions — no
103
+ AssemblyAI session, no phone call.
104
+
105
+ ## Client-side tools (no server, no tunnel)
106
+
107
+ `@agent.tool` defaults to `transport="http"` — minmo hosts the function
108
+ behind a URL AssemblyAI calls. Pass `transport="client"` instead to declare
109
+ a [client-side/function tool](https://assemblyai.com/docs/voice-agents/voice-agent-api/tools/client-side-tools):
110
+ no server, no tunnel, no `host_url` needed for that tool.
111
+
112
+ ```python
113
+ @agent.tool(transport="client")
114
+ def get_account_balance(account_id: str) -> str:
115
+ """Look up an account's balance."""
116
+ return "$42.00"
117
+ ```
118
+
119
+ minmo only builds the correct agent config for this — declaring the tool
120
+ as client-side. Running the actual session (the WebSocket connection that
121
+ receives `tool.call` and sends back `tool.result`) is your own code's job:
122
+ a browser app, a Twilio bridge, whatever already holds that live connection.
123
+ If every registered tool is `transport="client"`, `deploy()` skips the local
124
+ server and tunnel (or `host_url` requirement) entirely — nothing to host.
125
+
126
+ ## Providers
127
+
128
+ minmo supports AssemblyAI (default), Hume, and OpenAI Realtime. They don't
129
+ all host your agent the same way:
130
+
131
+ | Provider | Hosted on their servers? | How you use it |
132
+ |---|---|---|
133
+ | AssemblyAI | Yes — `deploy()` creates a persistent agent record | `mint_token()` for a client token, connect a voice session with it |
134
+ | Hume | Yes — `deploy()` creates/versions a config resource | `mint_token()` for an access token, start an EVI session with it |
135
+ | OpenAI Realtime | No — `deploy()` only builds a config in your process's memory | Call `mint_token()` right after, in the same process, to actually send it to OpenAI and get a session token |
136
+
137
+ Pass a different provider via `VoiceAgent(provider=..., provider_options=...)`;
138
+ see each provider's docstring in `minmo/providers/` for required options.
139
+
140
+ ## CLI
141
+
142
+ ```bash
143
+ minmo init # scaffold main.py, .env.example, requirements.txt
144
+ minmo deploy # import main.py, find the VoiceAgent, deploy it
145
+ minmo logs # print the most recent session log
146
+ ```
147
+
148
+ ## Deploying to a real server (not `local=True`)
149
+
150
+ ```python
151
+ agent.deploy(local=False, host_url="https://your-deployed-tool-server.example.com")
152
+ # or set MINMO_HOST_URL in the environment instead of passing host_url
153
+ ```
154
+
155
+ Your host must already be running the tool server — `local=True` is for
156
+ development; production tool hosting is up to you (minmo's local server
157
+ factory, `minmo.server.create_tool_server`, is reusable if you want to
158
+ deploy it yourself behind a real domain).
159
+
160
+ ## Development
161
+
162
+ ```bash
163
+ pip install -e ".[dev]"
164
+ pytest -q
165
+ ```
166
+
167
+ Pushes are auto-formatted with [Black](https://black.readthedocs.io/) via
168
+ GitHub Actions, and every push/PR runs the test suite across Python 3.10–3.12.
169
+
170
+ ## Contributing
171
+
172
+ Issues and PRs welcome. Keep changes small and covered by a test.
173
+
174
+ ## License
175
+
176
+ [MIT](LICENSE) © [Anas Elhaag](https://github.com/a-elhaag)
@@ -0,0 +1,18 @@
1
+ minmo/__init__.py,sha256=G7vOsyFjTOCcNvMnb4g0zRGxFv4RojRm3MGO0bf2vdo,265
2
+ minmo/agent.py,sha256=Fy-UnO7Kk88u_XBd19gByk3eGvgnhUAVNTZSaevR9XY,5758
3
+ minmo/cli.py,sha256=TP2qhk_-fGPG81r4qm2vXA2JCCUuKJMi37E1ICUBpns,2658
4
+ minmo/errors.py,sha256=Ebqd7vATKCCQfJea2pJ2yxId7Om9BKz33Ll9K5n9Vps,445
5
+ minmo/logging.py,sha256=cF_yPBMzKPG4DgWWYvYmk7Qu2goTUOBjQj-5vM8kJoQ,539
6
+ minmo/schema.py,sha256=DJ37CZar8DZt6awDGiRBjVF4qDBuQU-aq5ymUUpBKok,2581
7
+ minmo/server.py,sha256=iUsTmfKZQ-IudWimiAXcWiHX5-Foh6GG01lhfv7Uels,838
8
+ minmo/testing.py,sha256=vQORRSeON0X0zne_QLfs3Z9y7H0N2964s5lO4lLPcUQ,2219
9
+ minmo/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ minmo/providers/assemblyai.py,sha256=-XVn7JMCKS-mLDEDfL7QJH3U3kzme-5QjY4VEo-SYzo,3327
11
+ minmo/providers/base.py,sha256=V3_3BbxS1l0I-na332MX1DMmylL1smdIHoGjq-Zc6uc,2162
12
+ minmo/providers/hume.py,sha256=sVjT_AxyPnBUccIv8TCZZcPf-fKc-4nP7aQamijfPjY,8655
13
+ minmo/providers/openai_realtime.py,sha256=UolKCk480dLStf52irtCx5KizYLYMz6S2VkXLrimsAE,5333
14
+ minmo-0.1.0.dist-info/METADATA,sha256=2u-kJISPxnHLSTwPDSXKQ9UmxL9dlAy0jr3UELVcAOc,6238
15
+ minmo-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
16
+ minmo-0.1.0.dist-info/entry_points.txt,sha256=yVq0PWqm9rSExZVypzd1ZYSp5RoQ4KJEzla4CR7XuyA,41
17
+ minmo-0.1.0.dist-info/licenses/LICENSE,sha256=qv_G1ukgjdOZd4NueyR_BduXSsaATBk7q1GVngnlSzs,1068
18
+ minmo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ minmo = minmo.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anas Elhaag
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.