hitmos 0.0.1__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.
- hitmos/__init__.py +1 -0
- hitmos/app.py +163 -0
- hitmos/chat.py +34 -0
- hitmos/cli.py +46 -0
- hitmos/client.py +131 -0
- hitmos/commands.py +37 -0
- hitmos/config.py +67 -0
- hitmos/constants.py +41 -0
- hitmos/context.py +119 -0
- hitmos/exceptions.py +22 -0
- hitmos/methods/__init__.py +45 -0
- hitmos/methods/base.py +33 -0
- hitmos/methods/edit_file.py +23 -0
- hitmos/methods/list_directory.py +20 -0
- hitmos/methods/read_file.py +17 -0
- hitmos/methods/run_command.py +31 -0
- hitmos/methods/write_file.py +18 -0
- hitmos/ui.py +217 -0
- hitmos/utils.py +10 -0
- hitmos-0.0.1.dist-info/METADATA +196 -0
- hitmos-0.0.1.dist-info/RECORD +23 -0
- hitmos-0.0.1.dist-info/WHEEL +4 -0
- hitmos-0.0.1.dist-info/entry_points.txt +2 -0
hitmos/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
hitmos/app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .chat import ChatSession
|
|
6
|
+
from .client import OpenRouterClient, ToolCallRequest
|
|
7
|
+
from .commands import CommandHandler, CommandResult, CommandType
|
|
8
|
+
from .config import ConfigManager
|
|
9
|
+
from .constants import SYSTEM_PROMPT
|
|
10
|
+
from .context import ProjectContext
|
|
11
|
+
from .exceptions import AuthError, HitmosError
|
|
12
|
+
from .methods import dispatch
|
|
13
|
+
from .ui import ConsoleUI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HitmosApp:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._config = ConfigManager()
|
|
19
|
+
self._ui = ConsoleUI()
|
|
20
|
+
self._commands = CommandHandler()
|
|
21
|
+
self._session: ChatSession | None = None
|
|
22
|
+
self._client: OpenRouterClient | None = None
|
|
23
|
+
|
|
24
|
+
def run(self) -> None:
|
|
25
|
+
try:
|
|
26
|
+
token = self._config.resolve_token()
|
|
27
|
+
except AuthError as e:
|
|
28
|
+
self._ui.show_error(str(e))
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
|
|
31
|
+
model = self._config.get_model()
|
|
32
|
+
self._client = OpenRouterClient(token, model)
|
|
33
|
+
|
|
34
|
+
system_prompt, ctx_kb = self._build_system_prompt()
|
|
35
|
+
self._session = ChatSession(system_prompt=system_prompt)
|
|
36
|
+
|
|
37
|
+
self._ui.show_welcome(model, ctx_kb)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
asyncio.run(self._loop())
|
|
41
|
+
except KeyboardInterrupt:
|
|
42
|
+
self._ui.show_exit()
|
|
43
|
+
|
|
44
|
+
async def _loop(self) -> None:
|
|
45
|
+
assert self._client is not None
|
|
46
|
+
assert self._session is not None
|
|
47
|
+
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
text = await self._ui.get_input()
|
|
51
|
+
except (KeyboardInterrupt, EOFError):
|
|
52
|
+
self._ui.show_exit()
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
text = text.strip()
|
|
56
|
+
if not text:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
result = self._commands.parse(text)
|
|
60
|
+
if result is not None:
|
|
61
|
+
should_exit = await self._handle_command(result)
|
|
62
|
+
if should_exit:
|
|
63
|
+
return
|
|
64
|
+
else:
|
|
65
|
+
await self._handle_message(text)
|
|
66
|
+
|
|
67
|
+
def _build_system_prompt(self) -> tuple[str, int]:
|
|
68
|
+
ctx, kb = ProjectContext(Path.cwd()).build()
|
|
69
|
+
if not ctx:
|
|
70
|
+
return SYSTEM_PROMPT, 0
|
|
71
|
+
return (
|
|
72
|
+
"You are Hitmos, a helpful AI coding assistant. "
|
|
73
|
+
"Be concise, accurate, and developer-focused.\n\n"
|
|
74
|
+
"You have access to the user's project files below. "
|
|
75
|
+
"Use this context to give accurate, project-specific answers.\n\n"
|
|
76
|
+
+ ctx,
|
|
77
|
+
kb,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def _handle_command(self, result: CommandResult) -> bool:
|
|
81
|
+
assert self._client is not None
|
|
82
|
+
assert self._session is not None
|
|
83
|
+
|
|
84
|
+
match result.type:
|
|
85
|
+
case CommandType.HELP:
|
|
86
|
+
self._ui.show_help()
|
|
87
|
+
case CommandType.CLEAR:
|
|
88
|
+
self._session.clear()
|
|
89
|
+
self._ui.show_info("History cleared.")
|
|
90
|
+
case CommandType.RESET:
|
|
91
|
+
self._session.reset()
|
|
92
|
+
self._ui.show_info("Context reset.")
|
|
93
|
+
case CommandType.MODEL:
|
|
94
|
+
if result.arg:
|
|
95
|
+
self._client.model = result.arg
|
|
96
|
+
self._config.save_model(result.arg)
|
|
97
|
+
self._ui.show_success(f"Model: {result.arg}")
|
|
98
|
+
else:
|
|
99
|
+
selected = await self._ui.show_model_picker(self._client.model)
|
|
100
|
+
if selected:
|
|
101
|
+
self._client.model = selected
|
|
102
|
+
self._config.save_model(selected)
|
|
103
|
+
self._ui.show_success(f"Model: {selected}")
|
|
104
|
+
case CommandType.EXIT:
|
|
105
|
+
self._ui.show_exit()
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
async def _handle_message(self, text: str) -> None:
|
|
110
|
+
assert self._client is not None
|
|
111
|
+
assert self._session is not None
|
|
112
|
+
|
|
113
|
+
self._session.add_user(text)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
while True:
|
|
117
|
+
tool_calls: list[ToolCallRequest] = []
|
|
118
|
+
|
|
119
|
+
async def _text_gen() -> AsyncGenerator[str, None]:
|
|
120
|
+
async for item in self._client.stream_chat( # type: ignore[union-attr]
|
|
121
|
+
self._session.messages # type: ignore[union-attr]
|
|
122
|
+
):
|
|
123
|
+
if isinstance(item, list):
|
|
124
|
+
tool_calls.extend(item)
|
|
125
|
+
else:
|
|
126
|
+
yield item
|
|
127
|
+
|
|
128
|
+
full_response = await self._ui.stream_response(_text_gen())
|
|
129
|
+
|
|
130
|
+
if not tool_calls:
|
|
131
|
+
if full_response:
|
|
132
|
+
self._session.add_assistant(full_response)
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
self._session.add_raw({
|
|
136
|
+
"role": "assistant",
|
|
137
|
+
"content": full_response or None,
|
|
138
|
+
"tool_calls": [
|
|
139
|
+
{
|
|
140
|
+
"id": tc.id,
|
|
141
|
+
"type": "function",
|
|
142
|
+
"function": {"name": tc.name, "arguments": tc.arguments},
|
|
143
|
+
}
|
|
144
|
+
for tc in tool_calls
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
for tc in tool_calls:
|
|
149
|
+
self._ui.show_tool_call(tc.name, tc.arguments)
|
|
150
|
+
result = dispatch(tc.name, tc.arguments)
|
|
151
|
+
self._ui.show_tool_result(result)
|
|
152
|
+
self._session.add_raw({
|
|
153
|
+
"role": "tool",
|
|
154
|
+
"tool_call_id": tc.id,
|
|
155
|
+
"content": result,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
except HitmosError as e:
|
|
159
|
+
self._session.pop_last()
|
|
160
|
+
self._ui.show_error(str(e))
|
|
161
|
+
except Exception as e:
|
|
162
|
+
self._session.pop_last()
|
|
163
|
+
self._ui.show_error(f"Unexpected error: {e}")
|
hitmos/chat.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .constants import SYSTEM_PROMPT
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ChatSession:
|
|
5
|
+
def __init__(self, system_prompt: str | None = None) -> None:
|
|
6
|
+
self._messages: list[dict] = []
|
|
7
|
+
self._system = system_prompt or SYSTEM_PROMPT
|
|
8
|
+
|
|
9
|
+
def add_user(self, content: str) -> None:
|
|
10
|
+
self._messages.append({"role": "user", "content": content})
|
|
11
|
+
|
|
12
|
+
def add_assistant(self, content: str) -> None:
|
|
13
|
+
self._messages.append({"role": "assistant", "content": content})
|
|
14
|
+
|
|
15
|
+
def add_raw(self, message: dict) -> None:
|
|
16
|
+
self._messages.append(message)
|
|
17
|
+
|
|
18
|
+
def clear(self) -> None:
|
|
19
|
+
self._messages.clear()
|
|
20
|
+
|
|
21
|
+
def reset(self) -> None:
|
|
22
|
+
self._messages.clear()
|
|
23
|
+
|
|
24
|
+
def pop_last(self) -> None:
|
|
25
|
+
if self._messages:
|
|
26
|
+
self._messages.pop()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def messages(self) -> list[dict]:
|
|
30
|
+
return [{"role": "system", "content": self._system}] + self._messages
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_empty(self) -> bool:
|
|
34
|
+
return len(self._messages) == 0
|
hitmos/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.prompt import Prompt
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="hitmos",
|
|
7
|
+
help="Hitmos — AI terminal assistant",
|
|
8
|
+
add_completion=False,
|
|
9
|
+
no_args_is_help=False,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.callback(invoke_without_command=True)
|
|
16
|
+
def main(ctx: typer.Context) -> None:
|
|
17
|
+
"""Start interactive Hitmos session."""
|
|
18
|
+
if ctx.invoked_subcommand is None:
|
|
19
|
+
from .app import HitmosApp
|
|
20
|
+
|
|
21
|
+
HitmosApp().run()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("login")
|
|
25
|
+
def login() -> None:
|
|
26
|
+
"""Save OpenRouter API key to ~/.hitmos/config.toml."""
|
|
27
|
+
from .config import ConfigManager
|
|
28
|
+
|
|
29
|
+
console.print()
|
|
30
|
+
console.print(" [bold]Hitmos Login[/bold]")
|
|
31
|
+
console.print()
|
|
32
|
+
|
|
33
|
+
api_key = Prompt.ask(" Paste your OpenRouter API key", password=True)
|
|
34
|
+
api_key = api_key.strip()
|
|
35
|
+
|
|
36
|
+
if not api_key:
|
|
37
|
+
console.print(" [bold red]✖[/bold red] No API key provided.")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
ConfigManager().save_token(api_key)
|
|
41
|
+
|
|
42
|
+
console.print()
|
|
43
|
+
console.print(
|
|
44
|
+
" [bold green]✓[/bold green] API key saved to [dim]~/.hitmos/config.toml[/dim]"
|
|
45
|
+
)
|
|
46
|
+
console.print()
|
hitmos/client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
from fasthttp import AsyncSession
|
|
6
|
+
|
|
7
|
+
from .constants import APP_TITLE, HTTP_REFERER, OPENROUTER_CHAT_URL
|
|
8
|
+
from .exceptions import APIError, AuthError, NetworkError, RateLimitError
|
|
9
|
+
from .methods import ALL_TOOLS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ToolCallRequest:
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
arguments: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenRouterClient:
|
|
20
|
+
def __init__(self, token: str, model: str) -> None:
|
|
21
|
+
self._token = token
|
|
22
|
+
self._model = model
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def model(self) -> str:
|
|
26
|
+
return self._model
|
|
27
|
+
|
|
28
|
+
@model.setter
|
|
29
|
+
def model(self, value: str) -> None:
|
|
30
|
+
self._model = value
|
|
31
|
+
|
|
32
|
+
def _headers(self) -> dict[str, str]:
|
|
33
|
+
return {
|
|
34
|
+
"Authorization": f"Bearer {self._token}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"HTTP-Referer": HTTP_REFERER,
|
|
37
|
+
"X-Title": APP_TITLE,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async def stream_chat(
|
|
41
|
+
self,
|
|
42
|
+
messages: list[dict],
|
|
43
|
+
use_tools: bool = True,
|
|
44
|
+
) -> AsyncGenerator[str | list[ToolCallRequest], None]:
|
|
45
|
+
payload_dict: dict = {
|
|
46
|
+
"model": self._model,
|
|
47
|
+
"messages": messages,
|
|
48
|
+
"stream": True,
|
|
49
|
+
"max_tokens": 8192,
|
|
50
|
+
}
|
|
51
|
+
if use_tools:
|
|
52
|
+
payload_dict["tools"] = ALL_TOOLS
|
|
53
|
+
payload_dict["tool_choice"] = "auto"
|
|
54
|
+
|
|
55
|
+
payload = orjson.dumps(payload_dict)
|
|
56
|
+
pending: dict[int, dict] = {}
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
async with AsyncSession(security=False, timeout=120.0) as session:
|
|
60
|
+
raw = session._ensure_open()
|
|
61
|
+
async with raw.stream(
|
|
62
|
+
"POST",
|
|
63
|
+
OPENROUTER_CHAT_URL,
|
|
64
|
+
headers=self._headers(),
|
|
65
|
+
content=payload,
|
|
66
|
+
timeout=120.0,
|
|
67
|
+
) as resp:
|
|
68
|
+
await self._check_status(resp)
|
|
69
|
+
async for line in resp.aiter_lines():
|
|
70
|
+
token = self._parse_sse(line, pending)
|
|
71
|
+
if token is not None:
|
|
72
|
+
yield token
|
|
73
|
+
except (AuthError, RateLimitError, APIError):
|
|
74
|
+
raise
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
msg = str(exc).lower()
|
|
77
|
+
if any(k in msg for k in ("connect", "timeout", "timed out", "network")):
|
|
78
|
+
raise NetworkError(str(exc)) from exc
|
|
79
|
+
raise NetworkError(f"Request failed: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
if pending:
|
|
82
|
+
yield [
|
|
83
|
+
ToolCallRequest(id=v["id"], name=v["name"], arguments=v["arguments"])
|
|
84
|
+
for v in sorted(pending.values(), key=lambda x: x.get("index", 0))
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
async def _check_status(resp: object) -> None:
|
|
89
|
+
code: int = resp.status_code # type: ignore[attr-defined]
|
|
90
|
+
if code == 401:
|
|
91
|
+
raise AuthError("Invalid API key. Run: hitmos login")
|
|
92
|
+
if code == 429:
|
|
93
|
+
raise RateLimitError("Rate limit exceeded. Try again later.")
|
|
94
|
+
if code >= 400:
|
|
95
|
+
body = await resp.aread() # type: ignore[attr-defined]
|
|
96
|
+
try:
|
|
97
|
+
err = orjson.loads(body)
|
|
98
|
+
msg = err.get("error", {}).get("message", f"HTTP {code}")
|
|
99
|
+
except Exception:
|
|
100
|
+
msg = f"HTTP {code}"
|
|
101
|
+
raise APIError(msg)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _parse_sse(line: str, pending: dict[int, dict]) -> str | None:
|
|
105
|
+
if not line.startswith("data: "):
|
|
106
|
+
return None
|
|
107
|
+
data = line[6:]
|
|
108
|
+
if data.strip() == "[DONE]":
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
chunk = orjson.loads(data)
|
|
112
|
+
delta = chunk["choices"][0]["delta"]
|
|
113
|
+
|
|
114
|
+
if content := delta.get("content"):
|
|
115
|
+
return content
|
|
116
|
+
|
|
117
|
+
for tc in delta.get("tool_calls", []):
|
|
118
|
+
idx = tc.get("index", 0)
|
|
119
|
+
if idx not in pending:
|
|
120
|
+
pending[idx] = {"index": idx, "id": "", "name": "", "arguments": ""}
|
|
121
|
+
if tc.get("id"):
|
|
122
|
+
pending[idx]["id"] = tc["id"]
|
|
123
|
+
fn = tc.get("function", {})
|
|
124
|
+
if fn.get("name"):
|
|
125
|
+
pending[idx]["name"] = fn["name"]
|
|
126
|
+
if args := fn.get("arguments"):
|
|
127
|
+
pending[idx]["arguments"] += args
|
|
128
|
+
|
|
129
|
+
return None
|
|
130
|
+
except (KeyError, IndexError, orjson.JSONDecodeError):
|
|
131
|
+
return None
|
hitmos/commands.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CommandType(Enum):
|
|
6
|
+
HELP = "help"
|
|
7
|
+
CLEAR = "clear"
|
|
8
|
+
RESET = "reset"
|
|
9
|
+
MODEL = "model"
|
|
10
|
+
EXIT = "exit"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CommandResult:
|
|
15
|
+
type: CommandType
|
|
16
|
+
arg: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandHandler:
|
|
20
|
+
_MAP: dict[str, CommandType] = {
|
|
21
|
+
"/help": CommandType.HELP,
|
|
22
|
+
"/clear": CommandType.CLEAR,
|
|
23
|
+
"/reset": CommandType.RESET,
|
|
24
|
+
"/model": CommandType.MODEL,
|
|
25
|
+
"/exit": CommandType.EXIT,
|
|
26
|
+
"/quit": CommandType.EXIT,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def parse(self, text: str) -> CommandResult | None:
|
|
30
|
+
if not text.startswith("/"):
|
|
31
|
+
return None
|
|
32
|
+
parts = text.split(maxsplit=1)
|
|
33
|
+
cmd_type = self._MAP.get(parts[0].lower())
|
|
34
|
+
if cmd_type is None:
|
|
35
|
+
return None
|
|
36
|
+
arg = parts[1] if len(parts) > 1 else None
|
|
37
|
+
return CommandResult(type=cmd_type, arg=arg)
|
hitmos/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
from .constants import CONFIG_DIR, CONFIG_FILE, DEFAULT_MODEL
|
|
7
|
+
from .exceptions import AuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _EnvSettings(BaseSettings):
|
|
11
|
+
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
|
12
|
+
|
|
13
|
+
open_token: str = ""
|
|
14
|
+
openrouter_api_key: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigManager:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._env = _EnvSettings()
|
|
20
|
+
self._file_config: dict = self._load_file()
|
|
21
|
+
|
|
22
|
+
def _load_file(self) -> dict:
|
|
23
|
+
if not CONFIG_FILE.exists():
|
|
24
|
+
return {}
|
|
25
|
+
try:
|
|
26
|
+
with CONFIG_FILE.open("rb") as f:
|
|
27
|
+
return tomllib.load(f)
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
def resolve_token(self) -> str:
|
|
32
|
+
token = (
|
|
33
|
+
self._env.open_token
|
|
34
|
+
or self._env.openrouter_api_key
|
|
35
|
+
or self._file_config.get("api_key", "")
|
|
36
|
+
)
|
|
37
|
+
if not token:
|
|
38
|
+
raise AuthError(
|
|
39
|
+
"No API key found.\n\n"
|
|
40
|
+
" Set env: export OPEN_TOKEN=sk-or-xxx\n"
|
|
41
|
+
" Or run: hitmos login"
|
|
42
|
+
)
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
def get_model(self) -> str:
|
|
46
|
+
return self._file_config.get("model", DEFAULT_MODEL)
|
|
47
|
+
|
|
48
|
+
def save_token(self, token: str) -> None:
|
|
49
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
data = self._load_file()
|
|
51
|
+
data["api_key"] = token
|
|
52
|
+
self._write_file(data)
|
|
53
|
+
|
|
54
|
+
def save_model(self, model: str) -> None:
|
|
55
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
data = self._load_file()
|
|
57
|
+
data["model"] = model
|
|
58
|
+
self._write_file(data)
|
|
59
|
+
|
|
60
|
+
def _write_file(self, data: dict) -> None:
|
|
61
|
+
lines = []
|
|
62
|
+
for key, value in data.items():
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
lines.append(f'{key} = "{value}"')
|
|
65
|
+
else:
|
|
66
|
+
lines.append(f"{key} = {value}")
|
|
67
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
hitmos/constants.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
VERSION = "0.1.0"
|
|
4
|
+
APP_NAME = "hitmos"
|
|
5
|
+
APP_TITLE = "Hitmos"
|
|
6
|
+
|
|
7
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
8
|
+
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
|
|
9
|
+
HTTP_REFERER = "https://github.com/ndugram/hitmos"
|
|
10
|
+
|
|
11
|
+
DEFAULT_MODEL = "deepseek/deepseek-chat"
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".hitmos"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = (
|
|
17
|
+
"You are Hitmos, a helpful AI coding assistant. "
|
|
18
|
+
"Be concise, accurate, and developer-focused."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
COMMANDS: dict[str, str] = {
|
|
22
|
+
"/help": "Show available commands",
|
|
23
|
+
"/clear": "Clear conversation history",
|
|
24
|
+
"/reset": "Reset context",
|
|
25
|
+
"/model": "Switch AI model (interactive picker)",
|
|
26
|
+
"/model <name>": "Switch AI model directly",
|
|
27
|
+
"/exit": "Exit Hitmos",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
AVAILABLE_MODELS: list[tuple[str, str]] = [
|
|
31
|
+
("deepseek/deepseek-chat", "DeepSeek Chat · fast, cheap, great for code"),
|
|
32
|
+
("deepseek/deepseek-r1", "DeepSeek R1 · reasoning model"),
|
|
33
|
+
("google/gemini-2.5-flash", "Gemini 2.5 Flash · fast multimodal"),
|
|
34
|
+
("google/gemini-2.5-pro", "Gemini 2.5 Pro · powerful multimodal"),
|
|
35
|
+
("anthropic/claude-3.5-haiku", "Claude 3.5 Haiku · fast Anthropic"),
|
|
36
|
+
("anthropic/claude-sonnet-4-5", "Claude Sonnet 4.5 · balanced Anthropic"),
|
|
37
|
+
("openai/gpt-4o-mini", "GPT-4o Mini · OpenAI fast"),
|
|
38
|
+
("openai/o4-mini", "o4-mini · OpenAI reasoning"),
|
|
39
|
+
("meta-llama/llama-3.3-70b-instruct", "Llama 3.3 70B · open source"),
|
|
40
|
+
("mistralai/mistral-small-3.2-24b-instruct","Mistral Small 3.2 · lightweight"),
|
|
41
|
+
]
|
hitmos/context.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProjectContext:
|
|
7
|
+
_IGNORE_DIRS = frozenset({
|
|
8
|
+
".git", "__pycache__", ".venv", "venv", "env",
|
|
9
|
+
"node_modules", "dist", "build", ".pytest_cache",
|
|
10
|
+
".mypy_cache", ".ruff_cache", "eggs", ".eggs",
|
|
11
|
+
".tox", "htmlcov", ".idea", ".vscode",
|
|
12
|
+
})
|
|
13
|
+
_IGNORE_EXTS = frozenset({
|
|
14
|
+
".pyc", ".pyo", ".pyd", ".so", ".dylib", ".dll",
|
|
15
|
+
".jpg", ".jpeg", ".png", ".gif", ".ico", ".svg", ".webp",
|
|
16
|
+
".pdf", ".zip", ".tar", ".gz", ".bz2", ".whl", ".egg",
|
|
17
|
+
".db", ".sqlite", ".sqlite3", ".bin", ".pkl",
|
|
18
|
+
})
|
|
19
|
+
_PRIORITY_NAMES = frozenset({
|
|
20
|
+
"README.md", "README.rst", "README.txt",
|
|
21
|
+
"pyproject.toml", "package.json", "Cargo.toml",
|
|
22
|
+
"setup.py", "setup.cfg", "requirements.txt",
|
|
23
|
+
"Makefile", "docker-compose.yml", "Dockerfile",
|
|
24
|
+
".env.example",
|
|
25
|
+
})
|
|
26
|
+
MAX_FILE_BYTES = 25_000
|
|
27
|
+
MAX_TOTAL_BYTES = 120_000
|
|
28
|
+
|
|
29
|
+
def __init__(self, cwd: Path) -> None:
|
|
30
|
+
self.cwd = cwd
|
|
31
|
+
|
|
32
|
+
def build(self) -> tuple[str, int]:
|
|
33
|
+
"""Returns (context_string, size_in_kb)."""
|
|
34
|
+
parts: list[str] = []
|
|
35
|
+
|
|
36
|
+
tree = self._tree()
|
|
37
|
+
if tree:
|
|
38
|
+
parts.append(f"Project structure ({self.cwd}):\n{tree}")
|
|
39
|
+
|
|
40
|
+
total = sum(len(p) for p in parts)
|
|
41
|
+
for path in self._collect():
|
|
42
|
+
if total >= self.MAX_TOTAL_BYTES:
|
|
43
|
+
parts.append("(context limit reached — more files exist)")
|
|
44
|
+
break
|
|
45
|
+
text = self._read(path)
|
|
46
|
+
if text is None:
|
|
47
|
+
continue
|
|
48
|
+
rel = str(path.relative_to(self.cwd))
|
|
49
|
+
block = f"--- {rel} ---\n{text}"
|
|
50
|
+
parts.append(block)
|
|
51
|
+
total += len(block)
|
|
52
|
+
|
|
53
|
+
context = "\n\n".join(parts)
|
|
54
|
+
return context, len(context) // 1024
|
|
55
|
+
|
|
56
|
+
def _tree(self) -> str:
|
|
57
|
+
lines: list[str] = []
|
|
58
|
+
self._walk_tree(self.cwd, "", lines, 0)
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
def _walk_tree(self, path: Path, prefix: str, lines: list[str], depth: int) -> None:
|
|
62
|
+
if depth > 4:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
children = sorted(
|
|
66
|
+
[p for p in path.iterdir() if self._visible(p)],
|
|
67
|
+
key=lambda p: (p.is_file(), p.name.lower()),
|
|
68
|
+
)
|
|
69
|
+
except PermissionError:
|
|
70
|
+
return
|
|
71
|
+
for i, child in enumerate(children):
|
|
72
|
+
is_last = i == len(children) - 1
|
|
73
|
+
lines.append(f"{prefix}{'└── ' if is_last else '├── '}{child.name}")
|
|
74
|
+
if child.is_dir():
|
|
75
|
+
self._walk_tree(child, prefix + (" " if is_last else "│ "), lines, depth + 1)
|
|
76
|
+
|
|
77
|
+
def _visible(self, path: Path) -> bool:
|
|
78
|
+
name = path.name
|
|
79
|
+
if name.startswith(".") and name not in {".env.example"}:
|
|
80
|
+
return False
|
|
81
|
+
if path.is_dir() and name in self._IGNORE_DIRS:
|
|
82
|
+
return False
|
|
83
|
+
if path.is_file() and path.suffix in self._IGNORE_EXTS:
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def _collect(self) -> list[Path]:
|
|
88
|
+
priority: list[Path] = []
|
|
89
|
+
others: list[Path] = []
|
|
90
|
+
for path in self._rglob():
|
|
91
|
+
(priority if path.name in self._PRIORITY_NAMES else others).append(path)
|
|
92
|
+
return priority + sorted(others, key=lambda p: str(p))
|
|
93
|
+
|
|
94
|
+
def _rglob(self) -> list[Path]:
|
|
95
|
+
result: list[Path] = []
|
|
96
|
+
for path in self.cwd.rglob("*"):
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
continue
|
|
99
|
+
rel_parts = path.relative_to(self.cwd).parts
|
|
100
|
+
if any(
|
|
101
|
+
p in self._IGNORE_DIRS or (p.startswith(".") and p != ".env.example")
|
|
102
|
+
for p in rel_parts[:-1]
|
|
103
|
+
):
|
|
104
|
+
continue
|
|
105
|
+
if path.suffix in self._IGNORE_EXTS:
|
|
106
|
+
continue
|
|
107
|
+
result.append(path)
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
def _read(self, path: Path) -> str | None:
|
|
111
|
+
try:
|
|
112
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
113
|
+
if not text.strip():
|
|
114
|
+
return None
|
|
115
|
+
if len(text) > self.MAX_FILE_BYTES:
|
|
116
|
+
text = text[: self.MAX_FILE_BYTES] + "\n... (truncated)"
|
|
117
|
+
return text
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
hitmos/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class HitmosError(Exception):
|
|
2
|
+
"""Base exception."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthError(HitmosError):
|
|
6
|
+
"""Invalid or missing API key."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NetworkError(HitmosError):
|
|
10
|
+
"""Network connectivity issue."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(HitmosError):
|
|
14
|
+
"""API returned an error."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RateLimitError(APIError):
|
|
18
|
+
"""Rate limit exceeded."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(HitmosError):
|
|
22
|
+
"""Configuration error."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import orjson
|
|
2
|
+
|
|
3
|
+
from .base import BaseMethod, _snake
|
|
4
|
+
from .edit_file import EditFile
|
|
5
|
+
from .list_directory import ListDirectory
|
|
6
|
+
from .read_file import ReadFile
|
|
7
|
+
from .run_command import RunCommand
|
|
8
|
+
from .write_file import WriteFile
|
|
9
|
+
|
|
10
|
+
ALL_METHODS: list[type[BaseMethod]] = [
|
|
11
|
+
WriteFile,
|
|
12
|
+
ReadFile,
|
|
13
|
+
EditFile,
|
|
14
|
+
ListDirectory,
|
|
15
|
+
RunCommand,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
ALL_TOOLS: list[dict] = [m.as_tool() for m in ALL_METHODS]
|
|
19
|
+
|
|
20
|
+
_METHOD_MAP: dict[str, type[BaseMethod]] = {
|
|
21
|
+
_snake(m.__name__): m for m in ALL_METHODS
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dispatch(name: str, arguments: str | dict) -> str:
|
|
26
|
+
"""Parse arguments and execute the named method."""
|
|
27
|
+
cls = _METHOD_MAP.get(name)
|
|
28
|
+
if cls is None:
|
|
29
|
+
return f"Unknown method: {name}"
|
|
30
|
+
if isinstance(arguments, str):
|
|
31
|
+
arguments = orjson.loads(arguments)
|
|
32
|
+
return cls.model_validate(arguments).call()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BaseMethod",
|
|
37
|
+
"WriteFile",
|
|
38
|
+
"ReadFile",
|
|
39
|
+
"EditFile",
|
|
40
|
+
"ListDirectory",
|
|
41
|
+
"RunCommand",
|
|
42
|
+
"ALL_METHODS",
|
|
43
|
+
"ALL_TOOLS",
|
|
44
|
+
"dispatch",
|
|
45
|
+
]
|
hitmos/methods/base.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _snake(name: str) -> str:
|
|
8
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseMethod(BaseModel):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def call(self) -> str: ...
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def as_tool(cls) -> dict:
|
|
17
|
+
schema = cls.model_json_schema()
|
|
18
|
+
params = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
k: {kk: vv for kk, vv in v.items() if kk != "title"}
|
|
22
|
+
for k, v in schema.get("properties", {}).items()
|
|
23
|
+
},
|
|
24
|
+
"required": schema.get("required", []),
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
"type": "function",
|
|
28
|
+
"function": {
|
|
29
|
+
"name": _snake(cls.__name__),
|
|
30
|
+
"description": (cls.__doc__ or "").strip(),
|
|
31
|
+
"parameters": params,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EditFile(BaseMethod):
|
|
9
|
+
"""Replace the first occurrence of old_string with new_string in a file."""
|
|
10
|
+
|
|
11
|
+
path: str = Field(description="File path to edit")
|
|
12
|
+
old_string: str = Field(description="Exact string to find and replace")
|
|
13
|
+
new_string: str = Field(description="Replacement string")
|
|
14
|
+
|
|
15
|
+
def call(self) -> str:
|
|
16
|
+
p = Path(self.path)
|
|
17
|
+
if not p.exists():
|
|
18
|
+
return f"File not found: {self.path}"
|
|
19
|
+
text = p.read_text(encoding="utf-8")
|
|
20
|
+
if self.old_string not in text:
|
|
21
|
+
return f"String not found in {self.path}"
|
|
22
|
+
p.write_text(text.replace(self.old_string, self.new_string, 1), encoding="utf-8")
|
|
23
|
+
return f"Edited {self.path}"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ListDirectory(BaseMethod):
|
|
9
|
+
"""List files and subdirectories at the given path."""
|
|
10
|
+
|
|
11
|
+
path: str = Field(default=".", description="Directory path (default: current directory)")
|
|
12
|
+
|
|
13
|
+
def call(self) -> str:
|
|
14
|
+
p = Path(self.path)
|
|
15
|
+
if not p.exists():
|
|
16
|
+
return f"Path not found: {self.path}"
|
|
17
|
+
items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
|
18
|
+
if not items:
|
|
19
|
+
return "(empty)"
|
|
20
|
+
return "\n".join(f"{'dir ' if x.is_dir() else 'file'} {x.name}" for x in items)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReadFile(BaseMethod):
|
|
9
|
+
"""Read the full contents of a file."""
|
|
10
|
+
|
|
11
|
+
path: str = Field(description="File path to read")
|
|
12
|
+
|
|
13
|
+
def call(self) -> str:
|
|
14
|
+
p = Path(self.path)
|
|
15
|
+
if not p.exists():
|
|
16
|
+
return f"File not found: {self.path}"
|
|
17
|
+
return p.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RunCommand(BaseMethod):
|
|
9
|
+
"""Run a shell command and return its output."""
|
|
10
|
+
|
|
11
|
+
command: str = Field(description="Shell command to execute")
|
|
12
|
+
timeout: int = Field(default=30, description="Timeout in seconds")
|
|
13
|
+
|
|
14
|
+
def call(self) -> str:
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
self.command,
|
|
18
|
+
shell=True,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=self.timeout,
|
|
22
|
+
)
|
|
23
|
+
out = result.stdout.strip()
|
|
24
|
+
err = result.stderr.strip()
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
return f"Exit {result.returncode}\n{err or out}"
|
|
27
|
+
return out or "(no output)"
|
|
28
|
+
except subprocess.TimeoutExpired:
|
|
29
|
+
return f"Command timed out after {self.timeout}s"
|
|
30
|
+
except Exception as e:
|
|
31
|
+
return f"Error: {e}"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from .base import BaseMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WriteFile(BaseMethod):
|
|
9
|
+
"""Create or overwrite a file with the given content."""
|
|
10
|
+
|
|
11
|
+
path: str = Field(description="File path (relative to cwd or absolute)")
|
|
12
|
+
content: str = Field(description="Full file content to write")
|
|
13
|
+
|
|
14
|
+
def call(self) -> str:
|
|
15
|
+
p = Path(self.path)
|
|
16
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
p.write_text(self.content, encoding="utf-8")
|
|
18
|
+
return f"Written {len(self.content)} chars to {self.path}"
|
hitmos/ui.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
import questionary
|
|
6
|
+
from prompt_toolkit import PromptSession
|
|
7
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
8
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
9
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
10
|
+
from questionary import Style as QStyle
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from .constants import APP_TITLE, AVAILABLE_MODELS, COMMANDS
|
|
19
|
+
from .utils import get_cwd_display
|
|
20
|
+
|
|
21
|
+
# ── questionary picker style ──────────────────────────────────────────────────
|
|
22
|
+
_PICKER_STYLE = QStyle([
|
|
23
|
+
("qmark", "fg:#888888"),
|
|
24
|
+
("question", "fg:#888888"),
|
|
25
|
+
("answer", "fg:#00d7af bold"),
|
|
26
|
+
("pointer", "fg:#00d7af bold"),
|
|
27
|
+
("highlighted", "fg:#00d7af"),
|
|
28
|
+
("selected", "fg:#00d7af"),
|
|
29
|
+
("separator", "fg:#444444"),
|
|
30
|
+
("instruction", "fg:#444444"),
|
|
31
|
+
("text", ""),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
# ── prompt input style ────────────────────────────────────────────────────────
|
|
35
|
+
_INPUT_STYLE = PTStyle.from_dict({
|
|
36
|
+
"prompt": "bold",
|
|
37
|
+
"completion-menu.completion": "bg:#1e1e1e #888888",
|
|
38
|
+
"completion-menu.completion.current": "bg:#00d7af #000000 bold",
|
|
39
|
+
"completion-menu.meta.completion": "bg:#1a1a1a #555555",
|
|
40
|
+
"completion-menu.meta.completion.current": "bg:#00875f #000000",
|
|
41
|
+
"scrollbar.background": "bg:#2a2a2a",
|
|
42
|
+
"scrollbar.button": "bg:#555555",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
_COMMAND_COMPLETIONS = ["/help", "/clear", "/reset", "/model", "/exit"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _HitmosCompleter(Completer):
|
|
49
|
+
def get_completions(self, document, complete_event):
|
|
50
|
+
text = document.text_before_cursor
|
|
51
|
+
|
|
52
|
+
if text.lower().startswith("/model "):
|
|
53
|
+
typed = text[7:]
|
|
54
|
+
for model_id, desc in AVAILABLE_MODELS:
|
|
55
|
+
if model_id.startswith(typed):
|
|
56
|
+
yield Completion(
|
|
57
|
+
model_id,
|
|
58
|
+
start_position=-len(typed),
|
|
59
|
+
display=model_id,
|
|
60
|
+
display_meta=desc,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
elif text.startswith("/") and " " not in text:
|
|
64
|
+
for cmd in _COMMAND_COMPLETIONS:
|
|
65
|
+
if cmd.startswith(text):
|
|
66
|
+
yield Completion(cmd, start_position=-len(text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ConsoleUI:
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.console = Console(highlight=False)
|
|
72
|
+
self._session: PromptSession = PromptSession(
|
|
73
|
+
completer=_HitmosCompleter(),
|
|
74
|
+
complete_while_typing=False,
|
|
75
|
+
style=_INPUT_STYLE,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def show_welcome(self, model: str, ctx_kb: int = 0) -> None:
|
|
79
|
+
self.console.print()
|
|
80
|
+
self.console.print(
|
|
81
|
+
Panel(
|
|
82
|
+
Text.from_markup(
|
|
83
|
+
f"[bold]✻ Welcome to {APP_TITLE}[/bold]\n\n"
|
|
84
|
+
"[dim]/help for commands[/dim]"
|
|
85
|
+
),
|
|
86
|
+
border_style="dim",
|
|
87
|
+
padding=(1, 2),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
self.console.print()
|
|
91
|
+
self.console.print(f" [dim]◆[/dim] [dim]{get_cwd_display()}[/dim]")
|
|
92
|
+
self.console.print(f" [dim]◆[/dim] [dim]{model}[/dim]")
|
|
93
|
+
if ctx_kb > 0:
|
|
94
|
+
self.console.print(f" [dim]◆[/dim] [dim]context {ctx_kb} KB[/dim]")
|
|
95
|
+
self.console.print()
|
|
96
|
+
|
|
97
|
+
def show_help(self) -> None:
|
|
98
|
+
table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
|
|
99
|
+
table.add_column(style="bold cyan", no_wrap=True)
|
|
100
|
+
table.add_column(style="dim")
|
|
101
|
+
for cmd, desc in COMMANDS.items():
|
|
102
|
+
table.add_row(cmd, desc)
|
|
103
|
+
self.console.print()
|
|
104
|
+
self.console.print(table)
|
|
105
|
+
self.console.print()
|
|
106
|
+
|
|
107
|
+
async def show_model_picker(self, current: str) -> str | None:
|
|
108
|
+
self.console.print()
|
|
109
|
+
|
|
110
|
+
choices = [
|
|
111
|
+
questionary.Choice(
|
|
112
|
+
title=f"{model_id:<48} {desc}",
|
|
113
|
+
value=model_id,
|
|
114
|
+
)
|
|
115
|
+
for model_id, desc in AVAILABLE_MODELS
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
default = next((c for c in choices if c.value == current), choices[0])
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
result = await questionary.select(
|
|
122
|
+
"Select model (↑↓ navigate, Enter confirm, Ctrl+C cancel)",
|
|
123
|
+
choices=choices,
|
|
124
|
+
default=default,
|
|
125
|
+
style=_PICKER_STYLE,
|
|
126
|
+
use_shortcuts=False,
|
|
127
|
+
use_arrow_keys=True,
|
|
128
|
+
).ask_async()
|
|
129
|
+
except KeyboardInterrupt:
|
|
130
|
+
result = None
|
|
131
|
+
|
|
132
|
+
self.console.print()
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def show_error(self, message: str) -> None:
|
|
136
|
+
self.console.print()
|
|
137
|
+
lines = message.split("\n")
|
|
138
|
+
self.console.print(f" [bold red]✖[/bold red] {lines[0]}")
|
|
139
|
+
for line in lines[1:]:
|
|
140
|
+
if line:
|
|
141
|
+
self.console.print(f" {line}")
|
|
142
|
+
self.console.print()
|
|
143
|
+
|
|
144
|
+
def show_info(self, message: str) -> None:
|
|
145
|
+
self.console.print()
|
|
146
|
+
self.console.print(f" [dim]{message}[/dim]")
|
|
147
|
+
self.console.print()
|
|
148
|
+
|
|
149
|
+
def show_success(self, message: str) -> None:
|
|
150
|
+
self.console.print()
|
|
151
|
+
self.console.print(f" [bold green]✓[/bold green] {message}")
|
|
152
|
+
self.console.print()
|
|
153
|
+
|
|
154
|
+
async def stream_response(self, token_gen: AsyncGenerator[str, None]) -> str:
|
|
155
|
+
self.console.print()
|
|
156
|
+
buffer = ""
|
|
157
|
+
|
|
158
|
+
async for first in token_gen:
|
|
159
|
+
buffer = first
|
|
160
|
+
break
|
|
161
|
+
else:
|
|
162
|
+
return buffer
|
|
163
|
+
|
|
164
|
+
MAX_CHARS = 24_000
|
|
165
|
+
truncated = False
|
|
166
|
+
interrupted = False
|
|
167
|
+
|
|
168
|
+
with Live(
|
|
169
|
+
Markdown(buffer),
|
|
170
|
+
console=self.console,
|
|
171
|
+
refresh_per_second=15,
|
|
172
|
+
vertical_overflow="visible",
|
|
173
|
+
) as live:
|
|
174
|
+
try:
|
|
175
|
+
async for token in token_gen:
|
|
176
|
+
buffer += token
|
|
177
|
+
live.update(Markdown(buffer))
|
|
178
|
+
if len(buffer) >= MAX_CHARS:
|
|
179
|
+
truncated = True
|
|
180
|
+
break
|
|
181
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
182
|
+
interrupted = True
|
|
183
|
+
finally:
|
|
184
|
+
await token_gen.aclose()
|
|
185
|
+
|
|
186
|
+
if truncated:
|
|
187
|
+
self.console.print()
|
|
188
|
+
self.console.print(" [dim]Response truncated.[/dim]")
|
|
189
|
+
self.console.print()
|
|
190
|
+
if interrupted:
|
|
191
|
+
self.console.print(" [dim]Interrupted.[/dim]")
|
|
192
|
+
self.console.print()
|
|
193
|
+
return buffer
|
|
194
|
+
|
|
195
|
+
async def get_input(self) -> str:
|
|
196
|
+
return await self._session.prompt_async(
|
|
197
|
+
FormattedText([("class:prompt", "> ")]),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def show_tool_call(self, name: str, arguments: str) -> None:
|
|
201
|
+
try:
|
|
202
|
+
args = orjson.loads(arguments)
|
|
203
|
+
summary = " ".join(f"[dim]{k}[/dim] {str(v)[:60]}" for k, v in args.items())
|
|
204
|
+
except Exception:
|
|
205
|
+
summary = arguments[:80]
|
|
206
|
+
self.console.print(f" [bold cyan]⚙[/bold cyan] [cyan]{name}[/cyan] {summary}")
|
|
207
|
+
|
|
208
|
+
def show_tool_result(self, result: str) -> None:
|
|
209
|
+
first_line = result.split("\n")[0]
|
|
210
|
+
preview = first_line[:80] + ("…" if len(result) > 80 else "")
|
|
211
|
+
self.console.print(f" [dim]→ {preview}[/dim]")
|
|
212
|
+
self.console.print()
|
|
213
|
+
|
|
214
|
+
def show_exit(self) -> None:
|
|
215
|
+
self.console.print()
|
|
216
|
+
self.console.print(" [dim]Goodbye.[/dim]")
|
|
217
|
+
self.console.print()
|
hitmos/utils.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hitmos
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: AI terminal assistant powered by OpenRouter — minimal, fast, developer UX
|
|
5
|
+
Project-URL: Homepage, https://github.com/ndugram/hitmos
|
|
6
|
+
Project-URL: Documentation, https://github.com/ndugram/hitmos
|
|
7
|
+
Project-URL: Repository, https://github.com/ndugram/hitmos
|
|
8
|
+
Project-URL: Issues, https://github.com/ndugram/hitmos/issues
|
|
9
|
+
Author-email: White NEFOR <n7for8572@gmail.com>
|
|
10
|
+
Maintainer-email: White NEFOR <n7for8572@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
Keywords: ai,assistant,chat,cli,deepseek,developer-tools,llm,openrouter,python,terminal
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Terminals
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.13
|
|
26
|
+
Requires-Dist: fasthttp-client>=1.3.13
|
|
27
|
+
Requires-Dist: orjson>=3.9.0
|
|
28
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
29
|
+
Requires-Dist: questionary>=2.1.1
|
|
30
|
+
Requires-Dist: rich>=15.0.0
|
|
31
|
+
Requires-Dist: typer>=0.26.7
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://raw.githubusercontent.com/ndugram/hitmos/master/docs/logo.png" style="background:white; padding:12px; border-radius:10px; width:300">
|
|
36
|
+
</p>
|
|
37
|
+
<p align="center">
|
|
38
|
+
<em>AI terminal assistant powered by OpenRouter — minimal, fast, developer UX.</em>
|
|
39
|
+
</p>
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://pypi.org/project/hitmos" target="_blank">
|
|
42
|
+
<img src="https://img.shields.io/pypi/v/hitmos?color=%2300d7af&label=pypi%20package" alt="Package version">
|
|
43
|
+
</a>
|
|
44
|
+
<a href="https://pypi.org/project/hitmos" target="_blank">
|
|
45
|
+
<img src="https://img.shields.io/pypi/pyversions/hitmos.svg?color=%2300d7af" alt="Supported Python versions">
|
|
46
|
+
</a>
|
|
47
|
+
<a href="https://pypi.org/project/hitmos" target="_blank">
|
|
48
|
+
<img src="https://img.shields.io/pypi/dm/hitmos?color=%2300d7af&label=downloads" alt="Monthly downloads">
|
|
49
|
+
</a>
|
|
50
|
+
<a href="https://pepy.tech/projects/hitmos" target="_blank">
|
|
51
|
+
<img src="https://img.shields.io/pepy/dt/hitmos?color=%2300d7af&label=total%20downloads" alt="Total downloads">
|
|
52
|
+
</a>
|
|
53
|
+
<a href="https://github.com/ndugram/hitmos" target="_blank">
|
|
54
|
+
<img src="https://img.shields.io/github/stars/ndugram/hitmos?style=social" alt="GitHub Stars">
|
|
55
|
+
</a>
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
**Source Code**: <a href="https://github.com/ndugram/hitmos" target="_blank">https://github.com/ndugram/hitmos</a>
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
Hitmos is a modern **AI terminal assistant** for developers, powered by <a href="https://openrouter.ai" target="_blank">OpenRouter</a>. It brings a Claude Code–styled UX to your terminal — live markdown streaming, interactive model switching, project context injection, and Tab completion, all with zero latency overhead.
|
|
65
|
+
|
|
66
|
+
Key features:
|
|
67
|
+
|
|
68
|
+
- **Streaming** — responses stream token-by-token with live Markdown rendering via <a href="https://github.com/Textualize/rich" target="_blank">rich</a>.
|
|
69
|
+
- **Multi-model** — switch between DeepSeek, Gemini, Claude, GPT-4o, Llama, and more with an interactive arrow-key picker.
|
|
70
|
+
- **Project context** — automatically injects your project files into the system prompt so the model can answer project-specific questions.
|
|
71
|
+
- **Tab completion** — `/model <Tab>` lists all available models; `/` completes all commands.
|
|
72
|
+
- **Minimal UX** — Claude Code–inspired welcome panel, `◆` status lines, clean `>` prompt.
|
|
73
|
+
- **Fast** — built on <a href="https://github.com/ndugram/fasthttp" target="_blank">fasthttp-client</a> with full async SSE streaming.
|
|
74
|
+
- **Configurable** — API key via env (`OPEN_TOKEN`, `OPENROUTER_API_KEY`) or `~/.hitmos/config.toml`.
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
Python 3.13+
|
|
79
|
+
|
|
80
|
+
Hitmos depends on:
|
|
81
|
+
|
|
82
|
+
- <a href="https://github.com/ndugram/fasthttp" target="_blank"><code>fasthttp-client</code></a> — async HTTP transport with SSE streaming.
|
|
83
|
+
- <a href="https://github.com/Textualize/rich" target="_blank"><code>rich</code></a> — live Markdown rendering and styled console output.
|
|
84
|
+
- <a href="https://typer.tiangolo.com/" target="_blank"><code>typer</code></a> — CLI interface.
|
|
85
|
+
- <a href="https://github.com/ijl/orjson" target="_blank"><code>orjson</code></a> — fast JSON parsing.
|
|
86
|
+
- <a href="https://github.com/prompt-toolkit/python-prompt-toolkit" target="_blank"><code>prompt-toolkit</code></a> — async input with Tab completion (bundled with questionary).
|
|
87
|
+
- <a href="https://github.com/tmbo/questionary" target="_blank"><code>questionary</code></a> — interactive model picker.
|
|
88
|
+
- <a href="https://docs.pydantic.dev/" target="_blank"><code>pydantic-settings</code></a> — config management.
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ pip install hitmos
|
|
94
|
+
|
|
95
|
+
---> 100%
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Quickstart
|
|
99
|
+
|
|
100
|
+
### Save your API key
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
$ hitmos login
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or set an environment variable:
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ export OPEN_TOKEN=sk-or-...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Start chatting
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
$ hitmos
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You will see:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
╭─────────────────────────────────────────────────────╮
|
|
122
|
+
│ │
|
|
123
|
+
│ ✻ Welcome to Hitmos │
|
|
124
|
+
│ │
|
|
125
|
+
│ /help for commands │
|
|
126
|
+
│ │
|
|
127
|
+
╰─────────────────────────────────────────────────────╯
|
|
128
|
+
|
|
129
|
+
◆ ~/my-project
|
|
130
|
+
◆ deepseek/deepseek-chat
|
|
131
|
+
◆ context 12 KB
|
|
132
|
+
|
|
133
|
+
>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Ask anything about your project:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
> what does this project do?
|
|
140
|
+
> add error handling to client.py
|
|
141
|
+
> explain the streaming logic
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Commands
|
|
145
|
+
|
|
146
|
+
| Command | Description |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `/help` | Show available commands |
|
|
149
|
+
| `/model` | Interactive model picker (↑↓ + Enter) |
|
|
150
|
+
| `/model <name>` | Switch model directly |
|
|
151
|
+
| `/clear` | Clear conversation history |
|
|
152
|
+
| `/reset` | Reset context |
|
|
153
|
+
| `/exit` | Exit Hitmos |
|
|
154
|
+
|
|
155
|
+
### Switch model interactively
|
|
156
|
+
|
|
157
|
+
Type `/model` and press Enter to open the arrow-key picker:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
? Select model (↑↓ navigate, Enter confirm, Ctrl+C cancel)
|
|
161
|
+
❯ deepseek/deepseek-chat DeepSeek Chat · fast, cheap, great for code
|
|
162
|
+
deepseek/deepseek-r1 DeepSeek R1 · reasoning model
|
|
163
|
+
google/gemini-2.5-flash Gemini 2.5 Flash · fast multimodal
|
|
164
|
+
google/gemini-2.5-pro Gemini 2.5 Pro · powerful multimodal
|
|
165
|
+
anthropic/claude-3.5-haiku Claude 3.5 Haiku · fast Anthropic
|
|
166
|
+
anthropic/claude-sonnet-4-5 Claude Sonnet 4.5 · balanced Anthropic
|
|
167
|
+
openai/gpt-4o-mini GPT-4o Mini · OpenAI fast
|
|
168
|
+
openai/o4-mini o4-mini · OpenAI reasoning
|
|
169
|
+
meta-llama/llama-3.3-70b-instruct Llama 3.3 70B · open source
|
|
170
|
+
mistralai/mistral-small-3.2-24b-instruct Mistral Small 3.2 · lightweight
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Or use Tab completion: type `/model deep` then press `Tab`.
|
|
174
|
+
|
|
175
|
+
## Configuration
|
|
176
|
+
|
|
177
|
+
Hitmos resolves the API key in this order:
|
|
178
|
+
|
|
179
|
+
1. `OPEN_TOKEN` environment variable
|
|
180
|
+
2. `OPENROUTER_API_KEY` environment variable
|
|
181
|
+
3. `~/.hitmos/config.toml`
|
|
182
|
+
|
|
183
|
+
Config file format:
|
|
184
|
+
|
|
185
|
+
```toml
|
|
186
|
+
token = "sk-or-..."
|
|
187
|
+
model = "deepseek/deepseek-chat"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Contributing
|
|
191
|
+
|
|
192
|
+
Contributions are welcome! Please open an issue or pull request on <a href="https://github.com/ndugram/hitmos" target="_blank">GitHub</a>.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
This project is licensed under the terms of the <a href="https://github.com/ndugram/hitmos/blob/master/LICENSE" target="_blank">MIT license</a>.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
hitmos/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
hitmos/app.py,sha256=QkNm0M7yirdmlu_fcxFslOaOJtvuE3qIsgEBOH2IaXQ,5730
|
|
3
|
+
hitmos/chat.py,sha256=w4xsAEB-ZwuhGKeoRE1fXFpIuvJcl4VvTD3gDO6RYYM,977
|
|
4
|
+
hitmos/cli.py,sha256=oJIPN6CgxchZs4OEmyXIiAUyfpNW1RACbVXgr84icBg,1126
|
|
5
|
+
hitmos/client.py,sha256=o2926AoPE6INGCyEy-OAus36Wgk5OaaCNqAMJCbmnoU,4399
|
|
6
|
+
hitmos/commands.py,sha256=X9fQmBUcc8wYQHLwALhE-ailWRjj8Mf-MACYunB63vg,910
|
|
7
|
+
hitmos/config.py,sha256=fbRy_HvGdHb7K3JZ61haBfSMZkxxiS7O3q8NdTEPQCw,1961
|
|
8
|
+
hitmos/constants.py,sha256=XirbJf-tlXErx3UV6uyUCDDBUzsQ7Hwv6DfKXQOnMaw,1688
|
|
9
|
+
hitmos/context.py,sha256=6_Mkq65K1eEJ3H1f59PUvetWmfYLcCYuqxE_F9ZHG8I,4203
|
|
10
|
+
hitmos/exceptions.py,sha256=1PRjXeX4_QoTetYEyOojehkOvBhP9E40ghSmT5YmS4Y,393
|
|
11
|
+
hitmos/ui.py,sha256=Mk-_ky3mW-Ut9Vyo-tHrqLbc7orPeki1BnLxBQW_4Cw,7672
|
|
12
|
+
hitmos/utils.py,sha256=tFHXLk-1TezhWiPQoNEdV_Ng1AxaeL6_Y5lyRhFB2QQ,231
|
|
13
|
+
hitmos/methods/__init__.py,sha256=ZC1vMjNZ8Wxu578ztwt1RmrlLnpGbWDObN2kSYs4WVA,1011
|
|
14
|
+
hitmos/methods/base.py,sha256=lBPcLQeWZjyE9N5qTGScj5GCh8F48ziasC-MEPXtXPY,865
|
|
15
|
+
hitmos/methods/edit_file.py,sha256=WcowNwSrqujPQekSRix43AgljYbtB1FuoxvJmZ3qXhw,786
|
|
16
|
+
hitmos/methods/list_directory.py,sha256=UEaDLrfy-ibArdgDz0l12jg0-4e1FYGKgakMqXMl5eQ,620
|
|
17
|
+
hitmos/methods/read_file.py,sha256=p-pi8ukMSMfC7yCFq1N5thRTtz7kVOQn_GRwz9zbfCc,391
|
|
18
|
+
hitmos/methods/run_command.py,sha256=mbiDkQDOjxlGWJv2sgqHJBMOwc_mLFJrfK04YEQtuWI,942
|
|
19
|
+
hitmos/methods/write_file.py,sha256=ntxW5453n4CP4ck9GJx_1zFC7dX0UNeS2cPrQ2f6XLI,548
|
|
20
|
+
hitmos-0.0.1.dist-info/METADATA,sha256=OakZJEhhEQ4cmeyxMG-mw3g8Va1INXP3SkT4QNfP1UY,7845
|
|
21
|
+
hitmos-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
22
|
+
hitmos-0.0.1.dist-info/entry_points.txt,sha256=NWubdQwXCRS7hB_tdxuYxCchPl7A_OoT7x14EO76mQo,42
|
|
23
|
+
hitmos-0.0.1.dist-info/RECORD,,
|