redsba-code 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.
redsba/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
redsba/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ from redsba.app import run
2
+
3
+
4
+ def main():
5
+ try:
6
+ run()
7
+ except (KeyboardInterrupt, EOFError):
8
+ pass
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
redsba/app.py ADDED
@@ -0,0 +1,262 @@
1
+ from prompt_toolkit import prompt
2
+
3
+ from redsba.config import (ROLES, SLOT_COUNT, empty_slot, find_brain, load_config,
4
+ mask, save_config, slot_ready)
5
+ import json
6
+
7
+ from redsba import chats, skills, tools
8
+ from redsba.providers import PROVIDERS, TEMPLATES, ask, chat_tools, list_gemini_models
9
+ from redsba.ui import clear, menu, spinner
10
+
11
+ BANNER = """██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
12
+ ██╔══██╗██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
13
+ ██████╔╝███████╗ ██║ ██║ ██║██║ ██║█████╗
14
+ ██╔══██╗╚════██║ ██║ ██║ ██║██║ ██║██╔══╝
15
+ ██║ ██║███████║ ╚██████╗╚██████╔╝██████╔╝███████╗
16
+ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝"""
17
+
18
+ MAX_STEPS = 20
19
+ SYSTEM_PROMPT = (
20
+ "Ты агент в терминале redSBA code. Рабочая папка: {root}. "
21
+ "Для работы с файлами и командами используй инструменты. "
22
+ "Перед правкой файла сначала прочитай его. Если нужен совет, используй ask_helper "
23
+ "(не больше трёх раз). Отвечай кратко и по делу."
24
+ )
25
+
26
+
27
+ def start_screen():
28
+ clear()
29
+ print("redSBA code\n")
30
+ items = ["chatting", "history", "setting", "exit"]
31
+ choice = menu("Главное меню", items)
32
+ return items[choice] if choice is not None else "exit"
33
+
34
+
35
+ def history_screen():
36
+ """Список сохранённых чатов. Возвращает выбранный чат или None."""
37
+ saved = chats.list_chats()
38
+ if not saved:
39
+ clear()
40
+ print("Сохранённых чатов пока нет.")
41
+ input("Enter, чтобы вернуться...")
42
+ return None
43
+ items = [f"{c['updated']} {c['title']}" for c in saved] + ["назад"]
44
+ pick = menu("История чатов", items)
45
+ if pick is None or pick == len(saved):
46
+ return None
47
+ return saved[pick]
48
+
49
+
50
+ def pick_gemini_model(slot):
51
+ """Загружает список моделей Gemini с Google и даёт выбрать стрелками."""
52
+ if not slot["api_key"]:
53
+ print("Сначала введите ключ Gemini.")
54
+ input("Enter...")
55
+ return
56
+ clear()
57
+ print("Загружаю список моделей...")
58
+ try:
59
+ models = list_gemini_models(slot["api_key"])
60
+ except Exception as e:
61
+ print(f"Не удалось получить список: {e}")
62
+ input("Enter...")
63
+ return
64
+ if not models:
65
+ print("Google не вернул ни одной модели для этого ключа.")
66
+ input("Enter...")
67
+ return
68
+ pick = menu("Модель Gemini", models)
69
+ if pick is not None:
70
+ slot["model"] = models[pick]
71
+
72
+
73
+ def edit_slot(cfg, idx):
74
+ slot = cfg["slots"][idx]
75
+ while True:
76
+ clear()
77
+ print(f"Настройка {slot['name']}\n")
78
+ items = [
79
+ f"провайдер: {slot['provider']}",
80
+ f"роль: {slot['role']}",
81
+ f"ключ: {mask(slot['api_key'])}",
82
+ f"base_url: {slot['base_url'] or '(нужен для openai)'}",
83
+ f"модель: {slot['model'] or '(пусто, введите вручную)'}",
84
+ "шаблон (Groq, OpenRouter...)",
85
+ "очистить слот",
86
+ "назад",
87
+ ]
88
+ choice = menu(slot["name"], items)
89
+ if choice is None or choice == 7:
90
+ save_config(cfg)
91
+ return
92
+ if choice == 0:
93
+ slot["provider"] = PROVIDERS[(PROVIDERS.index(slot["provider"]) + 1) % len(PROVIDERS)]
94
+ elif choice == 1:
95
+ slot["role"] = ROLES[(ROLES.index(slot["role"]) + 1) % len(ROLES)]
96
+ elif choice == 2:
97
+ slot["api_key"] = prompt("API-ключ: ", is_password=True).strip()
98
+ elif choice == 3:
99
+ slot["base_url"] = prompt("base_url: ", default=slot["base_url"]).strip()
100
+ elif choice == 4:
101
+ if slot["provider"] == "gemini":
102
+ pick_gemini_model(slot)
103
+ else:
104
+ value = prompt("модель: ", default=slot["model"]).strip()
105
+ if value:
106
+ slot["model"] = value
107
+ else:
108
+ print("Модель не может быть пустой.")
109
+ input("Enter...")
110
+ elif choice == 5:
111
+ names = list(TEMPLATES)
112
+ pick = menu("Шаблон", names)
113
+ if pick is not None:
114
+ prov, url = TEMPLATES[names[pick]]
115
+ slot["provider"], slot["base_url"], slot["model"] = prov, url, ""
116
+ elif choice == 6:
117
+ cfg["slots"][idx] = empty_slot(idx + 1)
118
+ return
119
+
120
+
121
+ def settings_screen(cfg):
122
+ while True:
123
+ clear()
124
+ items = []
125
+ for s in cfg["slots"]:
126
+ state = "готов" if slot_ready(s) else "не настроен"
127
+ items.append(f"{s['name']}: {s['provider']}, {s['role']}, {state}")
128
+ items.append("назад")
129
+ choice = menu("Настройки", items)
130
+ if choice is None or choice == len(items) - 1:
131
+ save_config(cfg)
132
+ return
133
+ edit_slot(cfg, choice)
134
+
135
+
136
+ def agent_turn(brain, system, history):
137
+ """Крутит модель, пока она вызывает инструменты, и выводит итоговый ответ."""
138
+ for _ in range(MAX_STEPS):
139
+ with spinner("Думаю"):
140
+ reply = chat_tools(brain, system, history, tools.SPECS)
141
+ history.append({"role": "assistant", "content": reply["content"],
142
+ "tool_calls": reply["tool_calls"]})
143
+ if reply["content"]:
144
+ print(reply["content"])
145
+ if not reply["tool_calls"]:
146
+ return
147
+ for call in reply["tool_calls"]:
148
+ print(f"\n[инструмент] {call['name']} {json.dumps(call['args'], ensure_ascii=False)[:200]}")
149
+ result = tools.execute(call["name"], call["args"])
150
+ history.append({"role": "tool", "tool_call_id": call["id"],
151
+ "name": call["name"], "content": result})
152
+ print(f"\nЛимит шагов ({MAX_STEPS}) достигнут. Напишите, как продолжить.")
153
+
154
+
155
+ def skill_menu():
156
+ """Выбор навыка из домашней папки. Выбранный навык добавляется к промпту."""
157
+ found = skills.find_skills()
158
+ if not found:
159
+ print("В домашней папке не найдено папок с SKILL.md.")
160
+ print("Положите навык в папку, например ~/мой_навык/SKILL.md")
161
+ return
162
+ items = [f"{s['name']}: {s['description'][:60]}" for s in found]
163
+ pick = menu("Выберите навык", items)
164
+ if pick is None:
165
+ return
166
+ chosen = found[pick]
167
+ if any(s["path"] == chosen["path"] for s in skills.ACTIVE):
168
+ print("Этот навык уже включён.")
169
+ return
170
+ skills.ACTIVE.append(chosen)
171
+ print(f"Навык включён: {chosen['name']}")
172
+
173
+
174
+ def chat_screen(cfg, chat_id=None, history=None):
175
+ brain = find_brain(cfg)
176
+ clear()
177
+ if not brain:
178
+ print("Нет слота с ролью brain. Откройте setting и настройте один.")
179
+ input("Enter, чтобы вернуться...")
180
+ return
181
+ system = SYSTEM_PROMPT.format(root=tools.ROOT)
182
+ tools.CONFIG["slots"] = cfg["slots"]
183
+ history = list(history) if history else []
184
+ chat_id = chat_id or chats.new_id()
185
+ def header():
186
+ clear()
187
+ print(BANNER)
188
+ print()
189
+ print(brain["model"])
190
+ print()
191
+
192
+ header()
193
+ while True:
194
+ text = prompt("> ").strip()
195
+ if not text:
196
+ continue
197
+ if text == "/exit":
198
+ return
199
+ if text == "/clear":
200
+ # очищает только экран, история диалога остаётся
201
+ header()
202
+ continue
203
+ if text == "/new":
204
+ history.clear()
205
+ chat_id = chats.new_id()
206
+ header()
207
+ print("Начат новый чат.")
208
+ continue
209
+ if text == "/skill":
210
+ skill_menu()
211
+ if skills.ACTIVE:
212
+ print("Активные навыки: " + ", ".join(s["name"] for s in skills.ACTIVE))
213
+ continue
214
+ if text == "/skill clear":
215
+ skills.ACTIVE.clear()
216
+ print("Навыки отключены.")
217
+ continue
218
+ if text.startswith("/ask "):
219
+ parts = text.split(maxsplit=2)
220
+ if len(parts) < 3 or not parts[1].isdigit() or not 0 < int(parts[1]) <= SLOT_COUNT:
221
+ print("Формат: /ask <номер слота> <вопрос>")
222
+ continue
223
+ helper = cfg["slots"][int(parts[1]) - 1]
224
+ if helper["role"] != "helper" or not slot_ready(helper):
225
+ print("Этот слот не готов или не имеет роль helper.")
226
+ continue
227
+ try:
228
+ with spinner("Спрашиваю помощника"):
229
+ answer = ask(helper, parts[2])
230
+ print(answer)
231
+ except Exception as e:
232
+ print(f"Ошибка: {e}")
233
+ continue
234
+ history.append({"role": "user", "content": text})
235
+ tools.HELPER_CALLS[0] = 0
236
+ mark = len(history)
237
+ try:
238
+ agent_turn(brain, skills.build_system(system), history)
239
+ chats.save(chat_id, history)
240
+ except KeyboardInterrupt:
241
+ del history[mark - 1:]
242
+ print("\nПрервано.")
243
+ except Exception as e:
244
+ del history[mark - 1:]
245
+ print(f"Ошибка: {e}")
246
+
247
+
248
+ def run():
249
+ cfg = load_config()
250
+ while True:
251
+ action = start_screen()
252
+ if action == "chatting":
253
+ chat_screen(cfg)
254
+ elif action == "history":
255
+ saved = history_screen()
256
+ if saved:
257
+ chat_screen(cfg, saved["id"], saved["history"])
258
+ elif action == "setting":
259
+ settings_screen(cfg)
260
+ else:
261
+ clear()
262
+ return
redsba/chats.py ADDED
@@ -0,0 +1,40 @@
1
+ """Сохранение чатов в ~/.redsba/chats/ как отдельных JSON-файлов."""
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ CHATS_DIR = Path.home() / ".redsba" / "chats"
8
+
9
+
10
+ def new_id():
11
+ return time.strftime("%Y%m%d-%H%M%S")
12
+
13
+
14
+ def save(chat_id, history):
15
+ """Сохраняет чат. Ключи API сюда не попадают: только сообщения."""
16
+ first = next((m["content"] for m in history if m["role"] == "user"), "")
17
+ title = " ".join(first.split())[:60] or "(пусто)"
18
+ data = {
19
+ "id": chat_id,
20
+ "title": title,
21
+ "updated": time.strftime("%Y-%m-%d %H:%M"),
22
+ "history": history,
23
+ }
24
+ CHATS_DIR.mkdir(parents=True, exist_ok=True)
25
+ path = CHATS_DIR / f"{chat_id}.json"
26
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
27
+ path.chmod(0o600)
28
+
29
+
30
+ def list_chats():
31
+ """Возвращает сохранённые чаты, новые сверху."""
32
+ result = []
33
+ if not CHATS_DIR.exists():
34
+ return result
35
+ for p in sorted(CHATS_DIR.glob("*.json"), reverse=True):
36
+ try:
37
+ result.append(json.loads(p.read_text(encoding="utf-8")))
38
+ except (OSError, json.JSONDecodeError):
39
+ continue
40
+ return result
redsba/config.py ADDED
@@ -0,0 +1,50 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ CONFIG_DIR = Path.home() / ".redsba"
6
+ CONFIG_FILE = CONFIG_DIR / "config.json"
7
+ SLOT_COUNT = 3
8
+ ROLES = ["off", "brain", "helper"]
9
+
10
+
11
+ def empty_slot(n):
12
+ return {"name": f"slot {n}", "provider": "openai", "api_key": "",
13
+ "base_url": "", "model": "", "role": "off"}
14
+
15
+
16
+ def load_config():
17
+ data = {}
18
+ if CONFIG_FILE.exists():
19
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
20
+ slots = data.get("slots", [])
21
+ while len(slots) < SLOT_COUNT:
22
+ slots.append(empty_slot(len(slots) + 1))
23
+ return {"slots": slots[:SLOT_COUNT]}
24
+
25
+
26
+ def save_config(cfg):
27
+ CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
28
+ CONFIG_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
29
+ os.chmod(CONFIG_FILE, 0o600)
30
+
31
+
32
+ def mask(key):
33
+ if not key:
34
+ return "(пусто)"
35
+ return key[:4] + "****" + key[-4:] if len(key) > 8 else "****"
36
+
37
+
38
+ def slot_ready(slot):
39
+ if not slot["api_key"] or not slot["model"] or slot["role"] == "off":
40
+ return False
41
+ if slot["provider"] == "openai":
42
+ return bool(slot["base_url"])
43
+ return True
44
+
45
+
46
+ def find_brain(cfg):
47
+ for s in cfg["slots"]:
48
+ if s["role"] == "brain" and slot_ready(s):
49
+ return s
50
+ return None
redsba/providers.py ADDED
@@ -0,0 +1,169 @@
1
+ import json
2
+
3
+ import requests
4
+
5
+ TEMPLATES = {
6
+ "Groq": ("openai", "https://api.groq.com/openai/v1"),
7
+ "OpenRouter": ("openai", "https://openrouter.ai/api/v1"),
8
+ "Mistral": ("openai", "https://api.mistral.ai/v1"),
9
+ "DeepSeek": ("openai", "https://api.deepseek.com/v1"),
10
+ "Gemini": ("gemini", ""),
11
+ }
12
+ PROVIDERS = ["openai", "gemini"]
13
+
14
+
15
+ HINTS = {
16
+ 400: "запрос отклонён, проверьте модель и параметры",
17
+ 401: "неверный ключ API",
18
+ 403: "доступ запрещён, проверьте ключ и оплату проекта",
19
+ 404: "модель не найдена, проверьте её имя",
20
+ 413: "запрос слишком большой для лимита, сократите текст или выберите другую модель",
21
+ 429: "превышен лимит запросов, подождите минуту",
22
+ 500: "внутренняя ошибка сервера, попробуйте позже",
23
+ 503: "модель сейчас перегружена, попробуйте через минуту",
24
+ }
25
+
26
+
27
+ def _raise(r):
28
+ """Превращает HTTP-ошибку в обычный текст, без JSON."""
29
+ if r.status_code < 400:
30
+ return
31
+ detail = ""
32
+ try:
33
+ err = r.json().get("error", {})
34
+ detail = err.get("message", "") if isinstance(err, dict) else str(err)
35
+ except ValueError:
36
+ detail = r.text
37
+ detail = " ".join(detail.split())[:200]
38
+ text = HINTS.get(r.status_code, f"ошибка сервера, код {r.status_code}")
39
+ raise RuntimeError(f"{text}. {detail}" if detail else text)
40
+
41
+
42
+ # ---------- OpenAI-совместимый формат ----------
43
+
44
+ def _openai_messages(system, history):
45
+ out = [{"role": "system", "content": system}]
46
+ for m in history:
47
+ if m["role"] == "user":
48
+ out.append({"role": "user", "content": m["content"]})
49
+ elif m["role"] == "assistant":
50
+ d = {"role": "assistant", "content": m.get("content") or None}
51
+ if m.get("tool_calls"):
52
+ d["tool_calls"] = [
53
+ {"id": c["id"], "type": "function",
54
+ "function": {"name": c["name"],
55
+ "arguments": json.dumps(c["args"], ensure_ascii=False)}}
56
+ for c in m["tool_calls"]
57
+ ]
58
+ out.append(d)
59
+ elif m["role"] == "tool":
60
+ out.append({"role": "tool", "tool_call_id": m["tool_call_id"],
61
+ "content": m["content"]})
62
+ return out
63
+
64
+
65
+ def _chat_openai(slot, system, history, tools):
66
+ url = slot["base_url"].rstrip("/") + "/chat/completions"
67
+ body = {"model": slot["model"], "messages": _openai_messages(system, history)}
68
+ if tools:
69
+ body["tools"] = [{"type": "function", "function": t} for t in tools]
70
+ r = requests.post(url, headers={"Authorization": f"Bearer {slot['api_key']}"},
71
+ json=body, timeout=180)
72
+ _raise(r)
73
+ msg = r.json()["choices"][0]["message"]
74
+ calls = []
75
+ for i, tc in enumerate(msg.get("tool_calls") or []):
76
+ try:
77
+ args = json.loads(tc["function"].get("arguments") or "{}")
78
+ except json.JSONDecodeError:
79
+ args = {}
80
+ calls.append({"id": tc.get("id") or f"call_{i}",
81
+ "name": tc["function"]["name"], "args": args})
82
+ return {"content": msg.get("content") or "", "tool_calls": calls}
83
+
84
+
85
+ # ---------- Gemini API ----------
86
+
87
+ def _gemini_contents(history):
88
+ contents = []
89
+ for m in history:
90
+ if m["role"] == "user":
91
+ contents.append({"role": "user", "parts": [{"text": m["content"]}]})
92
+ elif m["role"] == "assistant":
93
+ parts = []
94
+ if m.get("content"):
95
+ parts.append({"text": m["content"]})
96
+ for c in m.get("tool_calls", []):
97
+ # raw сохраняет исходную часть ответа, включая служебные поля Gemini 3
98
+ parts.append(c.get("raw") or {"functionCall": {"name": c["name"], "args": c["args"]}})
99
+ if parts:
100
+ contents.append({"role": "model", "parts": parts})
101
+ elif m["role"] == "tool":
102
+ part = {"functionResponse": {"name": m["name"],
103
+ "response": {"result": m["content"]}}}
104
+ last = contents[-1] if contents else None
105
+ if last and last["role"] == "user" and any("functionResponse" in p for p in last["parts"]):
106
+ last["parts"].append(part)
107
+ else:
108
+ contents.append({"role": "user", "parts": [part]})
109
+ return contents
110
+
111
+
112
+ def _chat_gemini(slot, system, history, tools):
113
+ url = ("https://generativelanguage.googleapis.com/v1beta/models/"
114
+ f"{slot['model']}:generateContent")
115
+ body = {"contents": _gemini_contents(history)}
116
+ if system:
117
+ body["systemInstruction"] = {"parts": [{"text": system}]}
118
+ if tools:
119
+ body["tools"] = [{"functionDeclarations": tools}]
120
+ r = requests.post(url, params={"key": slot["api_key"]}, json=body, timeout=180)
121
+ _raise(r)
122
+ parts = r.json()["candidates"][0].get("content", {}).get("parts", [])
123
+ texts, calls = [], []
124
+ for i, p in enumerate(parts):
125
+ if "functionCall" in p:
126
+ fc = p["functionCall"]
127
+ calls.append({"id": f"gem_{i}", "name": fc["name"],
128
+ "args": fc.get("args", {}), "raw": p})
129
+ elif "text" in p and not p.get("thought"):
130
+ texts.append(p["text"])
131
+ return {"content": "".join(texts), "tool_calls": calls}
132
+
133
+
134
+ # ---------- общие функции ----------
135
+
136
+ def list_gemini_models(api_key):
137
+ """Возвращает имена текстовых моделей Gemini, доступных этому ключу."""
138
+ r = requests.get(
139
+ "https://generativelanguage.googleapis.com/v1beta/models",
140
+ params={"key": api_key, "pageSize": 1000},
141
+ timeout=30,
142
+ )
143
+ r.raise_for_status()
144
+ names = []
145
+ for m in r.json().get("models", []):
146
+ methods = m.get("supportedGenerationMethods", [])
147
+ name = m.get("name", "").removeprefix("models/")
148
+ if "generateContent" in methods and name.startswith("gemini"):
149
+ names.append(name)
150
+ return sorted(names, reverse=True)
151
+
152
+
153
+ def chat_tools(slot, system, history, tools=None):
154
+ """Один запрос к модели. Возвращает {"content": str, "tool_calls": [...]}."""
155
+ try:
156
+ if slot["provider"] == "gemini":
157
+ return _chat_gemini(slot, system, history, tools)
158
+ return _chat_openai(slot, system, history, tools)
159
+ except requests.exceptions.Timeout:
160
+ raise RuntimeError("сервер не ответил вовремя, попробуйте ещё раз")
161
+ except requests.exceptions.ConnectionError:
162
+ raise RuntimeError("обрыв соединения с сервером, попробуйте ещё раз")
163
+
164
+
165
+ def ask(slot, question):
166
+ """Простой вопрос без инструментов, для /ask."""
167
+ reply = chat_tools(slot, "Отвечай кратко и по делу.",
168
+ [{"role": "user", "content": question}])
169
+ return reply["content"]
redsba/skills.py ADDED
@@ -0,0 +1,94 @@
1
+ """Навыки в формате Claude: папки с файлом SKILL.md в домашней папке Termux."""
2
+
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ MAX_SKILL_CHARS = 20000
7
+ MAX_DEPTH = 2
8
+ ACTIVE = [] # выбранные навыки: список словарей {name, description, body}
9
+
10
+
11
+ def _parse(text):
12
+ """Разбирает YAML-шапку между строками ---. Возвращает (meta, body)."""
13
+ meta = {}
14
+ body = text
15
+ if text.startswith("---"):
16
+ parts = text.split("---", 2)
17
+ if len(parts) == 3:
18
+ for line in parts[1].splitlines():
19
+ if ":" in line:
20
+ key, value = line.split(":", 1)
21
+ meta[key.strip()] = value.strip().strip('"\'')
22
+ body = parts[2].strip()
23
+ return meta, body
24
+
25
+
26
+ def _from_zip(path):
27
+ """Читает SKILL.md из zip-архива: в корне или в одной папке внутри."""
28
+ try:
29
+ with zipfile.ZipFile(path) as z:
30
+ names = [n for n in z.namelist()
31
+ if n.rsplit("/", 1)[-1] == "SKILL.md" and n.count("/") <= 1]
32
+ if not names:
33
+ return None
34
+ names.sort(key=lambda n: n.count("/"))
35
+ with z.open(names[0]) as f:
36
+ # читаем с ограничением, чтобы огромный архив не забил память
37
+ raw = f.read(MAX_SKILL_CHARS * 4)
38
+ except (OSError, zipfile.BadZipFile, KeyError):
39
+ return None
40
+ meta, body = _parse(raw.decode("utf-8", errors="replace"))
41
+ return {
42
+ "name": meta.get("name") or path.stem,
43
+ "description": meta.get("description", ""),
44
+ "body": body[:MAX_SKILL_CHARS],
45
+ "path": str(path),
46
+ }
47
+
48
+
49
+ def find_skills(base=None):
50
+ """Ищет папки с SKILL.md до MAX_DEPTH уровней вглубь от базовой папки."""
51
+ base = Path(base or Path.home())
52
+ found = []
53
+
54
+ def walk(folder, depth):
55
+ if depth > MAX_DEPTH:
56
+ return
57
+ try:
58
+ children = sorted(folder.iterdir())
59
+ except OSError:
60
+ return
61
+ for child in children:
62
+ if child.name.startswith("."):
63
+ continue
64
+ if child.is_file() and child.suffix.lower() == ".zip":
65
+ zipped = _from_zip(child)
66
+ if zipped:
67
+ found.append(zipped)
68
+ continue
69
+ if not child.is_dir():
70
+ continue
71
+ skill_file = child / "SKILL.md"
72
+ if skill_file.is_file():
73
+ try:
74
+ meta, body = _parse(skill_file.read_text(encoding="utf-8", errors="replace"))
75
+ except OSError:
76
+ continue
77
+ found.append({
78
+ "name": meta.get("name") or child.name,
79
+ "description": meta.get("description", ""),
80
+ "body": body[:MAX_SKILL_CHARS],
81
+ "path": str(child),
82
+ })
83
+ walk(child, depth + 1)
84
+
85
+ walk(base, 1)
86
+ return found
87
+
88
+
89
+ def build_system(prompt):
90
+ """Добавляет тексты выбранных навыков к системному промпту."""
91
+ if not ACTIVE:
92
+ return prompt
93
+ blocks = [f"## Навык: {s['name']}\n{s['body']}" for s in ACTIVE]
94
+ return prompt + "\n\nСледуй инструкциям выбранных навыков:\n\n" + "\n\n".join(blocks)
redsba/tools.py ADDED
@@ -0,0 +1,289 @@
1
+ """Инструменты агента: файлы и команды внутри рабочей папки, с подтверждением."""
2
+
3
+ import difflib
4
+ import subprocess
5
+ import time
6
+ from html.parser import HTMLParser
7
+ from pathlib import Path
8
+
9
+ import requests
10
+
11
+ from redsba.config import slot_ready
12
+ from redsba.providers import ask
13
+
14
+ ROOT = Path.cwd().resolve()
15
+ # конфиг слотов, передаётся из app.py при запуске чата
16
+ CONFIG = {"slots": []}
17
+ # сколько раз мозг обратился к помощнику за один запрос пользователя
18
+ HELPER_CALLS = [0]
19
+ MAX_HELPER_CALLS = 3
20
+ BACKUP_DIR = ROOT / ".redsba_backup"
21
+ LIMIT = 8000
22
+ # "всегда в этой сессии" для каждого типа действия
23
+ APPROVED = {"write": False, "edit": False, "run": False, "fetch": False}
24
+
25
+
26
+ class _TextExtractor(HTMLParser):
27
+ """Достаёт видимый текст из HTML, пропуская скрипты и стили."""
28
+
29
+ SKIP = {"script", "style", "noscript", "svg", "head"}
30
+
31
+ def __init__(self):
32
+ super().__init__()
33
+ self.parts = []
34
+ self._skip = 0
35
+
36
+ def handle_starttag(self, tag, attrs):
37
+ if tag in self.SKIP:
38
+ self._skip += 1
39
+
40
+ def handle_endtag(self, tag):
41
+ if tag in self.SKIP and self._skip:
42
+ self._skip -= 1
43
+
44
+ def handle_data(self, data):
45
+ if not self._skip and data.strip():
46
+ self.parts.append(data.strip())
47
+
48
+ SPECS = [
49
+ {
50
+ "name": "read_file",
51
+ "description": "Прочитать текстовый файл рабочей папки. Возвращает строки с номерами.",
52
+ "parameters": {
53
+ "type": "object",
54
+ "properties": {"path": {"type": "string", "description": "путь относительно рабочей папки"}},
55
+ "required": ["path"],
56
+ },
57
+ },
58
+ {
59
+ "name": "list_dir",
60
+ "description": "Показать содержимое папки рабочей папки.",
61
+ "parameters": {
62
+ "type": "object",
63
+ "properties": {"path": {"type": "string", "description": "путь, по умолчанию '.'"}},
64
+ "required": [],
65
+ },
66
+ },
67
+ {
68
+ "name": "write_file",
69
+ "description": "Создать или полностью перезаписать файл. Пользователь подтверждает действие.",
70
+ "parameters": {
71
+ "type": "object",
72
+ "properties": {
73
+ "path": {"type": "string"},
74
+ "content": {"type": "string", "description": "полное содержимое файла"},
75
+ },
76
+ "required": ["path", "content"],
77
+ },
78
+ },
79
+ {
80
+ "name": "edit_file",
81
+ "description": ("Заменить точный фрагмент текста в файле. Фрагмент old должен встречаться "
82
+ "ровно один раз. Пользователь подтверждает действие."),
83
+ "parameters": {
84
+ "type": "object",
85
+ "properties": {
86
+ "path": {"type": "string"},
87
+ "old": {"type": "string", "description": "точный текст для замены"},
88
+ "new": {"type": "string", "description": "новый текст"},
89
+ },
90
+ "required": ["path", "old", "new"],
91
+ },
92
+ },
93
+ {
94
+ "name": "fetch_url",
95
+ "description": ("Открыть веб-страницу по адресу http или https и получить её текст. "
96
+ "Пользователь подтверждает каждый адрес."),
97
+ "parameters": {
98
+ "type": "object",
99
+ "properties": {"url": {"type": "string", "description": "полный адрес, например https://example.com"}},
100
+ "required": ["url"],
101
+ },
102
+ },
103
+ {
104
+ "name": "ask_helper",
105
+ "description": ("Спросить помощника (слот с ролью helper) и получить его ответ. "
106
+ f"Не больше {MAX_HELPER_CALLS} обращений за один запрос пользователя. "
107
+ "Используй, когда нужен второй взгляд или подсказка по сложному вопросу."),
108
+ "parameters": {
109
+ "type": "object",
110
+ "properties": {
111
+ "question": {"type": "string", "description": "вопрос помощнику, с нужным контекстом"},
112
+ "slot": {"type": "integer", "description": "номер слота помощника, по умолчанию первый свободный helper"},
113
+ },
114
+ "required": ["question"],
115
+ },
116
+ },
117
+ {
118
+ "name": "run_command",
119
+ "description": ("Выполнить shell-команду в рабочей папке, таймаут 60 секунд. "
120
+ "Пользователь подтверждает действие."),
121
+ "parameters": {
122
+ "type": "object",
123
+ "properties": {"command": {"type": "string"}},
124
+ "required": ["command"],
125
+ },
126
+ },
127
+ ]
128
+
129
+
130
+ def _trim(text):
131
+ if len(text) <= LIMIT:
132
+ return text
133
+ return text[:LIMIT] + f"\n... (обрезано, всего {len(text)} символов)"
134
+
135
+
136
+ def _path(p):
137
+ target = (ROOT / p).resolve()
138
+ if target != ROOT and not target.is_relative_to(ROOT):
139
+ raise ValueError(f"путь вне рабочей папки: {p}")
140
+ return target
141
+
142
+
143
+ def _confirm(kind, title, details=""):
144
+ if APPROVED[kind]:
145
+ return True
146
+ print(f"\n{title}")
147
+ if details:
148
+ print(details)
149
+ ans = input("Разрешить? [y] да / [n] нет / [a] всегда в этой сессии: ").strip().lower()
150
+ if ans == "a":
151
+ APPROVED[kind] = True
152
+ return True
153
+ return ans in ("y", "yes", "д", "да")
154
+
155
+
156
+ def _backup(path):
157
+ if not path.exists():
158
+ return
159
+ dest = BACKUP_DIR / path.relative_to(ROOT)
160
+ stamp = time.strftime("%Y%m%d-%H%M%S")
161
+ dest = dest.with_name(f"{dest.name}.{stamp}.bak")
162
+ dest.parent.mkdir(parents=True, exist_ok=True)
163
+ dest.write_bytes(path.read_bytes())
164
+
165
+
166
+ def _diff(old, new, name):
167
+ lines = difflib.unified_diff(old.splitlines(), new.splitlines(),
168
+ fromfile=name, tofile=name, lineterm="")
169
+ return "\n".join(list(lines)[:80])
170
+
171
+
172
+ def _read(p):
173
+ path = _path(p)
174
+ if not path.is_file():
175
+ return f"Не файл: {p}"
176
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
177
+ return _trim("\n".join(f"{i}\t{line}" for i, line in enumerate(lines, 1)))
178
+
179
+
180
+ def _list(p):
181
+ path = _path(p)
182
+ if not path.is_dir():
183
+ return f"Не папка: {p}"
184
+ items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
185
+ return "\n".join(f"{x.name}/" if x.is_dir() else x.name for x in items) or "(пусто)"
186
+
187
+
188
+ def _write(p, content):
189
+ path = _path(p)
190
+ if path.is_dir():
191
+ return f"Это папка: {p}"
192
+ if path.exists():
193
+ old = path.read_text(encoding="utf-8", errors="replace")
194
+ details = _diff(old, content, p)
195
+ else:
196
+ details = "\n".join(content.splitlines()[:30])
197
+ if not _confirm("write", f"Записать файл: {p}", details):
198
+ return "Пользователь отклонил запись."
199
+ _backup(path)
200
+ path.parent.mkdir(parents=True, exist_ok=True)
201
+ path.write_text(content, encoding="utf-8")
202
+ return f"Записано: {p} ({len(content)} симв.)"
203
+
204
+
205
+ def _edit(p, old, new):
206
+ path = _path(p)
207
+ if not path.is_file():
208
+ return f"Не файл: {p}"
209
+ if not old:
210
+ return "Фрагмент old пустой."
211
+ text = path.read_text(encoding="utf-8", errors="replace")
212
+ count = text.count(old)
213
+ if count == 0:
214
+ return "Фрагмент не найден. Сначала прочитайте файл и возьмите точный текст."
215
+ if count > 1:
216
+ return f"Фрагмент встречается {count} раз. Сделайте old уникальнее."
217
+ updated = text.replace(old, new, 1)
218
+ if not _confirm("edit", f"Правка файла: {p}", _diff(text, updated, p)):
219
+ return "Пользователь отклонил правку."
220
+ _backup(path)
221
+ path.write_text(updated, encoding="utf-8")
222
+ return f"Правка применена: {p}"
223
+
224
+
225
+ def _fetch(url):
226
+ if not url.startswith(("http://", "https://")):
227
+ return "Разрешены только адреса http:// и https://"
228
+ if not _confirm("fetch", f"Открыть адрес: {url}"):
229
+ return "Пользователь отклонил открытие адреса."
230
+ r = requests.get(url, timeout=30, headers={"User-Agent": "redsba-code/0.1"})
231
+ r.raise_for_status()
232
+ ctype = r.headers.get("Content-Type", "")
233
+ if "html" in ctype:
234
+ parser = _TextExtractor()
235
+ parser.feed(r.text)
236
+ text = "\n".join(parser.parts)
237
+ else:
238
+ text = r.text
239
+ return _trim(f"Адрес: {r.url}\n\n{text}")
240
+
241
+
242
+ def _ask_helper(question, slot_no=None):
243
+ if HELPER_CALLS[0] >= MAX_HELPER_CALLS:
244
+ return f"Лимит обращений к помощнику ({MAX_HELPER_CALLS}) исчерпан. Решайте сами."
245
+ slots = CONFIG["slots"]
246
+ if slot_no is not None:
247
+ if not 1 <= slot_no <= len(slots):
248
+ return f"Нет слота с номером {slot_no}."
249
+ helper = slots[slot_no - 1]
250
+ if helper["role"] != "helper" or not slot_ready(helper):
251
+ return f"Слот {slot_no} не готов или не имеет роль helper."
252
+ else:
253
+ helper = next((s for s in slots if s["role"] == "helper" and slot_ready(s)), None)
254
+ if helper is None:
255
+ return "Нет готового слота с ролью helper."
256
+ HELPER_CALLS[0] += 1
257
+ answer = ask(helper, question)
258
+ return f"Ответ помощника ({helper['name']}, обращение {HELPER_CALLS[0]} из {MAX_HELPER_CALLS}):\n{answer}"
259
+
260
+
261
+ def _run(command):
262
+ if not _confirm("run", f"Команда: {command}"):
263
+ return "Пользователь отклонил команду."
264
+ proc = subprocess.run(command, shell=True, cwd=ROOT, capture_output=True,
265
+ text=True, timeout=60)
266
+ return _trim(f"код выхода {proc.returncode}\n{proc.stdout}{proc.stderr}")
267
+
268
+
269
+ def execute(name, args):
270
+ """Выполняет инструмент и возвращает текст результата для модели."""
271
+ try:
272
+ if name == "read_file":
273
+ return _read(args["path"])
274
+ if name == "list_dir":
275
+ return _list(args.get("path", "."))
276
+ if name == "write_file":
277
+ return _write(args["path"], args["content"])
278
+ if name == "edit_file":
279
+ return _edit(args["path"], args["old"], args["new"])
280
+ if name == "run_command":
281
+ return _run(args["command"])
282
+ if name == "fetch_url":
283
+ return _fetch(args["url"])
284
+ if name == "ask_helper":
285
+ slot_no = args.get("slot")
286
+ return _ask_helper(args["question"], int(slot_no) if slot_no is not None else None)
287
+ return f"Неизвестный инструмент: {name}"
288
+ except Exception as e:
289
+ return f"Ошибка: {e}"
redsba/ui.py ADDED
@@ -0,0 +1,89 @@
1
+ import itertools
2
+ import os
3
+ import sys
4
+ import threading
5
+ import time
6
+
7
+ from prompt_toolkit.application import Application
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+ from prompt_toolkit.layout import Layout
10
+ from prompt_toolkit.layout.containers import HSplit, Window
11
+ from prompt_toolkit.layout.controls import FormattedTextControl
12
+ from prompt_toolkit.styles import Style
13
+
14
+ STYLE = Style.from_dict({"title": "bold", "sel": "reverse", "hint": "#888888"})
15
+
16
+
17
+ def clear():
18
+ os.system("clear")
19
+
20
+
21
+ def menu(title, items):
22
+ """Выбор стрелками. Возвращает индекс или None (Esc)."""
23
+ index = [0]
24
+ kb = KeyBindings()
25
+
26
+ @kb.add("up")
27
+ def _(event):
28
+ index[0] = (index[0] - 1) % len(items)
29
+
30
+ @kb.add("down")
31
+ def _(event):
32
+ index[0] = (index[0] + 1) % len(items)
33
+
34
+ @kb.add("enter")
35
+ def _(event):
36
+ event.app.exit(result=index[0])
37
+
38
+ @kb.add("escape")
39
+ def _(event):
40
+ event.app.exit(result=None)
41
+
42
+ def render():
43
+ lines = [("class:title", f"{title}\n\n")]
44
+ for i, item in enumerate(items):
45
+ if i == index[0]:
46
+ lines.append(("class:sel", f" > {item}\n"))
47
+ else:
48
+ lines.append(("", f" {item}\n"))
49
+ lines.append(("class:hint", "\n стрелки - выбор, Enter - ок, Esc - назад"))
50
+ return lines
51
+
52
+ app = Application(
53
+ layout=Layout(HSplit([Window(FormattedTextControl(render), always_hide_cursor=True)])),
54
+ key_bindings=kb,
55
+ style=STYLE,
56
+ full_screen=False,
57
+ )
58
+ return app.run()
59
+
60
+
61
+ class spinner:
62
+ """Крутилка в строке, пока идёт запрос к модели. Используется как with."""
63
+
64
+ FRAMES = "|/-\\"
65
+
66
+ def __init__(self, text="Думаю"):
67
+ self.text = text
68
+ self._stop = threading.Event()
69
+ self._thread = None
70
+
71
+ def _run(self):
72
+ for frame in itertools.cycle(self.FRAMES):
73
+ if self._stop.is_set():
74
+ break
75
+ sys.stdout.write(f"\r{frame} {self.text}...")
76
+ sys.stdout.flush()
77
+ time.sleep(0.12)
78
+
79
+ def __enter__(self):
80
+ self._thread = threading.Thread(target=self._run, daemon=True)
81
+ self._thread.start()
82
+ return self
83
+
84
+ def __exit__(self, *exc):
85
+ self._stop.set()
86
+ self._thread.join()
87
+ sys.stdout.write("\r" + " " * (len(self.text) + 6) + "\r")
88
+ sys.stdout.flush()
89
+ return False
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: redsba-code
3
+ Version: 0.1.0
4
+ Summary: Терминальный клиент ИИ со слотами провайдеров (Groq, Gemini, OpenAI-совместимые)
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31
8
+ Requires-Dist: prompt_toolkit>=3.0
9
+
10
+ # redSBA code
11
+
12
+ Терминальный клиент ИИ со слотами провайдеров.
13
+
14
+ ## Установка
15
+
16
+ pip install .
17
+
18
+ ## Запуск
19
+
20
+ redsba
21
+
22
+ Настройте слот в разделе `setting`: выберите шаблон провайдера, вставьте ключ и введите модель вручную.
@@ -0,0 +1,14 @@
1
+ redsba/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ redsba/__main__.py,sha256=8brjSH7qhBjmPMm_Dhk_JneYd1KrI00L_hDg44Pxu1U,159
3
+ redsba/app.py,sha256=vnpeomFb6-Yzj2vkyk13ZMM8UeGCsmtz9z8ZS-IVNdQ,10794
4
+ redsba/chats.py,sha256=SJIg74vD14-AHTuw7OXdtM39r8TL92ziiGxugPeCGzo,1293
5
+ redsba/config.py,sha256=a-ZIyoLIZcL-MZ_BVDZ1jFFT7dl-lbnguhQFyrw1iN4,1288
6
+ redsba/providers.py,sha256=69yUiqysUQ8k6RAbIboLaWLNxS7LIRXvWMUyA8_iDHI,7294
7
+ redsba/skills.py,sha256=owrCzbmDTrqWFqaH4vIjGC2crUrOmL4DmJkQ3-yz6pg,3500
8
+ redsba/tools.py,sha256=-B0qf8zLGE91-OqdoTapIVLqRxSzFYmsDL4sMhNNlhY,11657
9
+ redsba/ui.py,sha256=4z6jCtWeP5Lxta9mPF9UBpzK4J_WvBiTQqR9L6hEVMM,2493
10
+ redsba_code-0.1.0.dist-info/METADATA,sha256=_IcMa5eGyrQ5v2EbkfxVK-uSkzJARaqG5kzD_WkcgAE,681
11
+ redsba_code-0.1.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
12
+ redsba_code-0.1.0.dist-info/entry_points.txt,sha256=TpUyO0wHk1opREZVXsg0S7QB4TDdCzQgEWu8uq5lwug,48
13
+ redsba_code-0.1.0.dist-info/top_level.txt,sha256=9LMtQMgZRtQnzZuPXmCk0_y05k1hxSTKYHjAz7nurA4,7
14
+ redsba_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ redsba = redsba.__main__:main
@@ -0,0 +1 @@
1
+ redsba