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