agent-gyrk 0.2.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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-gyrk
3
+ Version: 0.2.0
4
+ Summary: agent-gyrk — CLI agentic rodando o modelo GYRK
5
+ Author: Havek
6
+ Project-URL: Homepage, https://gyrk.havek.ai
7
+ Keywords: gyrk,agent,cli,llm,coding-agent
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Environment :: Console
10
+ Classifier: Topic :: Software Development
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # agent-gyrk
15
+
16
+ Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
17
+ O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
18
+ um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
19
+ fechado das tools ativas. Só stdlib.
20
+
21
+ ## Instalar (comando global, igual claude-code)
22
+
23
+ ```bash
24
+ pipx install agent-gyrk # cria o comando `gyrk` no PATH
25
+ gyrk # free, sem chave (limitado)
26
+ gyrk init # cola sua chave pra mais (tier Pro)
27
+ ```
28
+
29
+ ## Usar
30
+
31
+ ```bash
32
+ gyrk # REPL interativo
33
+ gyrk "liste os .py" # tarefa única
34
+ gyrk --allow-local "..." # libera run_bash (off por padrão)
35
+ gyrk --help
36
+ ```
37
+
38
+ ## Config (env, sem segredo embutido)
39
+
40
+ | var | default |
41
+ |---|---|
42
+ | `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
43
+ | `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
44
+ | `GYRK_ALLOW_LOCAL` | `0` |
45
+ | `GYRK_MAX_TOKENS` | `2048` |
46
+ | `GYRK_TEMP` | `0.2` |
47
+
48
+ ## Tools (MVP)
49
+
50
+ `list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
51
+ atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
@@ -0,0 +1,38 @@
1
+ # agent-gyrk
2
+
3
+ Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
4
+ O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
5
+ um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
6
+ fechado das tools ativas. Só stdlib.
7
+
8
+ ## Instalar (comando global, igual claude-code)
9
+
10
+ ```bash
11
+ pipx install agent-gyrk # cria o comando `gyrk` no PATH
12
+ gyrk # free, sem chave (limitado)
13
+ gyrk init # cola sua chave pra mais (tier Pro)
14
+ ```
15
+
16
+ ## Usar
17
+
18
+ ```bash
19
+ gyrk # REPL interativo
20
+ gyrk "liste os .py" # tarefa única
21
+ gyrk --allow-local "..." # libera run_bash (off por padrão)
22
+ gyrk --help
23
+ ```
24
+
25
+ ## Config (env, sem segredo embutido)
26
+
27
+ | var | default |
28
+ |---|---|
29
+ | `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
30
+ | `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
31
+ | `GYRK_ALLOW_LOCAL` | `0` |
32
+ | `GYRK_MAX_TOKENS` | `2048` |
33
+ | `GYRK_TEMP` | `0.2` |
34
+
35
+ ## Tools (MVP)
36
+
37
+ `list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
38
+ atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-gyrk
3
+ Version: 0.2.0
4
+ Summary: agent-gyrk — CLI agentic rodando o modelo GYRK
5
+ Author: Havek
6
+ Project-URL: Homepage, https://gyrk.havek.ai
7
+ Keywords: gyrk,agent,cli,llm,coding-agent
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Environment :: Console
10
+ Classifier: Topic :: Software Development
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # agent-gyrk
15
+
16
+ Agente de código no terminal rodando o **GYRK** (modelo próprio, na nuvem).
17
+ O harness é dono do loop; o GYRK é só uma política de próxima-ação. Ação por turno =
18
+ um JSON travado por gramática (`response_format json_schema`), com `tool` num enum
19
+ fechado das tools ativas. Só stdlib.
20
+
21
+ ## Instalar (comando global, igual claude-code)
22
+
23
+ ```bash
24
+ pipx install agent-gyrk # cria o comando `gyrk` no PATH
25
+ gyrk # free, sem chave (limitado)
26
+ gyrk init # cola sua chave pra mais (tier Pro)
27
+ ```
28
+
29
+ ## Usar
30
+
31
+ ```bash
32
+ gyrk # REPL interativo
33
+ gyrk "liste os .py" # tarefa única
34
+ gyrk --allow-local "..." # libera run_bash (off por padrão)
35
+ gyrk --help
36
+ ```
37
+
38
+ ## Config (env, sem segredo embutido)
39
+
40
+ | var | default |
41
+ |---|---|
42
+ | `GYRK_BASE_URL` | endpoint da API GYRK (default api.gyrk.havek.ai) |
43
+ | `GYRK_API_KEY` | (vazio) — Bearer pro gateway autenticado (Fase 3) |
44
+ | `GYRK_ALLOW_LOCAL` | `0` |
45
+ | `GYRK_MAX_TOKENS` | `2048` |
46
+ | `GYRK_TEMP` | `0.2` |
47
+
48
+ ## Tools (MVP)
49
+
50
+ `list_files`, `read_file`, `grep`, `write_file`, `run_python` — escopadas na pasta
51
+ atual. `run_bash` é gated (`--allow-local`). GYRK puro: zero Claude.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ agent_gyrk.py
3
+ pyproject.toml
4
+ agent_gyrk.egg-info/PKG-INFO
5
+ agent_gyrk.egg-info/SOURCES.txt
6
+ agent_gyrk.egg-info/dependency_links.txt
7
+ agent_gyrk.egg-info/entry_points.txt
8
+ agent_gyrk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gyrk = agent_gyrk:main
@@ -0,0 +1 @@
1
+ agent_gyrk
@@ -0,0 +1,809 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ agent-gyrk — Fase 1 (MVP): agente de código no terminal rodando o GYRK.
4
+
5
+ O harness e' dono do loop; o GYRK e' so' um callable HTTP (a tese
6
+ "loop e' serving, modelo e' politica de proxima-acao"). A acao por turno e'
7
+ UM JSON travado por GRAMATICA (response_format json_schema -> GBNF), com `tool`
8
+ restrito a um ENUM fechado das tools ativas -> alucinar tool e' impossivel.
9
+ Provado no phase0_spike.py: 100% JSON valido / 100% roteamento / 100% args.
10
+
11
+ - GYRK PURO: zero Claude. Quando os guards travam, para com mensagem honesta.
12
+ - Tools escopadas na cwd. `run_bash` GATED (off por padrao; --allow-local liga).
13
+ - Sem segredo embutido: endpoint/modelo/key vem de env.
14
+ - So' stdlib (urllib) -> facil empacotar (pipx) depois.
15
+
16
+ Uso:
17
+ python3 agent_gyrk.py # REPL interativo
18
+ python3 agent_gyrk.py "liste os .py" # tarefa unica e sai
19
+ python3 agent_gyrk.py --allow-local # libera run_bash
20
+
21
+ Env:
22
+ GYRK_BASE_URL endpoint da API GYRK (default = https://api.gyrk.havek.ai)
23
+ GYRK_API_KEY se setado, vai como Bearer (pronto pro gateway autenticado)
24
+ GYRK_MODEL so' rotulo informativo na UI
25
+ GYRK_ALLOW_LOCAL=1 mesmo que --allow-local
26
+ GYRK_MAX_TOKENS default 2048
27
+ GYRK_TEMP default 0.2
28
+ """
29
+ import json
30
+ import os
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ import threading
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ # ---------- config (env, sem segredo embutido) ----------
40
+
41
+ WORKSPACE = os.getcwd()
42
+ CONFIG_PATH = os.path.expanduser("~/.config/gyrk/config.json")
43
+
44
+ # API pública do GYRK (gateway autenticado). A infra de serving fica ATRÁS dela,
45
+ # nunca exposta no cliente. Pra dev local, aponte com `gyrk init --base-url=...`.
46
+ DEFAULT_BASE_URL = "https://api.gyrk.havek.ai"
47
+
48
+
49
+ def _load_config():
50
+ try:
51
+ if os.path.isfile(CONFIG_PATH):
52
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
53
+ return json.load(f)
54
+ except Exception:
55
+ pass
56
+ return {}
57
+
58
+
59
+ _CFG = _load_config()
60
+ # precedência: env > config (~/.config/gyrk) > default
61
+ BASE_URL = (os.environ.get("GYRK_BASE_URL") or _CFG.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
62
+ ENDPOINT = BASE_URL + "/v1/chat/completions"
63
+ API_KEY = (os.environ.get("GYRK_API_KEY") or _CFG.get("api_key") or "").strip()
64
+ MODEL_LABEL = os.environ.get("GYRK_MODEL", "GYRK 1.0")
65
+ MAX_TOKENS = int(os.environ.get("GYRK_MAX_TOKENS", "2048"))
66
+ TEMP = float(os.environ.get("GYRK_TEMP", "0.2"))
67
+ ALLOW_LOCAL = (os.environ.get("GYRK_ALLOW_LOCAL", "") not in ("", "0", "false")) or ("--allow-local" in sys.argv)
68
+ CTX = int(os.environ.get("GYRK_CTX", "8192")) # janela de contexto do serving
69
+ VERBOSE = ("--verbose" in sys.argv) or ("-v" in sys.argv)
70
+ _SESSION_IN = 0 # tokens de entrada acumulados na sessão
71
+ _SESSION_OUT = 0 # tokens de saída acumulados na sessão
72
+
73
+
74
+ # ---------- cores terminal ----------
75
+
76
+ _USE_COLOR = sys.stdout.isatty()
77
+
78
+
79
+ def _c(code, s):
80
+ return s if not _USE_COLOR else f"\033[{code}m{s}\033[0m"
81
+
82
+
83
+ def dim(s): return _c("2", s)
84
+ def bold(s): return _c("1", s)
85
+ def cyan(s): return _c("36", s)
86
+ def green(s): return _c("32", s)
87
+ def yellow(s): return _c("33", s)
88
+ def red(s): return _c("31", s)
89
+ def mag(s): return _c("35", s)
90
+
91
+
92
+ class Spinner:
93
+ FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
94
+
95
+ def __init__(self, label="pensando"):
96
+ self.label = label
97
+ self._stop = threading.Event()
98
+ self._t = None
99
+
100
+ def _run(self):
101
+ i, t0 = 0, time.time()
102
+ while not self._stop.is_set():
103
+ frame = self.FRAMES[i % len(self.FRAMES)]
104
+ sys.stdout.write("\r " + cyan(frame) + dim(f" {self.label}… {time.time()-t0:.0f}s"))
105
+ sys.stdout.flush()
106
+ i += 1
107
+ time.sleep(0.08)
108
+ sys.stdout.write("\r" + " " * 48 + "\r")
109
+ sys.stdout.flush()
110
+
111
+ def __enter__(self):
112
+ if _USE_COLOR:
113
+ self._t = threading.Thread(target=self._run, daemon=True)
114
+ self._t.start()
115
+ return self
116
+
117
+ def __exit__(self, *exc):
118
+ self._stop.set()
119
+ if self._t:
120
+ self._t.join()
121
+
122
+
123
+ # ---------- ferramentas (escopadas na cwd) ----------
124
+
125
+ def _resolve(path: str) -> str:
126
+ if not os.path.isabs(path):
127
+ path = os.path.join(WORKSPACE, path)
128
+ full = os.path.abspath(path)
129
+ if not full.startswith(os.path.abspath(WORKSPACE)):
130
+ raise ValueError("acesso negado fora da pasta atual")
131
+ return full
132
+
133
+
134
+ def list_files(path: str = "."):
135
+ full = _resolve(path)
136
+ if not os.path.isdir(full):
137
+ return {"error": f"não é diretório: {path}"}
138
+ base = "" if path in (".", "") else path.rstrip("/") + "/"
139
+ items = []
140
+ for name in sorted(os.listdir(full)):
141
+ if name.startswith("."):
142
+ continue
143
+ p = os.path.join(full, name)
144
+ is_dir = os.path.isdir(p)
145
+ items.append({"path": base + name + ("/" if is_dir else ""),
146
+ "is_dir": is_dir,
147
+ "size": os.path.getsize(p) if os.path.isfile(p) else None})
148
+ return {"dir": path, "items": items}
149
+
150
+
151
+ def read_file(path: str, start_line: int = 1, end_line: int = None, max_lines: int = 300):
152
+ full = _resolve(path)
153
+ if not os.path.isfile(full):
154
+ return {"error": f"não é arquivo: {path}"}
155
+ with open(full, "r", encoding="utf-8", errors="replace") as f:
156
+ all_lines = f.read().splitlines()
157
+ total = len(all_lines)
158
+ start = max(1, int(start_line or 1))
159
+ end = min(int(end_line) if end_line else start + max_lines - 1, start + max_lines - 1, total)
160
+ chunk = all_lines[start - 1:end]
161
+ numbered = "\n".join(f"{start + i}\t{ln}" for i, ln in enumerate(chunk))
162
+ tail = "" if end >= total else f"\n... [linhas {end+1}-{total} não mostradas; peça com start_line/end_line]"
163
+ return {"path": path, "total_lines": total, "shown": f"{start}-{end}", "content": numbered + tail}
164
+
165
+
166
+ _GREP_SKIP = {".git", "node_modules", "venv", ".venv", "__pycache__", ".next", "dist", "build", ".mypy_cache"}
167
+
168
+
169
+ def grep(pattern: str, glob: str = None, max_matches: int = 200):
170
+ import fnmatch
171
+ try:
172
+ rx = re.compile(pattern)
173
+ except re.error as e:
174
+ return {"error": f"regex inválida: {e}"}
175
+ matches = []
176
+ for root, dirs, files in os.walk(WORKSPACE):
177
+ dirs[:] = [d for d in dirs if d not in _GREP_SKIP and not d.startswith(".")]
178
+ for fn in files:
179
+ if glob and not fnmatch.fnmatch(fn, glob):
180
+ continue
181
+ fp = os.path.join(root, fn)
182
+ try:
183
+ with open(fp, "r", encoding="utf-8", errors="ignore") as f:
184
+ for i, line in enumerate(f, 1):
185
+ if rx.search(line):
186
+ matches.append({"file": os.path.relpath(fp, WORKSPACE),
187
+ "line": i, "text": line.rstrip()[:200]})
188
+ if len(matches) >= max_matches:
189
+ return {"matches": matches, "truncated": True}
190
+ except Exception:
191
+ continue
192
+ return {"matches": matches, "count": len(matches)}
193
+
194
+
195
+ def _validate_python(path: str, content: str):
196
+ issues = []
197
+ if not path.endswith(".py"):
198
+ return issues
199
+ module_name = os.path.splitext(os.path.basename(path))[0]
200
+ pat = rf'^\s*(?:from\s+{re.escape(module_name)}\s+import|import\s+{re.escape(module_name)})\b'
201
+ for ln, line in enumerate(content.splitlines(), 1):
202
+ if re.match(pat, line):
203
+ issues.append(f"linha {ln}: `{line.strip()}` é auto-import — {module_name}.py não pode importar de si mesmo. Defina tudo inline.")
204
+ break
205
+ import ast
206
+ try:
207
+ ast.parse(content)
208
+ except SyntaxError as e:
209
+ issues.append(f"SyntaxError linha {e.lineno}: {e.msg}")
210
+ return issues
211
+
212
+
213
+ def write_file(path: str, content: str):
214
+ full = _resolve(path)
215
+ os.makedirs(os.path.dirname(full) or ".", exist_ok=True)
216
+ if len(content) > 1_000_000:
217
+ return {"error": "conteúdo > 1MB"}
218
+ stripped = content.strip().lower()
219
+ instructional = ("olha este", "olha esse", "analisa este", "analisa esse",
220
+ "resuma este", "resuma esse", "analyse this", "look at this")
221
+ if len(content) < 80 and any(stripped.startswith(p) for p in instructional):
222
+ return {"error": "conteúdo parece ser a instrução do usuário, não o dado a ser salvo",
223
+ "hint": "não salve a instrução; peça o dado real com {\"final\": \"...\"}."}
224
+ issues = _validate_python(path, content)
225
+ if issues:
226
+ return {"error": "código Python tem problemas — corrija antes de salvar",
227
+ "issues": issues,
228
+ "hint": "reescreva SEM os problemas. NÃO importe o próprio arquivo. Defina tudo inline."}
229
+ with open(full, "w", encoding="utf-8") as f:
230
+ f.write(content)
231
+ return {"path": path, "bytes_written": len(content.encode("utf-8")), "ok": True}
232
+
233
+
234
+ def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False):
235
+ """Edição cirúrgica: troca o trecho EXATO `old_string` por `new_string`."""
236
+ full = _resolve(path)
237
+ if not os.path.isfile(full):
238
+ return {"error": f"não é arquivo: {path}"}
239
+ with open(full, "r", encoding="utf-8", errors="replace") as f:
240
+ src = f.read()
241
+ count = src.count(old_string)
242
+ if count == 0:
243
+ return {"error": "old_string não encontrado no arquivo",
244
+ "hint": "copie o trecho EXATO via read_file (sem o número de linha)."}
245
+ if count > 1 and not replace_all:
246
+ return {"error": f"old_string aparece {count}x — ambíguo",
247
+ "hint": "inclua mais contexto pra ficar único, ou passe replace_all=true."}
248
+ new_src = src.replace(old_string, new_string) if replace_all else src.replace(old_string, new_string, 1)
249
+ issues = _validate_python(path, new_src)
250
+ if issues:
251
+ return {"error": "resultado Python tem problemas — revise", "issues": issues}
252
+ with open(full, "w", encoding="utf-8") as f:
253
+ f.write(new_src)
254
+ return {"path": path, "replaced": count if replace_all else 1, "ok": True}
255
+
256
+
257
+ def note(text: str):
258
+ """Anota um fato na memória persistente (sobrevive à compactação de contexto)."""
259
+ _SCRATCHPAD.append(text.strip())
260
+ return {"ok": True, "notas": len(_SCRATCHPAD)}
261
+
262
+
263
+ def run_python(code: str, timeout: int = 30):
264
+ try:
265
+ proc = subprocess.run(["python3", "-c", code], capture_output=True,
266
+ text=True, timeout=timeout, cwd=WORKSPACE)
267
+ out, err = proc.stdout, proc.stderr
268
+ if len(out) > 5000: out = out[:5000] + "\n...[stdout truncado]"
269
+ if len(err) > 5000: err = err[:5000] + "\n...[stderr truncado]"
270
+ result = {"exit_code": proc.returncode, "stdout": out, "stderr": err}
271
+ if proc.returncode != 0 and "ModuleNotFoundError" in err:
272
+ result["hint"] = "Pacote não instalado. Use só stdlib (math, re, json, os) ou run_bash com pip."
273
+ return result
274
+ except subprocess.TimeoutExpired:
275
+ return {"error": f"timeout após {timeout}s"}
276
+ except Exception as e:
277
+ return {"error": f"{type(e).__name__}: {e}"}
278
+
279
+
280
+ def run_bash(command: str, timeout: int = 30):
281
+ try:
282
+ proc = subprocess.run(command, shell=True, capture_output=True,
283
+ text=True, timeout=timeout, cwd=WORKSPACE)
284
+ out, err = proc.stdout, proc.stderr
285
+ if len(out) > 5000: out = out[:5000] + "\n...[stdout truncado]"
286
+ if len(err) > 5000: err = err[:5000] + "\n...[stderr truncado]"
287
+ return {"exit_code": proc.returncode, "stdout": out, "stderr": err}
288
+ except subprocess.TimeoutExpired:
289
+ return {"error": f"timeout após {timeout}s"}
290
+ except Exception as e:
291
+ return {"error": f"{type(e).__name__}: {e}"}
292
+
293
+
294
+ # memória persistente do agente (sobrevive à compactação do contexto 8k)
295
+ _SCRATCHPAD = []
296
+
297
+
298
+ # Catalogo: (nome, fn, args_desc, quando). run_bash so' entra se ALLOW_LOCAL.
299
+ # A MESMA lista vira o enum da gramatica E a tabela do prompt -> 1 fonte de verdade.
300
+ def build_tools():
301
+ specs = [
302
+ ("list_files", list_files, "path", "listar arquivos de uma pasta"),
303
+ ("read_file", read_file, "path, start_line, end_line", "ler arquivo (mostra nº de linha)"),
304
+ ("grep", grep, "pattern, glob (opcional), max_matches (200)", "buscar texto no código"),
305
+ ("write_file", write_file, "path, content", "CRIAR/refazer arquivo inteiro"),
306
+ ("edit_file", edit_file, "path, old_string, new_string, replace_all", "editar trecho EXATO de arquivo existente"),
307
+ ("run_python", run_python, "code, timeout (30)", "rodar Python (verificar por execução)"),
308
+ ("note", note, "text", "guardar um fato na memória (sobrevive à compactação)"),
309
+ ]
310
+ if ALLOW_LOCAL:
311
+ specs.append(("run_bash", run_bash, "command, timeout (30)", "rodar comando no shell (cwd)"))
312
+ return specs
313
+
314
+
315
+ TOOL_SPECS = build_tools()
316
+ TOOLS = {name: fn for name, fn, _, _ in TOOL_SPECS}
317
+ TOOL_NAMES = list(TOOLS)
318
+
319
+
320
+ # ---------- gramatica da acao (anyOf: tool | final) ----------
321
+
322
+ ACTION_SCHEMA = {
323
+ "anyOf": [
324
+ {"type": "object",
325
+ "properties": {"thought": {"type": "string"},
326
+ "tool": {"type": "string", "enum": TOOL_NAMES},
327
+ "args": {"type": "object", "additionalProperties": True}},
328
+ "required": ["thought", "tool", "args"], "additionalProperties": False},
329
+ {"type": "object",
330
+ "properties": {"thought": {"type": "string"}, "final": {"type": "string"}},
331
+ "required": ["thought", "final"], "additionalProperties": False},
332
+ ]
333
+ }
334
+
335
+
336
+ # ---------- system prompt (tabela de tools dinamica) ----------
337
+
338
+ def build_system_prompt():
339
+ table = "\n".join(f"| {n} | {a} | {q} |" for n, _, a, q in TOOL_SPECS)
340
+ bash_note = ("" if ALLOW_LOCAL else
341
+ "\n(`run_bash` está DESLIGADO nesta sessão — não há shell; use run_python pra cálculos.)")
342
+ return f"""Você é o gyrk — assistente + agente de código rodando no terminal. Pasta atual: `{WORKSPACE}`.
343
+
344
+ ## FORMATO (RÍGIDO)
345
+ Responda SEMPRE com UM objeto JSON, sem texto fora, sem markdown.
346
+ A. Tool: {{"thought":"<por que>","tool":"<nome>","args":{{...}}}}
347
+ B. Final: {{"thought":"<por que terminei>","final":"<texto natural ao usuário>"}}
348
+
349
+ ## QUANDO USAR CADA UM
350
+ | Entrada | Esquema | Por quê |
351
+ |---|---|---|
352
+ | Saudação ("oi", "obrigado") | B | conversa |
353
+ | Pergunta de fato ("capital do BR?") | B | sei a resposta |
354
+ | Resumir / traduzir / classificar texto colado | B | compreensão, sem código |
355
+ | Listar / ler / buscar arquivos | A | precisa do disco |
356
+ | Criar arquivo / código | A: write_file | é uma criação |
357
+ | Contar exato / regex / cálculo | A: run_python | precisa precisão |
358
+ | Entrada incompleta / ambígua | B perguntando | falta info |
359
+
360
+ REGRA DE OURO: se sua resposta seria prosa, NÃO use tool — vai direto no `final`.
361
+
362
+ ## FERRAMENTAS
363
+ | nome | args | quando |
364
+ |---|---|---|
365
+ {table}{bash_note}
366
+
367
+ - **Editar vs criar:** pra MUDAR um arquivo existente use `edit_file` (leia antes com read_file e copie o trecho EXATO — `old_string` é o texto do arquivo SEM o número de linha). `write_file` só pra criar/refazer do zero (apaga o conteúdo).
368
+ - **Verifique por execução:** antes de um `final` de sucesso, RODE (run_python) e confira exit_code 0. Sem ter executado, nunca diga "funcionou".
369
+ - **Memória:** use `note` pra guardar fatos importantes — eles sobrevivem quando o histórico é compactado.
370
+
371
+ ## ANTI-PADRÕES (críticos)
372
+ 1. **Não anuncie ação sem fazer.** Se o thought diz "vou usar X", o MESMO JSON precisa ter `"tool"` — não pode ter `"final"`. NUNCA declare sucesso de algo que não executou neste step.
373
+ 2. **`final` é texto natural, NUNCA outro JSON.**
374
+ 3. **Não invente conteúdo que o usuário não passou.** Se a entrada está incompleta, peça.
375
+ 4. **Não cole texto longo dentro de run_python.** Salve com write_file primeiro.
376
+ 5. **Não escreva `from X import *` dentro de `X.py`.** Auto-import quebra.
377
+ 6. **Não declare "executado com sucesso" sem uma tool de execução prévia.**
378
+
379
+ ## EXEMPLOS
380
+ > oi
381
+ {{"thought":"saudação","final":"Oi! Em que posso ajudar?"}}
382
+ > qual a capital do brasil?
383
+ {{"thought":"fato — respondo com contexto","final":"A capital é Brasília, inaugurada em 1960, projetada por Lúcio Costa e Oscar Niemeyer."}}
384
+ > <texto longo colado> resuma
385
+ {{"thought":"resumir é compreensão pura, sem tool","final":"<resumo em 2-4 frases>"}}
386
+ > liste os arquivos aqui
387
+ {{"thought":"explorar","tool":"list_files","args":{{"path":"."}}}}
388
+ > ache onde importa requests
389
+ {{"thought":"buscar no código","tool":"grep","args":{{"pattern":"import requests"}}}}
390
+ > crie um hello.py que printa hi
391
+ {{"thought":"código simples","tool":"write_file","args":{{"path":"hello.py","content":"print('hi')\\n"}}}}
392
+ > quantas vezes 'X' aparece no texto colado? (precisão)
393
+ Step 1: {{"thought":"salvo o texto","tool":"write_file","args":{{"path":"input.txt","content":"<texto cru>"}}}}
394
+ Step 2: {{"thought":"conto case-insensitive","tool":"run_python","args":{{"code":"print(open('input.txt').read().lower().count('x'))"}}}}
395
+
396
+ ## MULTI-STEP
397
+ Quando o usuário pede VÁRIAS coisas, faça UMA por step e só dê `final` quando TODAS estiverem feitas, descrevendo o que foi de fato feito.
398
+
399
+ ## TÉRMINO
400
+ Esquema B com fato observado (não com "vou fazer"). Após 2 erros do mesmo tipo, pare e explique no final.
401
+ """
402
+
403
+
404
+ SYSTEM_PROMPT = build_system_prompt()
405
+
406
+
407
+ # ---------- parser resiliente (cinto + suspensório, mesmo com gramatica) ----------
408
+
409
+ def _fix_bad_escapes(s: str) -> str:
410
+ return re.sub(r'\\(?!["\\/bfnrtu])', '', s)
411
+
412
+
413
+ def parse_action(text: str):
414
+ text = re.sub(r"```(?:json)?\s*", "", text).replace("```", "").strip()
415
+ decoder = json.JSONDecoder()
416
+
417
+ def looks_clean(obj):
418
+ if not isinstance(obj, dict):
419
+ return False
420
+ if "final" in obj and isinstance(obj["final"], str):
421
+ f = obj["final"].strip()
422
+ if f.startswith("{") and ('"tool"' in f or '"final"' in f):
423
+ return False
424
+ return True
425
+
426
+ thought_only = None
427
+ for src in (text, _fix_bad_escapes(text)):
428
+ n, i = len(src), 0
429
+ while i < n:
430
+ if src[i] == "{":
431
+ try:
432
+ obj, end = decoder.raw_decode(src, i)
433
+ except json.JSONDecodeError:
434
+ obj, end = None, None
435
+ if isinstance(obj, dict) and looks_clean(obj):
436
+ if "tool" in obj or "final" in obj:
437
+ return obj
438
+ if thought_only is None and isinstance(obj.get("thought"), str) and obj["thought"].strip():
439
+ thought_only = {"thought": "(promovido)", "final": obj["thought"].strip()}
440
+ if end is not None:
441
+ i = end
442
+ continue
443
+ i += 1
444
+ if _fix_bad_escapes(text) == text:
445
+ break
446
+ return thought_only
447
+
448
+
449
+ COMPREHENSION_VERBS = ("resuma", "resumir", "traduza", "traduzir", "classifique",
450
+ "classificar", "qual o tom", "explique este", "explique esse",
451
+ "do que se trata", "qual é a ideia")
452
+
453
+
454
+ def run_tool(action, user_input=""):
455
+ name = action.get("tool")
456
+ args = action.get("args", {}) or {}
457
+ if name not in TOOLS:
458
+ return {"error": f"ferramenta desconhecida: {name}. disponíveis: {TOOL_NAMES}"}
459
+ if name in ("write_file", "run_python", "run_bash") and user_input:
460
+ if any(v in user_input.lower() for v in COMPREHENSION_VERBS):
461
+ return {"error": f"{name} bloqueado: compreensão (resumir/traduzir/classificar) vai DIRETO no final, sem tool",
462
+ "hint": "responda com {\"thought\":\"compreensão direta\",\"final\":\"<resposta em prosa>\"}."}
463
+ try:
464
+ return TOOLS[name](**args)
465
+ except TypeError as e:
466
+ return {"error": f"args inválidos para {name}: {e}"}
467
+ except Exception as e:
468
+ return {"error": f"{type(e).__name__}: {e}"}
469
+
470
+
471
+ # ---------- compactação de contexto (cabe no ctx 8192 do GYRK) ----------
472
+
473
+ CHAR_BUDGET = 16000 # ~4k tokens de input; deixa folga pros 2048 de saída no ctx 8192
474
+
475
+
476
+ def build_request_messages(messages):
477
+ """Compacta o histórico pra caber no ctx, SEM mutar a lista canônica.
478
+ Mantém a 1ª msg (system+tarefa), injeta as notas + um resumo dos passos
479
+ antigos descartados, e mantém os turnos recentes que couberem (alternância
480
+ user/assistant preservada — exigência do template do modelo)."""
481
+ head, rest = messages[0], messages[1:]
482
+ kept, total = [], len(head["content"])
483
+ for m in reversed(rest):
484
+ if total + len(m["content"]) > CHAR_BUDGET and kept:
485
+ break
486
+ kept.append(m)
487
+ total += len(m["content"])
488
+ kept.reverse()
489
+ while kept and kept[0]["role"] == "user": # após head(user), o 1º mantido deve ser assistant
490
+ kept.pop(0)
491
+ dropped = rest[:len(rest) - len(kept)]
492
+ extras = []
493
+ if _SCRATCHPAD:
494
+ extras.append("NOTAS (memória persistente):\n- " + "\n- ".join(_SCRATCHPAD))
495
+ if dropped:
496
+ acts = []
497
+ for m in dropped:
498
+ if m["role"] == "assistant":
499
+ try:
500
+ a = json.loads(m["content"])
501
+ if a.get("tool"):
502
+ acts.append(a["tool"])
503
+ except Exception:
504
+ pass
505
+ extras.append(f"[{len(dropped)} mensagens antigas compactadas. Tools já usadas: {', '.join(acts) or 'várias'}]")
506
+ if extras:
507
+ head = {"role": "user", "content": head["content"] + "\n\n" + "\n\n".join(extras)}
508
+ return [head] + kept
509
+
510
+
511
+ # ---------- cliente HTTP do GYRK (a unica peca nova; provada pelo spike) ----------
512
+
513
+ def gyrk_generate(messages):
514
+ body = json.dumps({
515
+ "messages": messages,
516
+ "temperature": TEMP, "top_p": 0.9, "seed": 7, "max_tokens": MAX_TOKENS,
517
+ "response_format": {"type": "json_schema",
518
+ "json_schema": {"name": "action", "schema": ACTION_SCHEMA}},
519
+ }).encode()
520
+ headers = {"Content-Type": "application/json"}
521
+ if API_KEY:
522
+ headers["Authorization"] = f"Bearer {API_KEY}"
523
+ last = ""
524
+ for attempt in range(6):
525
+ try:
526
+ req = urllib.request.Request(ENDPOINT, data=body, headers=headers)
527
+ resp = json.loads(urllib.request.urlopen(req, timeout=180 if attempt == 0 else 90).read())
528
+ return resp["choices"][0]["message"]["content"], resp.get("usage", {})
529
+ except urllib.error.HTTPError as e:
530
+ last = (e.read().decode("utf-8", "ignore") if hasattr(e, "read") else str(e))[:300]
531
+ if "Loading model" in last or e.code in (502, 503):
532
+ time.sleep(min(5 * (attempt + 1), 20))
533
+ continue
534
+ if e.code == 429: # cota/rate-limit do gateway
535
+ try:
536
+ j = json.loads(last)
537
+ msg, up = j.get("error", "limite atingido"), j.get("upgrade")
538
+ except Exception:
539
+ msg, up = "limite atingido", None
540
+ raise RuntimeError(msg + (f" — pegue uma chave em {up}" if up else "") +
541
+ ". Já tem chave? rode: gyrk init")
542
+ if e.code in (401, 403):
543
+ raise RuntimeError("chave inválida ou sem acesso. Configure com: gyrk init")
544
+ raise RuntimeError(f"HTTP {e.code}: {last}")
545
+ except (urllib.error.URLError, TimeoutError) as e:
546
+ last = str(e)
547
+ time.sleep(min(5 * (attempt + 1), 20))
548
+ raise RuntimeError(f"GYRK indisponível após retries: {last}")
549
+
550
+
551
+ # ---------- loop do agente (GYRK puro; guards param honestamente) ----------
552
+
553
+ def run_task(task: str, max_steps: int = 12):
554
+ global _SESSION_IN, _SESSION_OUT
555
+ _SCRATCHPAD.clear()
556
+ messages = [{"role": "user", "content": SYSTEM_PROMPT + "\n\nENTRADA DO USUÁRIO: " + task}]
557
+ recent, consecutive_errors, parse_fails, last_ctx = [], 0, 0, 0
558
+
559
+ def add_user(text):
560
+ if messages and messages[-1]["role"] == "user":
561
+ messages[-1]["content"] += "\n\n" + text
562
+ else:
563
+ messages.append({"role": "user", "content": text})
564
+
565
+ try:
566
+ for step in range(1, max_steps + 1):
567
+ try:
568
+ with Spinner():
569
+ raw, usage = gyrk_generate(build_request_messages(messages))
570
+ except RuntimeError as e:
571
+ print(" " + red("✗ " + str(e)))
572
+ return
573
+ _SESSION_IN += usage.get("prompt_tokens", 0)
574
+ _SESSION_OUT += usage.get("completion_tokens", 0)
575
+ last_ctx = usage.get("prompt_tokens", 0) or last_ctx
576
+ action = parse_action(raw)
577
+
578
+ if action is None:
579
+ parse_fails += 1
580
+ if parse_fails >= 2:
581
+ print(" " + red("✗ o modelo não retornou um JSON válido."))
582
+ if VERBOSE:
583
+ print(dim(" " + raw[:300].replace("\n", "\n ")))
584
+ return
585
+ messages.append({"role": "assistant", "content": raw[:500]})
586
+ add_user("ERRO: responda com UM JSON: {\"thought\":...,\"tool\":...,\"args\":...} ou {\"thought\":...,\"final\":...}.")
587
+ continue
588
+
589
+ if VERBOSE:
590
+ print(" " + dim(f"step {step} · ") + mag(action.get("thought", "")))
591
+
592
+ # ----- FINAL -----
593
+ if "final" in action:
594
+ final_text = action.get("final") or ""
595
+ fake_words = ["foi executado com sucesso", "executei o", "rodei o", "abri o",
596
+ "testado com sucesso", "funcionou perfeitamente", "executou com sucesso"]
597
+ if any(w in final_text.lower() for w in fake_words):
598
+ had_exec = any(m["role"] == "user" and m["content"].startswith("OBSERVATION:")
599
+ and "exit_code" in m["content"] for m in messages)
600
+ if not had_exec:
601
+ if VERBOSE:
602
+ print(dim(" [fake-final — forçando execução real]"))
603
+ messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
604
+ add_user("ERRO: 'final' afirma sucesso mas nenhum run_python/run_bash rodou. Execute agora ou reescreva 'final' só com o que foi feito.")
605
+ continue
606
+ print()
607
+ print(render_final(final_text))
608
+ return
609
+
610
+ # ----- TOOL -----
611
+ if "tool" in action:
612
+ tool = action.get("tool")
613
+ args = action.get("args", {}) or {}
614
+ sig = json.dumps({"tool": tool, "args": args}, sort_keys=True, ensure_ascii=False)
615
+ recent.append(sig)
616
+ if len(recent) >= 3 and recent[-3:] == [sig, sig, sig]:
617
+ print(" " + red("✗ repetindo a mesma ação — parando. Reformule a tarefa."))
618
+ return
619
+
620
+ print(" " + cyan("→ ") + bold(str(tool)) + " " + dim(short_args(args)))
621
+ result = run_tool(action, user_input=task)
622
+ result_str = json.dumps(result, ensure_ascii=False)
623
+ if len(result_str) > 1500:
624
+ result_str = result_str[:1500] + "...[truncado]"
625
+
626
+ is_exec = tool in ("run_python", "run_bash")
627
+ is_error = isinstance(result, dict) and (result.get("error") or (is_exec and result.get("exit_code", 0) != 0))
628
+ print(" " + (red("✗ " + summarize_result(tool, result)) if is_error
629
+ else dim("✓ " + summarize_result(tool, result))))
630
+
631
+ consecutive_errors = consecutive_errors + 1 if is_error else 0
632
+ if consecutive_errors >= 3:
633
+ print(" " + red("✗ 3 erros seguidos — parando."))
634
+ return
635
+
636
+ messages.append({"role": "assistant", "content": json.dumps(action, ensure_ascii=False)})
637
+ obs = "OBSERVATION: " + result_str
638
+ if consecutive_errors == 2:
639
+ obs += ("\n\n[REFLITA] 2 erros seguidos. No próximo `thought` diga: (1) a causa raiz, "
640
+ "(2) por que a abordagem falhou, (3) uma abordagem DIFERENTE. Depois execute a nova "
641
+ "abordagem — ou termine com {\"final\":...} explicando, se não houver saída.")
642
+ obs += "\n\nResponda com UM JSON: próxima ferramenta ou {\"thought\":...,\"final\":...}."
643
+ add_user(obs)
644
+
645
+ print(" " + yellow(f"[parou após {max_steps} passos sem concluir]"))
646
+ finally:
647
+ if last_ctx:
648
+ print_footer(last_ctx)
649
+
650
+
651
+ # ---------- UI (clean & profissional) ----------
652
+
653
+ def fmt_tok(n):
654
+ n = n or 0
655
+ return f"{n/1000:.1f}k" if n >= 1000 else str(int(n))
656
+
657
+
658
+ def ctx_bar(used, total, width=12):
659
+ frac = min(1.0, (used or 0) / total) if total else 0.0
660
+ fill = int(round(frac * width))
661
+ return "▓" * fill + "░" * (width - fill)
662
+
663
+
664
+ def short_args(args):
665
+ for k in ("path", "pattern", "command", "code", "text", "old_string"):
666
+ if k in args and args[k] is not None:
667
+ v = str(args[k]).replace("\n", " ").strip()
668
+ return (v[:54] + "…") if len(v) > 54 else v
669
+ return ""
670
+
671
+
672
+ def summarize_result(tool, result):
673
+ if not isinstance(result, dict):
674
+ return str(result)[:60]
675
+ if result.get("error"):
676
+ return str(result["error"])[:70]
677
+ if "exit_code" in result:
678
+ out = (result.get("stdout") or "").strip().splitlines()
679
+ return f"exit {result['exit_code']}" + (f" · {out[0][:46]}" if out else "")
680
+ if "items" in result:
681
+ n = len(result["items"])
682
+ return f"{n} item" + ("" if n == 1 else "s")
683
+ if "matches" in result:
684
+ n = result.get("count", len(result.get("matches", [])))
685
+ return f"{n} ocorrência" + ("" if n == 1 else "s")
686
+ if "total_lines" in result:
687
+ return f"{result['total_lines']} linhas (mostrando {result.get('shown', '')})"
688
+ if "bytes_written" in result:
689
+ return f"salvo · {result['bytes_written']} bytes"
690
+ if "replaced" in result:
691
+ n = result["replaced"]
692
+ return f"{n} troca" + ("" if n == 1 else "s")
693
+ if "notas" in result:
694
+ return "anotado"
695
+ return "ok"
696
+
697
+
698
+ def render_final(text):
699
+ lines = (text or "").split("\n")
700
+ out = [" " + green(bold("● ")) + green(lines[0])]
701
+ out += [" " + green(ln) for ln in lines[1:]]
702
+ return "\n".join(out)
703
+
704
+
705
+ def print_footer(ctx_used):
706
+ pct = int(min(100, (ctx_used or 0) / CTX * 100)) if CTX else 0
707
+ print()
708
+ print(" " + dim("─" * 46))
709
+ print(" " + dim("contexto ") + cyan(ctx_bar(ctx_used, CTX)) +
710
+ dim(f" {fmt_tok(ctx_used)}/{fmt_tok(CTX)} ({pct}%) · ") +
711
+ dim(f"sessão {fmt_tok(_SESSION_IN)}↓ {fmt_tok(_SESSION_OUT)}↑ tokens"))
712
+
713
+
714
+ # ---------- REPL ----------
715
+
716
+ def banner():
717
+ print()
718
+ print(" " + bold(cyan("◆ GYRK")) + dim(" agente de código · v" + VERSION))
719
+ print(" " + dim(WORKSPACE + (" · shell on" if ALLOW_LOCAL else "")))
720
+ print()
721
+
722
+
723
+ VERSION = "0.2.0"
724
+ USAGE = """agent-gyrk — agente de código no terminal rodando o modelo GYRK.
725
+
726
+ uso:
727
+ gyrk abre o REPL interativo (digite tarefas)
728
+ gyrk "sua tarefa" roda uma tarefa e sai
729
+ gyrk init configura sua chave de API (ou: gyrk init --key=...)
730
+ gyrk --allow-local ... libera run_bash (off por padrão)
731
+ gyrk -v | --verbose mostra o raciocínio passo a passo (debug)
732
+ gyrk --help | --version
733
+
734
+ env:
735
+ GYRK_BASE_URL endpoint da API GYRK (default https://api.gyrk.havek.ai)
736
+ GYRK_API_KEY Bearer pro gateway autenticado (Fase 3)
737
+ GYRK_ALLOW_LOCAL=1 mesmo que --allow-local
738
+ GYRK_CTX janela de contexto (default 8192)
739
+ """
740
+
741
+
742
+ def cmd_init(flags):
743
+ key, base = "", ""
744
+ for a in flags:
745
+ if a.startswith("--key="):
746
+ key = a.split("=", 1)[1].strip()
747
+ if a.startswith("--base-url="):
748
+ base = a.split("=", 1)[1].strip()
749
+ if not key:
750
+ try:
751
+ key = input("Cole sua chave GYRK (gyrk_live_...) [enter = free]: ").strip()
752
+ except (EOFError, KeyboardInterrupt):
753
+ print()
754
+ return
755
+ cfg = _load_config()
756
+ if key:
757
+ cfg["api_key"] = key
758
+ if base:
759
+ cfg["base_url"] = base.rstrip("/")
760
+ os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
761
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
762
+ json.dump(cfg, f, indent=2)
763
+ try:
764
+ os.chmod(CONFIG_PATH, 0o600)
765
+ except Exception:
766
+ pass
767
+ print(" " + green("✓") + dim(f" config salva em {CONFIG_PATH}"))
768
+ print(" " + dim(f"chave: {key[:14]}… (tier definido no servidor)" if key else "sem chave = tier free (limitado)"))
769
+
770
+
771
+ def main():
772
+ flags = sys.argv[1:]
773
+ if "--version" in flags or "-V" in flags:
774
+ print(f"agent-gyrk {VERSION}")
775
+ return
776
+ if "--help" in flags or "-h" in flags:
777
+ print(USAGE)
778
+ return
779
+ if flags and flags[0] == "init":
780
+ cmd_init(flags[1:])
781
+ return
782
+ argv = [a for a in flags if not a.startswith("-")]
783
+ oneshot = " ".join(argv).strip()
784
+ banner()
785
+ if oneshot:
786
+ run_task(oneshot)
787
+ print()
788
+ return
789
+ print(" " + dim("digite uma tarefa · 'exit' pra sair") + "\n")
790
+ while True:
791
+ try:
792
+ task = input(bold(green("gyrk> "))).strip()
793
+ except (EOFError, KeyboardInterrupt):
794
+ print(dim("\n tchau 👋\n"))
795
+ break
796
+ if not task:
797
+ continue
798
+ if task.lower() in ("exit", "quit", "sair", ":q"):
799
+ print(dim(" tchau 👋\n"))
800
+ break
801
+ try:
802
+ run_task(task)
803
+ except KeyboardInterrupt:
804
+ print(yellow("\n [tarefa interrompida]"))
805
+ print()
806
+
807
+
808
+ if __name__ == "__main__":
809
+ main()
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-gyrk"
7
+ version = "0.2.0"
8
+ description = "agent-gyrk — CLI agentic rodando o modelo GYRK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [] # só stdlib — nada pra compilar, instala instantâneo
12
+ keywords = ["gyrk", "agent", "cli", "llm", "coding-agent"]
13
+ authors = [{ name = "Havek" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Environment :: Console",
17
+ "Topic :: Software Development",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://gyrk.havek.ai"
22
+
23
+ [project.scripts]
24
+ gyrk = "agent_gyrk:main"
25
+
26
+ [tool.setuptools]
27
+ py-modules = ["agent_gyrk"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+