revengelibrary 0.1.5__py3-none-any.whl → 0.1.7__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.
@@ -0,0 +1,480 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import mimetypes
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import threading
11
+ import webbrowser
12
+ from dataclasses import dataclass, field
13
+ from http import HTTPStatus
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, urlparse
18
+
19
+ from .agents import DEFAULT_AGENT, build_agent_memory_file, get_agent, list_agents
20
+ from .chat import (
21
+ APIError,
22
+ DEFAULT_MODEL,
23
+ DEFAULT_OPENROUTER_API_KEY,
24
+ FreeNeuroChatClient,
25
+ )
26
+
27
+ IDE_DIR = Path(__file__).resolve().parent / "ide"
28
+ HIDDEN_SKIP = {".git", "venv", ".venv", "__pycache__", "build", "dist"}
29
+
30
+
31
+ @dataclass
32
+ class IDEState:
33
+ root_dir: Path
34
+ api_key: str
35
+ default_model: str
36
+ default_memory_file: str
37
+ clients: dict[tuple[str, str, str, str], FreeNeuroChatClient] = field(
38
+ default_factory=dict
39
+ )
40
+ lock: threading.Lock = field(default_factory=threading.Lock)
41
+
42
+ def get_client(
43
+ self,
44
+ agent_name: str,
45
+ model: str,
46
+ memory_file: str | None = None,
47
+ system_prompt: str | None = None,
48
+ ) -> FreeNeuroChatClient:
49
+ agent = get_agent(agent_name)
50
+ chosen_model = model or self.default_model
51
+ prompt = system_prompt or agent.system_prompt
52
+
53
+ base_memory_file = memory_file or self.default_memory_file
54
+ scoped_memory_file = build_agent_memory_file(base_memory_file, agent.name) or ""
55
+
56
+ key = (agent.name, chosen_model, scoped_memory_file, prompt)
57
+ with self.lock:
58
+ client = self.clients.get(key)
59
+ if client is not None:
60
+ return client
61
+
62
+ client = FreeNeuroChatClient(
63
+ api_key=self.api_key,
64
+ model=chosen_model,
65
+ system_prompt=prompt,
66
+ memory_file=scoped_memory_file or None,
67
+ )
68
+ self.clients[key] = client
69
+ return client
70
+
71
+
72
+ def _json_response(
73
+ handler: BaseHTTPRequestHandler,
74
+ payload: dict[str, Any],
75
+ status: int = HTTPStatus.OK,
76
+ ) -> None:
77
+ raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
78
+ handler.send_response(status)
79
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
80
+ handler.send_header("Content-Length", str(len(raw)))
81
+ handler.end_headers()
82
+ handler.wfile.write(raw)
83
+
84
+
85
+ def _text_response(
86
+ handler: BaseHTTPRequestHandler,
87
+ content: bytes,
88
+ content_type: str,
89
+ status: int = HTTPStatus.OK,
90
+ ) -> None:
91
+ handler.send_response(status)
92
+ handler.send_header("Content-Type", content_type)
93
+ handler.send_header("Content-Length", str(len(content)))
94
+ handler.end_headers()
95
+ handler.wfile.write(content)
96
+
97
+
98
+ def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
99
+ raw_len = handler.headers.get("Content-Length", "0")
100
+ try:
101
+ length = int(raw_len)
102
+ except ValueError:
103
+ return {}
104
+ if length <= 0:
105
+ return {}
106
+ raw = handler.rfile.read(length)
107
+ try:
108
+ data = json.loads(raw.decode("utf-8"))
109
+ except (UnicodeDecodeError, json.JSONDecodeError):
110
+ return {}
111
+ return data if isinstance(data, dict) else {}
112
+
113
+
114
+ def _safe_project_path(root: Path, rel_path: str) -> Path:
115
+ rel = Path(rel_path)
116
+ if rel.is_absolute():
117
+ raise ValueError("Absolute paths are not allowed.")
118
+ candidate = (root / rel).resolve()
119
+ resolved_root = root.resolve()
120
+ if candidate != resolved_root and resolved_root not in candidate.parents:
121
+ raise ValueError("Path escapes project root.")
122
+ return candidate
123
+
124
+
125
+ def _collect_files(root: Path) -> list[str]:
126
+ result: list[str] = []
127
+ for dirpath, dirnames, filenames in os.walk(root):
128
+ dirnames[:] = [name for name in dirnames if name not in HIDDEN_SKIP]
129
+ base = Path(dirpath)
130
+ for filename in filenames:
131
+ if filename.endswith((".pyc", ".pyo")):
132
+ continue
133
+ if filename in {".DS_Store"}:
134
+ continue
135
+ file_path = base / filename
136
+ rel = file_path.relative_to(root).as_posix()
137
+ result.append(rel)
138
+ result.sort()
139
+ return result
140
+
141
+
142
+ def _normalize_text(content: str) -> str:
143
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
144
+ cleaned_lines = [line.rstrip() for line in normalized.split("\n")]
145
+ return "\n".join(cleaned_lines).rstrip() + "\n"
146
+
147
+
148
+ def _run_external_formatter(
149
+ command: list[str],
150
+ source: str,
151
+ suffix: str,
152
+ ) -> str | None:
153
+ temp_path: Path | None = None
154
+ try:
155
+ with tempfile.NamedTemporaryFile(
156
+ mode="w",
157
+ suffix=suffix,
158
+ encoding="utf-8",
159
+ delete=False,
160
+ ) as tmp:
161
+ tmp.write(source)
162
+ temp_path = Path(tmp.name)
163
+
164
+ result = subprocess.run(
165
+ [*command, str(temp_path)],
166
+ capture_output=True,
167
+ text=True,
168
+ check=False,
169
+ )
170
+ if result.returncode != 0:
171
+ return None
172
+
173
+ return temp_path.read_text(encoding="utf-8")
174
+ except OSError:
175
+ return None
176
+ finally:
177
+ if temp_path is not None:
178
+ temp_path.unlink(missing_ok=True)
179
+
180
+
181
+ def _format_source(path: Path, content: str) -> str:
182
+ suffix = path.suffix.lower()
183
+
184
+ if suffix == ".json":
185
+ try:
186
+ payload = json.loads(content)
187
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
188
+ except json.JSONDecodeError:
189
+ return _normalize_text(content)
190
+
191
+ if suffix == ".py":
192
+ if shutil.which("ruff"):
193
+ formatted = _run_external_formatter(["ruff", "format"], content, ".py")
194
+ if formatted is not None:
195
+ return formatted
196
+ if shutil.which("black"):
197
+ formatted = _run_external_formatter(["black", "-q"], content, ".py")
198
+ if formatted is not None:
199
+ return formatted
200
+ return _normalize_text(content)
201
+
202
+ if suffix in {".js", ".ts", ".tsx", ".jsx", ".css", ".html"}:
203
+ if shutil.which("prettier"):
204
+ formatted = _run_external_formatter(["prettier", "--write"], content, suffix)
205
+ if formatted is not None:
206
+ return formatted
207
+ return _normalize_text(content)
208
+
209
+ return _normalize_text(content)
210
+
211
+
212
+ def _load_asset(relative_name: str) -> tuple[bytes, str]:
213
+ asset_path = IDE_DIR / relative_name
214
+ if not asset_path.exists():
215
+ raise FileNotFoundError(relative_name)
216
+ content = asset_path.read_bytes()
217
+ content_type, _ = mimetypes.guess_type(asset_path.as_posix())
218
+ return content, content_type or "application/octet-stream"
219
+
220
+
221
+ def _make_handler(state: IDEState) -> type[BaseHTTPRequestHandler]:
222
+ class IDEHandler(BaseHTTPRequestHandler):
223
+ server_version = "revengelibrary-ide/1.0"
224
+
225
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
226
+ return
227
+
228
+ def do_GET(self) -> None: # noqa: N802
229
+ parsed = urlparse(self.path)
230
+ path = parsed.path
231
+ query = parse_qs(parsed.query)
232
+
233
+ if path == "/api/config":
234
+ payload = {
235
+ "root_name": state.root_dir.name,
236
+ "default_model": state.default_model,
237
+ "default_memory_file": state.default_memory_file,
238
+ "default_agent": DEFAULT_AGENT,
239
+ "agents": [
240
+ {"name": item.name, "title": item.title}
241
+ for item in list_agents()
242
+ ],
243
+ }
244
+ _json_response(self, payload)
245
+ return
246
+
247
+ if path == "/api/files":
248
+ files = _collect_files(state.root_dir)
249
+ _json_response(self, {"files": files})
250
+ return
251
+
252
+ if path == "/api/file":
253
+ file_values = query.get("path") or []
254
+ if not file_values:
255
+ _json_response(
256
+ self,
257
+ {"error": "Missing query parameter: path"},
258
+ status=HTTPStatus.BAD_REQUEST,
259
+ )
260
+ return
261
+ try:
262
+ target = _safe_project_path(state.root_dir, file_values[0])
263
+ content = target.read_text(encoding="utf-8")
264
+ except (OSError, ValueError) as exc:
265
+ _json_response(
266
+ self,
267
+ {"error": str(exc)},
268
+ status=HTTPStatus.BAD_REQUEST,
269
+ )
270
+ return
271
+ _json_response(self, {"path": file_values[0], "content": content})
272
+ return
273
+
274
+ if path == "/" or path == "/index.html":
275
+ content, content_type = _load_asset("index.html")
276
+ _text_response(self, content, content_type)
277
+ return
278
+ if path == "/app.css":
279
+ content, content_type = _load_asset("styles.css")
280
+ _text_response(self, content, content_type)
281
+ return
282
+ if path == "/app.js":
283
+ content, content_type = _load_asset("app.js")
284
+ _text_response(self, content, content_type)
285
+ return
286
+
287
+ _json_response(
288
+ self,
289
+ {"error": f"Not found: {path}"},
290
+ status=HTTPStatus.NOT_FOUND,
291
+ )
292
+
293
+ def do_POST(self) -> None: # noqa: N802
294
+ path = urlparse(self.path).path
295
+ body = _read_json(self)
296
+
297
+ if path == "/api/file":
298
+ rel_path = str(body.get("path", "")).strip()
299
+ content = str(body.get("content", ""))
300
+ if not rel_path:
301
+ _json_response(
302
+ self,
303
+ {"error": "Field 'path' is required"},
304
+ status=HTTPStatus.BAD_REQUEST,
305
+ )
306
+ return
307
+
308
+ try:
309
+ target = _safe_project_path(state.root_dir, rel_path)
310
+ target.parent.mkdir(parents=True, exist_ok=True)
311
+ target.write_text(content, encoding="utf-8")
312
+ except (OSError, ValueError) as exc:
313
+ _json_response(
314
+ self,
315
+ {"error": str(exc)},
316
+ status=HTTPStatus.BAD_REQUEST,
317
+ )
318
+ return
319
+
320
+ _json_response(self, {"ok": True, "path": rel_path})
321
+ return
322
+
323
+ if path == "/api/format":
324
+ rel_path = str(body.get("path", "")).strip()
325
+ if not rel_path:
326
+ _json_response(
327
+ self,
328
+ {"error": "Field 'path' is required"},
329
+ status=HTTPStatus.BAD_REQUEST,
330
+ )
331
+ return
332
+
333
+ try:
334
+ target = _safe_project_path(state.root_dir, rel_path)
335
+ input_content = body.get("content")
336
+ source = (
337
+ str(input_content)
338
+ if input_content is not None
339
+ else target.read_text(encoding="utf-8")
340
+ )
341
+ formatted = _format_source(target, source)
342
+ target.write_text(formatted, encoding="utf-8")
343
+ except (OSError, ValueError) as exc:
344
+ _json_response(
345
+ self,
346
+ {"error": str(exc)},
347
+ status=HTTPStatus.BAD_REQUEST,
348
+ )
349
+ return
350
+
351
+ _json_response(self, {"ok": True, "path": rel_path, "content": formatted})
352
+ return
353
+
354
+ if path == "/api/chat":
355
+ message = str(body.get("message", "")).strip()
356
+ agent_name = str(body.get("agent", DEFAULT_AGENT))
357
+ model = str(body.get("model", state.default_model))
358
+ memory_file = body.get("memory_file")
359
+ system_prompt = body.get("system_prompt")
360
+
361
+ if not message:
362
+ _json_response(
363
+ self,
364
+ {"error": "Field 'message' is required"},
365
+ status=HTTPStatus.BAD_REQUEST,
366
+ )
367
+ return
368
+
369
+ try:
370
+ client = state.get_client(
371
+ agent_name=agent_name,
372
+ model=model,
373
+ memory_file=str(memory_file) if memory_file else None,
374
+ system_prompt=str(system_prompt) if system_prompt else None,
375
+ )
376
+ reply = client.send(message)
377
+ except (ValueError, APIError) as exc:
378
+ _json_response(
379
+ self,
380
+ {"error": str(exc)},
381
+ status=HTTPStatus.BAD_REQUEST,
382
+ )
383
+ return
384
+ except Exception as exc: # noqa: BLE001
385
+ _json_response(
386
+ self,
387
+ {"error": f"Unexpected chat error: {exc}"},
388
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
389
+ )
390
+ return
391
+
392
+ _json_response(
393
+ self,
394
+ {
395
+ "reply": reply,
396
+ "agent": agent_name,
397
+ "model": client.model,
398
+ "memory_file": client.memory_file,
399
+ },
400
+ )
401
+ return
402
+
403
+ _json_response(
404
+ self,
405
+ {"error": f"Not found: {path}"},
406
+ status=HTTPStatus.NOT_FOUND,
407
+ )
408
+
409
+ return IDEHandler
410
+
411
+
412
+ def _build_parser() -> argparse.ArgumentParser:
413
+ parser = argparse.ArgumentParser(
414
+ prog="revengelibrary-ide",
415
+ description="Run local IDE-style interface for revengelibrary.",
416
+ )
417
+ parser.add_argument("--host", default="127.0.0.1", help="Bind host.")
418
+ parser.add_argument("--port", default=8765, type=int, help="Bind port.")
419
+ parser.add_argument(
420
+ "--root",
421
+ default=os.getcwd(),
422
+ help="Project root shown in the IDE.",
423
+ )
424
+ parser.add_argument(
425
+ "--api-key",
426
+ default=os.getenv("OPENROUTER_API_KEY", DEFAULT_OPENROUTER_API_KEY),
427
+ help="OpenRouter API key.",
428
+ )
429
+ parser.add_argument(
430
+ "--model",
431
+ default=DEFAULT_MODEL,
432
+ help="Default chat model.",
433
+ )
434
+ parser.add_argument(
435
+ "--memory-file",
436
+ default=".revide_memory",
437
+ help="Base memory file name for IDE chat sessions.",
438
+ )
439
+ parser.add_argument(
440
+ "--no-browser",
441
+ action="store_true",
442
+ help="Do not auto-open browser.",
443
+ )
444
+ return parser
445
+
446
+
447
+ def main() -> int:
448
+ parser = _build_parser()
449
+ args = parser.parse_args()
450
+
451
+ root_dir = Path(args.root).expanduser().resolve()
452
+ if not root_dir.exists() or not root_dir.is_dir():
453
+ parser.error(f"Invalid --root: {root_dir}")
454
+
455
+ state = IDEState(
456
+ root_dir=root_dir,
457
+ api_key=args.api_key or DEFAULT_OPENROUTER_API_KEY,
458
+ default_model=args.model,
459
+ default_memory_file=str((root_dir / args.memory_file).resolve()),
460
+ )
461
+ handler_cls = _make_handler(state)
462
+ server = ThreadingHTTPServer((args.host, args.port), handler_cls)
463
+
464
+ url = f"http://{args.host}:{args.port}"
465
+ print(f"revengelibrary IDE: {url}")
466
+ print(f"root: {root_dir}")
467
+ if not args.no_browser:
468
+ webbrowser.open(url)
469
+
470
+ try:
471
+ server.serve_forever()
472
+ except KeyboardInterrupt:
473
+ pass
474
+ finally:
475
+ server.server_close()
476
+ return 0
477
+
478
+
479
+ if __name__ == "__main__":
480
+ raise SystemExit(main())
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revengelibrary
3
- Version: 0.1.5
4
- Summary: Python chat library and CLI for free LLM models via OpenRouter.
3
+ Version: 0.1.7
4
+ Summary: Не нейросеть
5
5
  Author: revengebibliotek contributors
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/example/revengelibrary
@@ -0,0 +1,15 @@
1
+ revengelibrary/__init__.py,sha256=gCrIdVqm-vWoL5KmjtJQr5-W44RXHkrUt2udhvnBT5I,448
2
+ revengelibrary/agents.py,sha256=nF--yecnFXaaNayi5I-41j0KDy-k64tqogRfw5re0wA,2675
3
+ revengelibrary/chat.py,sha256=1UyJ9HZNea_oglsYMVqg3VdR2ASJvn4IPTBdUjNQ03k,7542
4
+ revengelibrary/cli.py,sha256=ztrcFN9Zs5JIOFZ17j2K624BWpAffyYQ-Fj9ReDcqtU,3363
5
+ revengelibrary/ide_server.py,sha256=ORPN2iSkMsA3dRv5y5NGEigA0aY2kog6yEWUvDj9Mlg,15961
6
+ revengelibrary/memory_store.json,sha256=N1F-Xz3GaBn2H1p7uKzhkhKCQV8QVR0t76XD6wmFtXA,3
7
+ revengelibrary/ide/app.js,sha256=ulYjtCALokdG3EsFHxP2oQe1XCfcpNx24RhTLciTiHo,9614
8
+ revengelibrary/ide/index.html,sha256=4aC007wOnnB0qbh0Yu5JLPDL41pSDJ4LXpjzGDP9vu4,3207
9
+ revengelibrary/ide/styles.css,sha256=7BJPxEJ9ppn9OE81W3OW0ydmb_OOLTN8l8itlytSPAY,6858
10
+ revengelibrary-0.1.7.dist-info/licenses/LICENSE,sha256=UgqQ8UtVfIOP8-clGkGAylmb9AUbT4-Ue49N_WjIpi4,1073
11
+ revengelibrary-0.1.7.dist-info/METADATA,sha256=qQ-5nOdCnC1ijicsulU54rODc6kH9F76JCXROCRuaoc,822
12
+ revengelibrary-0.1.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
13
+ revengelibrary-0.1.7.dist-info/entry_points.txt,sha256=z5tzRCdH6_7gF5IQjgwFu2pQImVJayK7V5DDFmmk-d8,111
14
+ revengelibrary-0.1.7.dist-info/top_level.txt,sha256=gfUsAxA-IgliQakMoupLAAjrTEYtUJurH5n29eHhUBE,15
15
+ revengelibrary-0.1.7.dist-info/RECORD,,
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  revengelibrary = revengelibrary.cli:main
3
+ revengelibrary-ide = revengelibrary.ide_server:main
@@ -1,10 +0,0 @@
1
- revengelibrary/__init__.py,sha256=HWDxVkOjWf65-SM4JIm0p3aNyDoEByCuCjTJCiFgBD4,299
2
- revengelibrary/chat.py,sha256=a1crCvwW9L3w3YYstE_OFtCM4khrPoGtr8eZFL6-EnQ,7374
3
- revengelibrary/cli.py,sha256=zllHMGN8EHJCSzMI5kpT8wk2-h9sD_38ysJ9zY6NxO0,2543
4
- revengelibrary/memory_store.json,sha256=N1F-Xz3GaBn2H1p7uKzhkhKCQV8QVR0t76XD6wmFtXA,3
5
- revengelibrary-0.1.5.dist-info/licenses/LICENSE,sha256=UgqQ8UtVfIOP8-clGkGAylmb9AUbT4-Ue49N_WjIpi4,1073
6
- revengelibrary-0.1.5.dist-info/METADATA,sha256=bqiA6Mi7yblgWF42hGOXMO2gDgcExiPNUIEL-U4O6gQ,862
7
- revengelibrary-0.1.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
- revengelibrary-0.1.5.dist-info/entry_points.txt,sha256=299WMDffRchuwIlqEIjW-DwHYkBvRZQwGjiASIVofYE,59
9
- revengelibrary-0.1.5.dist-info/top_level.txt,sha256=gfUsAxA-IgliQakMoupLAAjrTEYtUJurH5n29eHhUBE,15
10
- revengelibrary-0.1.5.dist-info/RECORD,,