librecoding 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {librecoding-0.4.0 → librecoding-0.6.0}/PKG-INFO +4 -3
- {librecoding-0.4.0 → librecoding-0.6.0}/pyproject.toml +4 -4
- librecoding-0.6.0/src/librecoding/__init__.py +418 -0
- librecoding-0.4.0/src/librecoding/__init__.py +0 -177
- {librecoding-0.4.0 → librecoding-0.6.0}/README.md +0 -0
- {librecoding-0.4.0 → librecoding-0.6.0}/src/librecoding/core.py +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librecoding
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Agent library: prompt -> tools -> results in files2/
|
|
5
5
|
Author: ar
|
|
6
6
|
License-Expression: MIT
|
|
7
|
-
Keywords: librecoding
|
|
7
|
+
Keywords: agent,ai,librecoding
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: openpyxl>=3.1
|
|
11
12
|
Description-Content-Type: text/markdown
|
|
12
13
|
|
|
13
14
|
# librecoding
|
|
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "librecoding"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "Agent library: prompt -> tools -> results in files2/"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [{ name = "ar" }]
|
|
13
|
-
keywords = ["librecoding"]
|
|
13
|
+
keywords = ["librecoding", "ai", "agent"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"Operating System :: OS Independent",
|
|
17
17
|
]
|
|
18
|
-
dependencies = []
|
|
18
|
+
dependencies = ["openpyxl>=3.1"]
|
|
19
19
|
|
|
20
20
|
[tool.hatch.build.targets.wheel]
|
|
21
21
|
packages = ["src/librecoding"]
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""librecoding - агент: промпт -> инструменты -> результат в files2/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
from .core import greet
|
|
15
|
+
|
|
16
|
+
__version__ = "0.6.0"
|
|
17
|
+
|
|
18
|
+
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
19
|
+
DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash"
|
|
20
|
+
KEY_FILE = Path.home() / ".config" / "librecoding" / "api_key"
|
|
21
|
+
IN_DIR = "files"
|
|
22
|
+
OUT_DIR = "files2"
|
|
23
|
+
PROMPT = "Введи промпт: "
|
|
24
|
+
PAUSE_TEXT = "Нажми Enter, чтобы закрыть..."
|
|
25
|
+
|
|
26
|
+
MAX_STEPS = 12
|
|
27
|
+
TOOL_LIMIT = 12000
|
|
28
|
+
|
|
29
|
+
SYSTEM_PROMPT = f"""Ты — автономный агент библиотеки librecoding.
|
|
30
|
+
Рабочая папка — та, где лежит запущенный скрипт.
|
|
31
|
+
|
|
32
|
+
Структура:
|
|
33
|
+
{IN_DIR}/ — входные файлы пользователя, только чтение
|
|
34
|
+
{OUT_DIR}/ — сюда складывай всё, что создаёшь
|
|
35
|
+
|
|
36
|
+
Инструменты:
|
|
37
|
+
list_files() — что есть в {IN_DIR}/
|
|
38
|
+
read_file(name) — текст или предпросмотр xlsx/csv
|
|
39
|
+
write_file(name, content) — записать текстовый файл в {OUT_DIR}/
|
|
40
|
+
run_python(code) — выполнить Python-код, cwd = рабочая папка,
|
|
41
|
+
openpyxl уже установлен
|
|
42
|
+
|
|
43
|
+
Правила:
|
|
44
|
+
1. Сначала list_files(), если точные имена файлов неизвестны.
|
|
45
|
+
2. Для xlsx/xls/csv НИКОГДА не выдумывай содержимое — только run_python.
|
|
46
|
+
3. Результат сохраняй в {OUT_DIR}/ с тем же именем, что исходник, если не сказано иначе.
|
|
47
|
+
4. Файлы в {IN_DIR}/ не перезаписывай и не удаляй.
|
|
48
|
+
5. Не задавай уточняющих вопросов — делай разумное предположение и выполняй.
|
|
49
|
+
6. Финальный ответ — коротко по-русски: что сделал, какие файлы создал. Без кода."""
|
|
50
|
+
|
|
51
|
+
TOOLS = [
|
|
52
|
+
{
|
|
53
|
+
"type": "function",
|
|
54
|
+
"function": {
|
|
55
|
+
"name": "list_files",
|
|
56
|
+
"description": f"Показать файлы во входной папке {IN_DIR}/.",
|
|
57
|
+
"parameters": {"type": "object", "properties": {}, "required": []},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "function",
|
|
62
|
+
"function": {
|
|
63
|
+
"name": "read_file",
|
|
64
|
+
"description": f"Прочитать файл из {IN_DIR}/ (текст или предпросмотр таблицы).",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {"name": {"type": "string", "description": "имя файла в files/"}},
|
|
68
|
+
"required": ["name"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"type": "function",
|
|
74
|
+
"function": {
|
|
75
|
+
"name": "write_file",
|
|
76
|
+
"description": f"Записать текстовый файл в {OUT_DIR}/.",
|
|
77
|
+
"parameters": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"name": {"type": "string"},
|
|
81
|
+
"content": {"type": "string"},
|
|
82
|
+
},
|
|
83
|
+
"required": ["name", "content"],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "function",
|
|
89
|
+
"function": {
|
|
90
|
+
"name": "run_python",
|
|
91
|
+
"description": (
|
|
92
|
+
"Выполнить Python-код в рабочей папке (cwd). Относительные пути: "
|
|
93
|
+
f"'{IN_DIR}/...' и '{OUT_DIR}/...'. openpyxl установлен. "
|
|
94
|
+
f"Результаты сохраняй в {OUT_DIR}/. Возвращает stdout/stderr."
|
|
95
|
+
),
|
|
96
|
+
"parameters": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {"code": {"type": "string"}},
|
|
99
|
+
"required": ["code"],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
_state = {"key": None, "model": DEFAULT_MODEL}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- настройки ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def set_api_key(key: str) -> None:
|
|
111
|
+
"""Задать ключ OpenRouter на время работы процесса."""
|
|
112
|
+
_state["key"] = key.strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_model(model: str) -> None:
|
|
116
|
+
"""Сменить модель, например 'deepseek/deepseek-chat'."""
|
|
117
|
+
_state["model"] = model
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_model() -> str:
|
|
121
|
+
return _state["model"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _api_key() -> str:
|
|
125
|
+
"""Ключ: set_api_key() -> OPENROUTER_API_KEY -> ~/.config/librecoding/api_key."""
|
|
126
|
+
if _state["key"]:
|
|
127
|
+
return _state["key"]
|
|
128
|
+
env = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
129
|
+
if env:
|
|
130
|
+
return env
|
|
131
|
+
if KEY_FILE.is_file():
|
|
132
|
+
for line in KEY_FILE.read_text(encoding="utf-8").splitlines():
|
|
133
|
+
line = line.strip()
|
|
134
|
+
if line and not line.startswith("#"):
|
|
135
|
+
return line
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"API-ключ OpenRouter не найден. Задай его так:\n"
|
|
138
|
+
f" echo 'sk-or-v1-...' > {KEY_FILE}\n"
|
|
139
|
+
f" chmod 600 {KEY_FILE}\n"
|
|
140
|
+
" либо: export OPENROUTER_API_KEY=sk-or-v1-...\n"
|
|
141
|
+
" либо: librecoding.set_api_key('sk-or-v1-...')"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- пути ---------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _caller_dir() -> Path:
|
|
148
|
+
"""Папка запущенного скрипта (test.py), а не библиотеки."""
|
|
149
|
+
path = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
150
|
+
if path:
|
|
151
|
+
return Path(path).resolve().parent
|
|
152
|
+
if sys.argv and sys.argv[0]:
|
|
153
|
+
return Path(sys.argv[0]).resolve().parent
|
|
154
|
+
return Path.cwd()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ensure_dirs() -> tuple[Path, Path]:
|
|
158
|
+
"""Создать files/ и files2/ рядом со скриптом. Вернуть (in_dir, out_dir)."""
|
|
159
|
+
base = _caller_dir()
|
|
160
|
+
in_dir, out_dir = base / IN_DIR, base / OUT_DIR
|
|
161
|
+
in_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
return in_dir, out_dir
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _safe(base: Path, name: str) -> Path:
|
|
167
|
+
"""Не дать выйти за пределы папки через ../ или абсолютный путь."""
|
|
168
|
+
base = base.resolve()
|
|
169
|
+
p = (base / name).resolve()
|
|
170
|
+
if base != p and base not in p.parents:
|
|
171
|
+
raise ValueError(f"путь вне папки: {name}")
|
|
172
|
+
return p
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def next_filename(directory=None) -> Path:
|
|
176
|
+
"""Следующее имя 1.txt, 2.txt, 3.txt ... (максимум + 1)."""
|
|
177
|
+
base = Path(directory) if directory else ensure_dirs()[1]
|
|
178
|
+
nums = [int(p.stem) for p in base.glob("*.txt") if p.stem.isdigit()]
|
|
179
|
+
return base / f"{max(nums, default=0) + 1}.txt"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- сеть ---------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _api(payload: dict, timeout: int = 300) -> dict:
|
|
185
|
+
req = urllib.request.Request(
|
|
186
|
+
API_URL,
|
|
187
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
188
|
+
headers={
|
|
189
|
+
"Authorization": f"Bearer {_api_key()}",
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
"HTTP-Referer": "https://pypi.org/project/librecoding/",
|
|
192
|
+
"X-Title": "librecoding",
|
|
193
|
+
},
|
|
194
|
+
method="POST",
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
198
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
199
|
+
except urllib.error.HTTPError as exc:
|
|
200
|
+
body = exc.read().decode("utf-8", "replace")[:400]
|
|
201
|
+
raise RuntimeError(f"OpenRouter HTTP {exc.code}: {body}") from None
|
|
202
|
+
except urllib.error.URLError as exc:
|
|
203
|
+
raise RuntimeError(f"нет сети / таймаут: {exc.reason}") from None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def ask(prompt, model=None, temperature=0.7, timeout=300) -> str:
|
|
207
|
+
"""Один запрос без инструментов. Возвращает текст ответа."""
|
|
208
|
+
data = _api(
|
|
209
|
+
{
|
|
210
|
+
"model": model or _state["model"],
|
|
211
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
212
|
+
"temperature": temperature,
|
|
213
|
+
},
|
|
214
|
+
timeout=timeout,
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
return data["choices"][0]["message"]["content"] or ""
|
|
218
|
+
except (KeyError, IndexError, TypeError):
|
|
219
|
+
raise RuntimeError(f"неожиданный ответ: {str(data)[:400]}") from None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- инструменты --------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def _tool_list() -> str:
|
|
225
|
+
in_dir, _ = ensure_dirs()
|
|
226
|
+
files = sorted(p for p in in_dir.iterdir() if p.is_file())
|
|
227
|
+
if not files:
|
|
228
|
+
return f"{IN_DIR}/ пуста"
|
|
229
|
+
return f"файлы в {IN_DIR}/:\n" + "\n".join(
|
|
230
|
+
f" {p.name} ({p.stat().st_size} байт)" for p in files
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _tool_read(name: str) -> str:
|
|
235
|
+
in_dir, _ = ensure_dirs()
|
|
236
|
+
p = _safe(in_dir, name)
|
|
237
|
+
if not p.is_file():
|
|
238
|
+
return f"нет файла {name}. Список: {[q.name for q in in_dir.iterdir() if q.is_file()]}"
|
|
239
|
+
|
|
240
|
+
suffix = p.suffix.lower()
|
|
241
|
+
if suffix in {".xlsx", ".xlsm"}:
|
|
242
|
+
try:
|
|
243
|
+
from openpyxl import load_workbook
|
|
244
|
+
except ImportError:
|
|
245
|
+
return "openpyxl недоступен — используй run_python"
|
|
246
|
+
wb = load_workbook(p, read_only=True, data_only=True)
|
|
247
|
+
parts = []
|
|
248
|
+
for ws in wb.worksheets[:3]:
|
|
249
|
+
parts.append(f"--- лист '{ws.title}' ---")
|
|
250
|
+
for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row or 0, 15), values_only=True):
|
|
251
|
+
parts.append(" | ".join("" if c is None else str(c) for c in row))
|
|
252
|
+
wb.close()
|
|
253
|
+
return "\n".join(parts)[:TOOL_LIMIT]
|
|
254
|
+
|
|
255
|
+
if suffix in {".csv", ".tsv", ".txt", ".md", ".json", ".py", ".log", ".xml", ".yml", ".yaml"}:
|
|
256
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
257
|
+
if len(text) > TOOL_LIMIT:
|
|
258
|
+
return text[:TOOL_LIMIT] + "\n...[обрезано]"
|
|
259
|
+
return text
|
|
260
|
+
|
|
261
|
+
return f"бинарный файл ({suffix or 'без расширения'}) — работай с ним через run_python"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _tool_write(name: str, content: str) -> str:
|
|
265
|
+
_, out_dir = ensure_dirs()
|
|
266
|
+
p = _safe(out_dir, name)
|
|
267
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
p.write_text(content, encoding="utf-8")
|
|
269
|
+
return f"записано: {OUT_DIR}/{p.relative_to(out_dir)}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _tool_python(code: str, timeout: int = 180) -> str:
|
|
273
|
+
try:
|
|
274
|
+
proc = subprocess.run(
|
|
275
|
+
[sys.executable, "-c", code],
|
|
276
|
+
cwd=_caller_dir(),
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
timeout=timeout,
|
|
280
|
+
)
|
|
281
|
+
except subprocess.TimeoutExpired:
|
|
282
|
+
return f"таймаут {timeout}s — код выполнялся слишком долго"
|
|
283
|
+
out = (proc.stdout or "")[-TOOL_LIMIT:]
|
|
284
|
+
err = (proc.stderr or "")[-4000:]
|
|
285
|
+
res = f"exit={proc.returncode}\nstdout:\n{out or '(пусто)'}"
|
|
286
|
+
if err.strip():
|
|
287
|
+
res += f"\nstderr:\n{err}"
|
|
288
|
+
return res
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _dispatch(name: str, args: dict) -> str:
|
|
292
|
+
try:
|
|
293
|
+
if name == "list_files":
|
|
294
|
+
return _tool_list()
|
|
295
|
+
if name == "read_file":
|
|
296
|
+
return _tool_read(str(args.get("name", "")))
|
|
297
|
+
if name == "write_file":
|
|
298
|
+
return _tool_write(str(args.get("name", "")), str(args.get("content", "")))
|
|
299
|
+
if name == "run_python":
|
|
300
|
+
return _tool_python(str(args.get("code", "")))
|
|
301
|
+
return f"неизвестный инструмент: {name}"
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
return f"ОШИБКА: {type(exc).__name__}: {exc}"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- агент --------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def run_agent(prompt: str, model=None, max_steps=MAX_STEPS, verbose=True) -> str:
|
|
309
|
+
"""Цикл: модель -> инструменты -> ответ. Возвращает финальный текст."""
|
|
310
|
+
mdl = model or _state["model"]
|
|
311
|
+
messages = [
|
|
312
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
313
|
+
{"role": "user", "content": prompt},
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
for step in range(1, max_steps + 1):
|
|
317
|
+
data = _api({"model": mdl, "messages": messages, "tools": TOOLS})
|
|
318
|
+
try:
|
|
319
|
+
msg = data["choices"][0]["message"]
|
|
320
|
+
except (KeyError, IndexError, TypeError):
|
|
321
|
+
raise RuntimeError(f"неожиданный ответ: {str(data)[:400]}") from None
|
|
322
|
+
|
|
323
|
+
calls = msg.get("tool_calls") or []
|
|
324
|
+
if not calls:
|
|
325
|
+
return (msg.get("content") or "").strip()
|
|
326
|
+
|
|
327
|
+
assistant = {"role": "assistant", "content": msg.get("content") or ""}
|
|
328
|
+
assistant["tool_calls"] = calls
|
|
329
|
+
messages.append(assistant)
|
|
330
|
+
|
|
331
|
+
for tc in calls:
|
|
332
|
+
fn = (tc.get("function") or {}).get("name", "?")
|
|
333
|
+
raw = (tc.get("function") or {}).get("arguments") or "{}"
|
|
334
|
+
try:
|
|
335
|
+
args = json.loads(raw)
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
args = {}
|
|
338
|
+
if verbose:
|
|
339
|
+
hint = args.get("name") or str(args.get("code", ""))[:45].replace("\n", " ")
|
|
340
|
+
print(f" [{step}] {fn}({hint})")
|
|
341
|
+
result = _dispatch(fn, args)
|
|
342
|
+
messages.append(
|
|
343
|
+
{
|
|
344
|
+
"role": "tool",
|
|
345
|
+
"tool_call_id": tc.get("id", ""),
|
|
346
|
+
"content": result[:TOOL_LIMIT],
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return f"агент не уложился в {max_steps} шагов"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --- ввод/вывод ---------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def pause(prompt: str = PAUSE_TEXT) -> None:
|
|
356
|
+
"""Ждёт Enter. Больше не вызывается автоматом, оставлена для ручного использования."""
|
|
357
|
+
if not sys.stdin.isatty():
|
|
358
|
+
return
|
|
359
|
+
try:
|
|
360
|
+
input(prompt)
|
|
361
|
+
except (EOFError, KeyboardInterrupt):
|
|
362
|
+
print()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def interactive(prompt=None, model=None, filename=None, save_answer=True, verbose=True):
|
|
366
|
+
"""Создать files/ и files2/, принять промпт, выполнить агента, сохранить ответ в files2/N.txt."""
|
|
367
|
+
in_dir, out_dir = ensure_dirs()
|
|
368
|
+
|
|
369
|
+
if prompt is None:
|
|
370
|
+
if not sys.stdin.isatty():
|
|
371
|
+
raise RuntimeError("нужен интерактивный терминал или аргумент prompt")
|
|
372
|
+
try:
|
|
373
|
+
prompt = input(PROMPT).strip()
|
|
374
|
+
except (EOFError, KeyboardInterrupt):
|
|
375
|
+
print()
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
prompt = str(prompt).strip()
|
|
379
|
+
if not prompt:
|
|
380
|
+
print("Пустой промпт — выход.")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
print(f"думаю... ({model or _state['model']})")
|
|
384
|
+
answer = run_agent(prompt, model=model, verbose=verbose)
|
|
385
|
+
|
|
386
|
+
target = None
|
|
387
|
+
if save_answer and answer:
|
|
388
|
+
target = out_dir / (filename or next_filename(out_dir).name)
|
|
389
|
+
target.write_text(answer + "\n", encoding="utf-8")
|
|
390
|
+
|
|
391
|
+
produced = sorted(p.name for p in out_dir.iterdir() if p.is_file())
|
|
392
|
+
print(f"готово. {OUT_DIR}/: {', '.join(produced) if produced else 'пусто'}")
|
|
393
|
+
return target
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --- магия: модуль, который можно вызвать -------------------------------
|
|
397
|
+
|
|
398
|
+
class _LibreCodingModule(ModuleType):
|
|
399
|
+
def __call__(self, obj=None, *args, **kwargs):
|
|
400
|
+
if callable(obj) and not args and not kwargs:
|
|
401
|
+
return obj # @librecoding -> режим декоратора
|
|
402
|
+
try:
|
|
403
|
+
if isinstance(obj, str): # librecoding("готовый промпт")
|
|
404
|
+
kwargs["prompt"] = obj
|
|
405
|
+
return interactive(*args, **kwargs)
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
print(f"Ошибка: {exc}", file=sys.stderr)
|
|
408
|
+
if os.environ.get("LIBRECODING_DEBUG"):
|
|
409
|
+
raise
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
sys.modules[__name__].__class__ = _LibreCodingModule
|
|
414
|
+
|
|
415
|
+
__all__ = [
|
|
416
|
+
"greet", "ask", "run_agent", "interactive", "pause", "ensure_dirs", "next_filename",
|
|
417
|
+
"set_api_key", "set_model", "get_model",
|
|
418
|
+
]
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
"""librecoding - вызываемый модуль: спрашивает промпт, шлёт в OpenRouter, пишет .txt."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
import urllib.error
|
|
9
|
-
import urllib.request
|
|
10
|
-
from datetime import datetime
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from types import ModuleType
|
|
13
|
-
|
|
14
|
-
from .core import greet
|
|
15
|
-
|
|
16
|
-
__version__ = "0.4.0"
|
|
17
|
-
|
|
18
|
-
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
19
|
-
DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash"
|
|
20
|
-
KEY_FILE = Path.home() / ".config" / "librecoding" / "api_key"
|
|
21
|
-
PROMPT = "Введи промпт: "
|
|
22
|
-
PAUSE_TEXT = "Нажми Enter, чтобы закрыть..."
|
|
23
|
-
|
|
24
|
-
_state: dict = {"key": None, "model": DEFAULT_MODEL}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def set_api_key(key: str) -> None:
|
|
28
|
-
"""Задать ключ OpenRouter на время работы процесса."""
|
|
29
|
-
_state["key"] = key.strip()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def set_model(model: str) -> None:
|
|
33
|
-
"""Сменить модель, например 'deepseek/deepseek-chat'."""
|
|
34
|
-
_state["model"] = model
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def get_model() -> str:
|
|
38
|
-
return _state["model"]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _api_key() -> str:
|
|
42
|
-
"""Ключ: set_api_key() -> OPENROUTER_API_KEY -> ~/.config/librecoding/api_key."""
|
|
43
|
-
if _state["key"]:
|
|
44
|
-
return _state["key"]
|
|
45
|
-
|
|
46
|
-
env = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
47
|
-
if env:
|
|
48
|
-
return env
|
|
49
|
-
|
|
50
|
-
if KEY_FILE.is_file():
|
|
51
|
-
for line in KEY_FILE.read_text(encoding="utf-8").splitlines():
|
|
52
|
-
line = line.strip()
|
|
53
|
-
if line and not line.startswith("#"):
|
|
54
|
-
return line
|
|
55
|
-
|
|
56
|
-
raise RuntimeError(
|
|
57
|
-
"API-ключ OpenRouter не найден. Задай его так:\n"
|
|
58
|
-
f" echo 'sk-or-v1-...' > {KEY_FILE}\n"
|
|
59
|
-
f" chmod 600 {KEY_FILE}\n"
|
|
60
|
-
" либо: export OPENROUTER_API_KEY=sk-or-v1-...\n"
|
|
61
|
-
" либо: librecoding.set_api_key('sk-or-v1-...')"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def ask(prompt, model=None, temperature=0.7, timeout=180) -> str:
|
|
66
|
-
"""Отправить промпт в OpenRouter, вернуть текст ответа."""
|
|
67
|
-
payload = {
|
|
68
|
-
"model": model or _state["model"],
|
|
69
|
-
"messages": [{"role": "user", "content": prompt}],
|
|
70
|
-
"temperature": temperature,
|
|
71
|
-
}
|
|
72
|
-
req = urllib.request.Request(
|
|
73
|
-
API_URL,
|
|
74
|
-
data=json.dumps(payload).encode("utf-8"),
|
|
75
|
-
headers={
|
|
76
|
-
"Authorization": f"Bearer {_api_key()}",
|
|
77
|
-
"Content-Type": "application/json",
|
|
78
|
-
"HTTP-Referer": "https://pypi.org/project/librecoding/",
|
|
79
|
-
"X-Title": "librecoding",
|
|
80
|
-
},
|
|
81
|
-
method="POST",
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
86
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
87
|
-
except urllib.error.HTTPError as exc:
|
|
88
|
-
body = exc.read().decode("utf-8", "replace")[:400]
|
|
89
|
-
raise RuntimeError(f"OpenRouter HTTP {exc.code}: {body}") from None
|
|
90
|
-
except urllib.error.URLError as exc:
|
|
91
|
-
raise RuntimeError(f"нет сети / таймаут: {exc.reason}") from None
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
return data["choices"][0]["message"]["content"] or ""
|
|
95
|
-
except (KeyError, IndexError, TypeError):
|
|
96
|
-
raise RuntimeError(f"неожиданный ответ: {str(data)[:400]}") from None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def pause(prompt: str = PAUSE_TEXT) -> None:
|
|
100
|
-
"""Ждёт Enter, чтобы терминал не закрылся. Молчит без tty."""
|
|
101
|
-
if not sys.stdin.isatty():
|
|
102
|
-
return
|
|
103
|
-
try:
|
|
104
|
-
input(prompt)
|
|
105
|
-
except (EOFError, KeyboardInterrupt):
|
|
106
|
-
print()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _caller_dir() -> Path:
|
|
110
|
-
"""Папка запущенного скрипта (test.py), а не библиотеки."""
|
|
111
|
-
path = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
112
|
-
if path:
|
|
113
|
-
return Path(path).resolve().parent
|
|
114
|
-
if sys.argv and sys.argv[0]:
|
|
115
|
-
return Path(sys.argv[0]).resolve().parent
|
|
116
|
-
return Path.cwd()
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def interactive(prompt=None, filename=None, model=None):
|
|
120
|
-
"""Спросить промпт -> запросить OpenRouter -> сохранить .txt рядом со скриптом."""
|
|
121
|
-
if not sys.stdin.isatty():
|
|
122
|
-
raise RuntimeError("нужен интерактивный терминал: запусти файл в консоли")
|
|
123
|
-
|
|
124
|
-
if prompt is None:
|
|
125
|
-
try:
|
|
126
|
-
prompt = input(PROMPT).strip()
|
|
127
|
-
except (EOFError, KeyboardInterrupt):
|
|
128
|
-
print()
|
|
129
|
-
return None
|
|
130
|
-
|
|
131
|
-
if not prompt:
|
|
132
|
-
print("Пустой промпт — выход.")
|
|
133
|
-
return None
|
|
134
|
-
|
|
135
|
-
mdl = model or _state["model"]
|
|
136
|
-
print(f"[{mdl}] думаю...")
|
|
137
|
-
answer = ask(prompt, model=mdl)
|
|
138
|
-
|
|
139
|
-
print("\n--- ответ ---")
|
|
140
|
-
print(answer.strip())
|
|
141
|
-
print("--- конец ---")
|
|
142
|
-
|
|
143
|
-
name = filename or f"response_{datetime.now():%Y-%m-%d_%H-%M-%S}.txt"
|
|
144
|
-
path = _caller_dir() / name
|
|
145
|
-
path.write_text(
|
|
146
|
-
f"Модель: {mdl}\n"
|
|
147
|
-
f"Дата: {datetime.now():%Y-%m-%d %H:%M:%S}\n\n"
|
|
148
|
-
f"Промпт:\n{prompt}\n\n"
|
|
149
|
-
f"Ответ:\n{answer.strip()}\n",
|
|
150
|
-
encoding="utf-8",
|
|
151
|
-
)
|
|
152
|
-
print(f"\nСохранено: {path}")
|
|
153
|
-
return path
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
class _LibreCodingModule(ModuleType):
|
|
157
|
-
def __call__(self, obj=None, *args, **kwargs):
|
|
158
|
-
if callable(obj) and not args and not kwargs:
|
|
159
|
-
return obj
|
|
160
|
-
try:
|
|
161
|
-
if isinstance(obj, str):
|
|
162
|
-
kwargs["prompt"] = obj
|
|
163
|
-
interactive(*args, **kwargs)
|
|
164
|
-
except Exception as exc:
|
|
165
|
-
print(f"Ошибка: {exc}", file=sys.stderr)
|
|
166
|
-
if os.environ.get("LIBRECODING_DEBUG"):
|
|
167
|
-
raise
|
|
168
|
-
pause()
|
|
169
|
-
return None
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
sys.modules[__name__].__class__ = _LibreCodingModule
|
|
173
|
-
|
|
174
|
-
__all__ = [
|
|
175
|
-
"greet", "ask", "interactive", "pause",
|
|
176
|
-
"set_api_key", "set_model", "get_model",
|
|
177
|
-
]
|
|
File without changes
|
|
File without changes
|