tarmina 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.
tarmina/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ import sys
2
+ import io
3
+
4
+ if sys.platform == "win32":
5
+ try:
6
+ sys.stdout = io.TextIOWrapper(
7
+ sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True
8
+ )
9
+ sys.stderr = io.TextIOWrapper(
10
+ sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True
11
+ )
12
+ except Exception:
13
+ pass
tarmina/ai.py ADDED
@@ -0,0 +1,157 @@
1
+ from typing import AsyncGenerator
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class AIConfig:
7
+ provider: str
8
+ model: str
9
+ api_key: str = ""
10
+ base_url: str = ""
11
+ temperature: float = 0.8
12
+ max_tokens: int = 2048
13
+
14
+
15
+ class AIProvider:
16
+ def __init__(self, config: AIConfig):
17
+ self.config = config
18
+
19
+ async def chat(self, messages: list[dict], system_prompt: str = "") -> str:
20
+ raise NotImplementedError
21
+
22
+ async def stream(self, messages: list[dict], system_prompt: str = "") -> AsyncGenerator[str, None]:
23
+ raise NotImplementedError
24
+
25
+
26
+ class OpenAIProvider(AIProvider):
27
+ def __init__(self, config: AIConfig):
28
+ super().__init__(config)
29
+ from openai import AsyncOpenAI
30
+ kwargs = {"api_key": config.api_key, "max_retries": 2}
31
+ if config.base_url:
32
+ kwargs["base_url"] = config.base_url
33
+ self.client = AsyncOpenAI(**kwargs)
34
+
35
+ def _build(self, messages, system_prompt):
36
+ msgs = []
37
+ if system_prompt:
38
+ msgs.append({"role": "system", "content": system_prompt})
39
+ msgs.extend(messages)
40
+ return msgs
41
+
42
+ async def chat(self, messages, system_prompt=""):
43
+ r = await self.client.chat.completions.create(
44
+ model=self.config.model,
45
+ messages=self._build(messages, system_prompt),
46
+ temperature=self.config.temperature,
47
+ max_tokens=self.config.max_tokens,
48
+ )
49
+ return r.choices[0].message.content or ""
50
+
51
+ async def stream(self, messages, system_prompt=""):
52
+ s = await self.client.chat.completions.create(
53
+ model=self.config.model,
54
+ messages=self._build(messages, system_prompt),
55
+ temperature=self.config.temperature,
56
+ max_tokens=self.config.max_tokens,
57
+ stream=True,
58
+ )
59
+ async for chunk in s:
60
+ if chunk.choices[0].delta.content:
61
+ yield chunk.choices[0].delta.content
62
+
63
+
64
+ class OllamaProvider(AIProvider):
65
+ def __init__(self, config: AIConfig):
66
+ super().__init__(config)
67
+ self.base = config.base_url.rstrip("/") if config.base_url else "http://localhost:11434"
68
+
69
+ def _build(self, messages, system_prompt):
70
+ msgs = []
71
+ if system_prompt:
72
+ msgs.append({"role": "system", "content": system_prompt})
73
+ msgs.extend(messages)
74
+ return msgs
75
+
76
+ async def chat(self, messages, system_prompt=""):
77
+ import aiohttp
78
+ import json
79
+ async with aiohttp.ClientSession() as s:
80
+ async with s.post(f"{self.base}/api/chat", json={
81
+ "model": self.config.model, "messages": self._build(messages, system_prompt),
82
+ "stream": False, "options": {"temperature": self.config.temperature},
83
+ }) as r:
84
+ data = await r.json()
85
+ return data.get("message", {}).get("content", "")
86
+
87
+ async def stream(self, messages, system_prompt=""):
88
+ import aiohttp
89
+ import json
90
+ async with aiohttp.ClientSession() as s:
91
+ async with s.post(f"{self.base}/api/chat", json={
92
+ "model": self.config.model, "messages": self._build(messages, system_prompt),
93
+ "stream": True, "options": {"temperature": self.config.temperature},
94
+ }) as r:
95
+ buf = ""
96
+ async for line in r.content:
97
+ line = line.decode().strip()
98
+ if not line:
99
+ continue
100
+ try:
101
+ chunk = json.loads(line)
102
+ content = chunk.get("message", {}).get("content", "")
103
+ if content:
104
+ yield content
105
+ except Exception:
106
+ continue
107
+
108
+
109
+ class GroqProvider(AIProvider):
110
+ def __init__(self, config: AIConfig):
111
+ super().__init__(config)
112
+ from groq import AsyncGroq
113
+ self.client = AsyncGroq(api_key=config.api_key)
114
+
115
+ def _build(self, messages, system_prompt):
116
+ msgs = []
117
+ if system_prompt:
118
+ msgs.append({"role": "system", "content": system_prompt})
119
+ msgs.extend(messages)
120
+ return msgs
121
+
122
+ async def chat(self, messages, system_prompt=""):
123
+ r = await self.client.chat.completions.create(
124
+ model=self.config.model,
125
+ messages=self._build(messages, system_prompt),
126
+ temperature=self.config.temperature,
127
+ max_tokens=self.config.max_tokens,
128
+ )
129
+ return r.choices[0].message.content or ""
130
+
131
+ async def stream(self, messages, system_prompt=""):
132
+ s = await self.client.chat.completions.create(
133
+ model=self.config.model,
134
+ messages=self._build(messages, system_prompt),
135
+ temperature=self.config.temperature,
136
+ max_tokens=self.config.max_tokens,
137
+ stream=True,
138
+ )
139
+ async for chunk in s:
140
+ if chunk.choices[0].delta.content:
141
+ yield chunk.choices[0].delta.content
142
+
143
+
144
+ PROVIDERS = {
145
+ "openai": OpenAIProvider,
146
+ "openrouter": OpenAIProvider,
147
+ "ollama": OllamaProvider,
148
+ "groq": GroqProvider,
149
+ "custom": OpenAIProvider,
150
+ }
151
+
152
+
153
+ def create_provider(config: AIConfig) -> AIProvider:
154
+ cls = PROVIDERS.get(config.provider)
155
+ if not cls:
156
+ raise ValueError(f"Unknown provider: {config.provider}. Options: {list(PROVIDERS.keys())}")
157
+ return cls(config)
tarmina/app.py ADDED
@@ -0,0 +1,372 @@
1
+ import asyncio
2
+ import time
3
+ from textual.app import App, ComposeResult
4
+ from textual.widgets import TextArea, Static, ListView, ListItem, Input
5
+ from textual.containers import Container
6
+ from textual.binding import Binding
7
+
8
+ from .ai import create_provider, AIConfig
9
+ from .config import get_ai_config, get_personality, get_system_config, get_memory_config
10
+ from .memory import Memory
11
+ from .tools.system import SystemTools, execute_tool_call
12
+
13
+
14
+ def _safe(text: str) -> str:
15
+ try:
16
+ text.encode("cp1252")
17
+ except UnicodeEncodeError:
18
+ text = text.encode("cp1252", errors="replace").decode("cp1252", errors="replace")
19
+ return text.strip()
20
+
21
+
22
+ class CommandPalette(Container):
23
+ COMMANDS = [
24
+ ("/clear", "Clear chat history"),
25
+ ("/exit", "Exit the app"),
26
+ ("/help", "Show help"),
27
+ ("/newsession", "Start a new session"),
28
+ ("/sessions", "List all sessions"),
29
+ ("/status", "Show system status"),
30
+ ]
31
+
32
+ def __init__(self, on_select):
33
+ super().__init__(id="palette")
34
+ self._on_select = on_select
35
+
36
+ def compose(self) -> ComposeResult:
37
+ yield ListView(id="palette-list")
38
+
39
+ def on_mount(self):
40
+ self._update_list()
41
+ self.query_one("#palette-list", ListView).focus()
42
+
43
+ def _update_list(self, selected_idx: int = 0):
44
+ lv = self.query_one("#palette-list", ListView)
45
+ lv.clear()
46
+ for i, (cmd, desc) in enumerate(self.COMMANDS):
47
+ mark = "[*]" if i == selected_idx else "[ ]"
48
+ lv.append(ListItem(
49
+ Static(f" {mark} [bold #e5e7eb]{cmd}[/] - [#484f58]{desc}[/]", id="cmd-item")
50
+ ))
51
+ lv.index = selected_idx
52
+
53
+ def on_list_view_highlighted(self, event: ListView.Highlighted):
54
+ if event.item and event.item.children:
55
+ self._update_list(event.list_view.index or 0)
56
+
57
+ def on_list_view_selected(self, event: ListView.Selected):
58
+ if event.item is not None:
59
+ idx = event.list_view.index
60
+ if idx is not None and 0 <= idx < len(self.COMMANDS):
61
+ self._on_select(self.COMMANDS[idx][0])
62
+
63
+
64
+ class TarminaApp(App):
65
+ CSS_PATH = "tarmina.css"
66
+
67
+ BINDINGS = [
68
+ Binding("escape", "quit", "Quit"),
69
+ Binding("ctrl+l", "clear_chat", "Clear"),
70
+ Binding("ctrl+n", "submit", "Send", show=False),
71
+ Binding("ctrl+c", "ignore_ctrl_c", "Copy", show=False),
72
+ ]
73
+
74
+ def __init__(self):
75
+ super().__init__()
76
+ self.ai_config = AIConfig(**get_ai_config())
77
+ self.personality = get_personality()
78
+ self.memory = Memory(get_memory_config())
79
+ self.tools = SystemTools(get_system_config())
80
+ self.provider = None
81
+ self._thinking = False
82
+ self._lines = []
83
+ self._tokens = 0
84
+ self._cost = 0.0
85
+
86
+ def _init_provider(self):
87
+ try:
88
+ self.provider = create_provider(self.ai_config)
89
+ except Exception as e:
90
+ self._add(f"AI init failed: {_safe(str(e))}")
91
+
92
+ def compose(self) -> ComposeResult:
93
+ yield TextArea(text="", read_only=True, show_line_numbers=False, id="output")
94
+ self._palette = CommandPalette(on_select=self._on_cmd_select)
95
+ self._palette.display = False
96
+ yield self._palette
97
+ yield TextArea(text="", show_line_numbers=False, id="input")
98
+ with Container(id="sep"):
99
+ yield Static(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}", id="build-info")
100
+ with Container(id="menu"):
101
+ yield Static("", id="usage")
102
+ yield Static("[bold #e5e7eb]Ctrl+N[/] send [bold #e5e7eb]Enter[/] newline [bold #e5e7eb]Esc[/] quit [bold #e5e7eb]Ctrl+L[/] clear", id="shortcuts")
103
+
104
+ def on_mount(self):
105
+ self._init_provider()
106
+ if not self.provider:
107
+ self._add("AI not configured. Run: tarmina --setup")
108
+ self.query_one("#input", TextArea).focus()
109
+ return
110
+
111
+ try:
112
+ from pyfiglet import Figlet
113
+ logo = Figlet(font="banner3", width=80).renderText("TARMINA").rstrip()
114
+ except ImportError:
115
+ logo = "Tarmina"
116
+ for line in logo.split("\n"):
117
+ self._lines.append(f" {line}")
118
+ self._lines.append("")
119
+ self._lines.append(f" {self.ai_config.provider} / {self.ai_config.model}")
120
+ self._lines.append(f" session: {self.memory.session_id}")
121
+ self._lines.append(f" {self.memory.count()} messages")
122
+ self._lines.append(" " + "\u2500" * 78)
123
+ self._flush()
124
+ self.query_one("#input", TextArea).focus()
125
+
126
+ def on_text_area_changed(self, event: TextArea.Changed):
127
+ if event.text_area.id != "input":
128
+ return
129
+ text = event.text_area.text
130
+ if text == "/":
131
+ self._palette.display = True
132
+ self._palette.query_one("#palette-list", ListView).focus()
133
+ self._palette._update_list()
134
+ elif self._palette.display and text != "/":
135
+ self._palette.display = False
136
+ self.query_one("#input", TextArea).focus()
137
+
138
+ def _on_cmd_select(self, cmd: str):
139
+ self._palette.display = False
140
+ self._handle_command(cmd)
141
+ self.query_one("#input", TextArea).text = ""
142
+ self.query_one("#input", TextArea).focus()
143
+
144
+ def action_submit(self):
145
+ if self._thinking:
146
+ return
147
+ inp = self.query_one("#input", TextArea)
148
+ text = inp.text.strip()
149
+ inp.text = ""
150
+ if not text:
151
+ return
152
+ if text.startswith("/"):
153
+ self._handle_command(text)
154
+ return
155
+ self._lines.append("")
156
+ self._lines.append(f" \u25b8 {_safe(text)}")
157
+ self._lines.append("")
158
+ self._lines.append(" Thinking...")
159
+ self._flush()
160
+ self._thinking = True
161
+ self._think_start = time.time()
162
+ self.run_worker(self._chat(text))
163
+ self.run_worker(self._thinking_timer())
164
+
165
+ def _add(self, text: str):
166
+ self._lines.append(f" {text}")
167
+ self._flush()
168
+
169
+ def _flush(self):
170
+ out = self.query_one("#output", TextArea)
171
+ out.text = "\n".join(self._lines[-500:])
172
+ out.scroll_end(animate=False)
173
+
174
+ async def _thinking_timer(self):
175
+ import asyncio as _asyncio
176
+ while self._thinking:
177
+ elapsed = time.time() - self._think_start
178
+ if self._lines and "Thinking" in self._lines[-1]:
179
+ self._lines[-1] = f" Thinking... {elapsed:.1f}s"
180
+ self._flush()
181
+ await _asyncio.sleep(0.5)
182
+
183
+ def _parse_tool_call(self, text: str) -> dict | None:
184
+ import json as _json
185
+ idx = text.find('"tool"')
186
+ if idx == -1:
187
+ return None
188
+ start = text.rfind("{", 0, idx)
189
+ if start == -1:
190
+ return None
191
+ depth = 0
192
+ for i in range(start, len(text)):
193
+ if text[i] == "{":
194
+ depth += 1
195
+ elif text[i] == "}":
196
+ depth -= 1
197
+ if depth == 0:
198
+ try:
199
+ obj = _json.loads(text[start:i+1])
200
+ if "tool" in obj and "args" in obj:
201
+ return obj
202
+ except Exception:
203
+ pass
204
+ return None
205
+ return None
206
+
207
+ async def _handle_tool(self, tool_call: dict) -> dict:
208
+ self._add(f"[tool] {tool_call['tool']}({tool_call['args']})")
209
+ self._flush()
210
+ result = await execute_tool_call(tool_call, self.tools)
211
+ if "error" in result:
212
+ self._add(f" error: {result['error'][:200]}")
213
+ elif "message" in result:
214
+ self._add(f" {result['message']}")
215
+ self._flush()
216
+ return result
217
+
218
+ async def _chat(self, text: str):
219
+ start = time.time()
220
+ sp = self.personality.get("system_prompt", "")
221
+ if self.tools.allow_commands or self.tools.allow_apps:
222
+ sp += "\n\n" + self.tools.get_tools_prompt()
223
+
224
+ msgs = self.memory.get_messages(limit=40)
225
+ msgs.append({"role": "user", "content": text})
226
+
227
+ try:
228
+ full = ""
229
+ async for chunk in self.provider.stream(msgs, sp):
230
+ full += chunk
231
+
232
+ self.memory.add("user", text)
233
+
234
+ tool_call = self._parse_tool_call(full)
235
+ if tool_call:
236
+ result = await self._handle_tool(tool_call)
237
+ result_text = _safe(str(result.get("stdout", result.get("content", result.get("message", str(result))))))
238
+ msgs.append({"role": "assistant", "content": full})
239
+ msgs.append({"role": "user", "content": f"Tool result: {result_text}"})
240
+ await self._tool_loop(msgs, sp, start)
241
+ else:
242
+ self._display_response(full)
243
+
244
+ self._lines.append(f" Thought: {(time.time() - start):.1f}s")
245
+ self._lines.append("")
246
+ self._flush()
247
+
248
+ total_chars = sum(len(m["content"]) for m in msgs) + len(full)
249
+ self._tokens += total_chars // 4
250
+ self._cost += total_chars * 0.0000001
251
+ self._update_usage()
252
+ except Exception as e:
253
+ self._add(f"Error: {_safe(str(e)[:200])}")
254
+
255
+ self._thinking = False
256
+
257
+ async def _tool_loop(self, msgs: list, sp: str, start: float):
258
+ for _ in range(5):
259
+ full = ""
260
+ async for chunk in self.provider.stream(msgs, sp):
261
+ full += chunk
262
+
263
+ tool_call = self._parse_tool_call(full)
264
+ if not tool_call:
265
+ self._display_response(full)
266
+ return
267
+
268
+ result = await self._handle_tool(tool_call)
269
+ result_text = _safe(str(result.get("stdout", result.get("content", result.get("message", str(result))))))
270
+ msgs.append({"role": "assistant", "content": full})
271
+ msgs.append({"role": "user", "content": f"Tool result: {result_text}"})
272
+
273
+ self._display_response(full)
274
+
275
+ def _display_response(self, response: str):
276
+ if self._lines and "Thinking..." in self._lines[-1]:
277
+ self._lines.pop()
278
+ if self._lines and self._lines[-1].strip() == "":
279
+ self._lines.pop()
280
+ self._lines.append("")
281
+ for line in _safe(response).split("\n"):
282
+ self._lines.append(f" {line}")
283
+ self._lines.append("")
284
+ self._flush()
285
+ self.memory.add("assistant", _safe(response))
286
+
287
+ def _update_usage(self):
288
+ try:
289
+ w = self.query_one("#usage", Static)
290
+ if self._tokens >= 1000:
291
+ ts = f"{self._tokens/1000:.1f}K"
292
+ else:
293
+ ts = str(self._tokens)
294
+ pct = min(self._tokens * 100 / 200000, 99)
295
+ w.update(f"[#484f58]{ts} ([#e5e7eb]{pct:.0f}%[/]) \xb7 [/#484f58]${self._cost:.2f}")
296
+ except Exception:
297
+ pass
298
+
299
+ def _handle_command(self, cmd: str):
300
+ parts = cmd.split(maxsplit=1)
301
+ name = parts[0].lower()
302
+ arg = parts[1] if len(parts) > 1 else ""
303
+
304
+ if name in ("/exit", "/quit"):
305
+ self.exit()
306
+ elif name == "/clear":
307
+ self.memory.clear()
308
+ self._lines.clear()
309
+ self._flush()
310
+ elif name == "/help":
311
+ self._add("/help /clear /status /exit /sessions /session /newsession")
312
+ elif name == "/sessions":
313
+ all_sessions = Memory.list_sessions()
314
+ if not all_sessions:
315
+ self._add("No saved sessions.")
316
+ return
317
+ current = self.memory.session_id
318
+ for s in all_sessions:
319
+ marker = ">" if s["id"] == current else " "
320
+ self._add(f"{marker} {s['id']} ({s['count']} msgs) {s['preview'][:50]}")
321
+ elif name == "/session":
322
+ if not arg:
323
+ self._add(f"Current session: {self.memory.session_id}")
324
+ return
325
+ if self.memory.switch_session(arg):
326
+ self._lines.clear()
327
+ self._add(f"Switched to session: {arg}")
328
+ self._flush()
329
+ try:
330
+ w = self.query_one("#build-info", Static)
331
+ w.update(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}")
332
+ except Exception:
333
+ pass
334
+ else:
335
+ self._add(f"Session not found: {arg}")
336
+ elif name == "/newsession":
337
+ old_id = self.memory.session_id
338
+ self.memory = Memory(get_memory_config())
339
+ self._lines.clear()
340
+ self._add(f"New session: {self.memory.session_id} (was {old_id})")
341
+ self._flush()
342
+ try:
343
+ w = self.query_one("#build-info", Static)
344
+ w.update(f"[bold #d97706]{self.memory.session_id}[/] · [bold #e5e7eb]{self.ai_config.model}[/] {self.ai_config.provider}")
345
+ except Exception:
346
+ pass
347
+ elif name == "/save":
348
+ self._add(f"Session {self.memory.session_id} ({self.memory.count()} msgs)")
349
+ elif name == "/status":
350
+ try:
351
+ import psutil
352
+ self._add(f"CPU {psutil.cpu_percent()}% RAM {psutil.virtual_memory().percent}% Disk {psutil.disk_usage('/').percent}%")
353
+ except ImportError:
354
+ self._add(f"{self.ai_config.provider} / {self.ai_config.model}")
355
+ else:
356
+ self._add(f"Unknown: {name}")
357
+
358
+ def action_ignore_ctrl_c(self):
359
+ pass
360
+
361
+ def action_quit(self):
362
+ if self._palette.display:
363
+ self._palette.display = False
364
+ self.query_one("#input", TextArea).text = ""
365
+ self.query_one("#input", TextArea).focus()
366
+ return
367
+ self.exit()
368
+
369
+ def action_clear_chat(self):
370
+ self._lines.clear()
371
+ self._flush()
372
+ self.memory.clear()