@andespindola/crow-skills 0.12.2 → 0.13.0

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,731 @@
1
+ #!/usr/bin/env python3
2
+ """lean-context: roda um comando e entrega ao agente uma visão enxuta e fiel da saída.
3
+
4
+ Princípios (cada um corrige um gap observado em rtk, caveman ou context-mode):
5
+ - Nunca resume, reescreve ou inventa: só seleciona linhas reais, com a numeração original.
6
+ - Nunca esconde perda: todo corte é marcado e a saída completa fica num arquivo reabrível,
7
+ também quando o comando dá certo.
8
+ - Preserva o exit code do comando (sinal -> 128+N, timeout -> 124), herda o stdin,
9
+ roda em grupo de processos próprio, tem timeout e mata o grupo inteiro ao estourar.
10
+ - Mascara padrões comuns de segredo no que mostra e no que grava (melhor esforço, não garantia);
11
+ nada bruto vai para o disco; arquivos 0600 num diretório 0700, com retenção curta.
12
+ - Métrica honesta: bytes medidos (brutos vs. exibidos), nunca tokens estimados.
13
+
14
+ Uso:
15
+ lc_run.py [opções] -- comando arg1 arg2 # executa direto (sem shell)
16
+ lc_run.py [opções] -- 'cmd | outro && mais' # um único argumento: roda em bash -c
17
+ lc_run.py --stats # totais medidos
18
+ Só usa a biblioteca padrão do Python 3.8+. macOS e Linux.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import collections
24
+ import json
25
+ import os
26
+ import re
27
+ import shutil
28
+ import signal
29
+ import stat
30
+ import subprocess
31
+ import sys
32
+ import threading
33
+ import time
34
+ from pathlib import Path
35
+
36
+ TAG = "[lean-context]"
37
+ PIECE = 65536 # linhas maiores são processadas em pedaços: custo linear, memória limitada
38
+
39
+ # ---------------------------------------------------------------- redação (melhor esforço)
40
+ _KW = r"(?:password|passwd|passphrase|pwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key|client[_-]?secret|credentials?)"
41
+ _SECRET_PATTERNS = [
42
+ # PEM/PGP inteiro numa linha (ex.: JSON de service account com \n literal)
43
+ (re.compile(rb"(-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----).{0,20000}?(-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----)"), rb"\1<redigido:chave-privada>\2"),
44
+ (re.compile(rb"(?i)\b(authorization\s{0,5}[:=]\s{0,5}(?:bearer|basic|token|bot)\s{1,5})[A-Za-z0-9._~+/=-]{8,}"), rb"\1<redigido>"),
45
+ (re.compile(rb"(?i)\b(bearer\s{1,5})[A-Za-z0-9._~+/=-]{16,}"), rb"\1<redigido>"),
46
+ (re.compile(rb"(?i)\b(x-api-key\s{0,5}[:=]\s{0,5})\S{8,}"), rb"\1<redigido>"),
47
+ # VAR=valor / chave: valor / "chave": "valor", com prefixo (PGPASSWORD, AWS_SECRET_ACCESS_KEY, OPENAI_API_KEY);
48
+ # o valor só é mascarado se parecer segredo (ver _kv_repl): erros como token: expired_or_revoked ficam
49
+ (re.compile(rb"(?i)(" + _KW.encode() + rb"([A-Za-z0-9_]{0,20})[\"']?\s{0,5}[:=]\s{0,5}[\"']?)(?!<redigido)([^\s\"',;&]{4,})"), "kv"),
50
+ # flags: --password valor, --token=valor
51
+ (re.compile(rb"(?i)((?<![\w-])--?(?:password|passwd|pass|token|secret|api-key|apikey|auth-token)(?:=|\s{1,5}))(?![0-9]+\b)(?!<redigido)[^\s\"']{4,}"), rb"\1<redigido>"),
52
+ # curl -u usuario:senha, cookies, Azure, Grafana, Hugging Face
53
+ (re.compile(rb"((?<![\w-])(?:-u|--user)\s{1,5}[^\s:'\"]{1,64}:)[^\s'\"]{4,}"), rb"\1<redigido>"),
54
+ (re.compile(rb"(?i)\b((?:set-)?cookie:\s{0,5}[^=;\s]{1,64}=)[^;\s]{8,}"), rb"\1<redigido>"),
55
+ (re.compile(rb"(?i)\b(AccountKey=|SharedAccessSignature=|SharedAccessKey=|[?&]sig=)[A-Za-z0-9%+/=]{16,}"), rb"\1<redigido>"),
56
+ (re.compile(rb"\bglsa_[A-Za-z0-9_]{20,}"), b"<redigido:grafana>"),
57
+ (re.compile(rb"\bhf_[A-Za-z0-9]{30,}"), b"<redigido:huggingface>"),
58
+ (re.compile(rb"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}"), b"<redigido:sk>"),
59
+ (re.compile(rb"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}"), b"<redigido:stripe>"),
60
+ (re.compile(rb"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), b"<redigido:sendgrid>"),
61
+ (re.compile(rb"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}"), b"<redigido:github>"),
62
+ (re.compile(rb"\bgithub_pat_[A-Za-z0-9_]{40,}"), b"<redigido:github>"),
63
+ (re.compile(rb"\bglpat-[A-Za-z0-9_-]{20,}"), b"<redigido:gitlab>"),
64
+ (re.compile(rb"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), b"<redigido:aws>"),
65
+ (re.compile(rb"\bxox[abprs]-[A-Za-z0-9-]{10,}"), b"<redigido:slack>"),
66
+ (re.compile(rb"\bAIza[0-9A-Za-z_-]{35}\b"), b"<redigido:google>"),
67
+ (re.compile(rb"\bnpm_[A-Za-z0-9]{36}\b"), b"<redigido:npm>"),
68
+ (re.compile(rb"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), b"<redigido:jwt>"),
69
+ # credencial em URL: esquema://usuario:senha@ (ancorado e limitado: linear)
70
+ (re.compile(rb"(?<![A-Za-z0-9+.-])([A-Za-z][A-Za-z0-9+.-]{0,20}://[^\s:/@]{1,200}:)[^\s@/]{3,200}@"), rb"\1<redigido>@"),
71
+ ]
72
+ # pré-filtro: só roda as regex completas quando a linha tem algum gatilho
73
+ _SECRET_TRIGGER = re.compile(rb"(?i)-----BEGIN|authoriz|bearer|api[_-]?key|pass|pwd|secret|token|private|access|credential|sk-|_live_|_test_|SG\.|gh[pousr]_|github_pat_|glpat-|AKIA|ASIA|xox|AIza|npm_|eyJ|://|cookie|accountkey|sharedaccess|sig=|glsa_|hf_|-u |--user")
74
+ _KEY_SUFFIX_NOT_SECRET = re.compile(rb"(?i)^(izer|izers|ization|_?names?|_?types?|_?ids?|_?paths?|_?files?|_?urls?|_?counts?|_?limits?|_?len(gth)?|_?expir\w*|_?ttl|s_used|_?status|_?policy|_?format)$")
75
+
76
+
77
+ def _secretish(val: bytes) -> bool:
78
+ if len(val) < 6 or val[:1] in (b"/", b"~", b".", b"$", b"<", b"{"):
79
+ return False
80
+ if re.fullmatch(rb"[a-z_.-]+", val) or re.fullmatch(rb"[0-9]+", val):
81
+ return False # palavra comum (expired_or_revoked, invalid_grant) ou número (max_tokens=4096)
82
+ if val.lower() in (b"true", b"false", b"null", b"none", b"undefined", b"required", b"missing", b"invalid"):
83
+ return False
84
+ return True
85
+ _PEM_BEGIN = re.compile(rb"-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----")
86
+ _PEM_END = re.compile(rb"-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----")
87
+ _PEM_BODY = re.compile(rb"^\s*(?:[A-Za-z0-9+/=]{1,}|[A-Za-z][A-Za-z-]{0,30}: .{0,200}|=[A-Za-z0-9+/]{1,10})?\s*$")
88
+ PEM_MAX_LINES = 200
89
+
90
+
91
+ class Redactor:
92
+ def __init__(self, enabled: bool):
93
+ self.enabled = enabled
94
+ self.pem_lines = -1 # -1 = fora de bloco PEM
95
+ self.count = 0
96
+
97
+ def _kv_repl(self, m) -> bytes:
98
+ if _KEY_SUFFIX_NOT_SECRET.match(m.group(2)) or not _secretish(m.group(3)):
99
+ return m.group(0)
100
+ self.count += 1
101
+ return m.group(1) + b"<redigido>"
102
+
103
+ def piece(self, raw: bytes, line_start: bool) -> bytes:
104
+ if not self.enabled:
105
+ return raw
106
+ if self.pem_lines >= 0 and line_start:
107
+ if _PEM_END.search(raw):
108
+ self.pem_lines = -1
109
+ return raw
110
+ # só o miolo base64 é mascarado; qualquer outra linha encerra o modo PEM
111
+ if _PEM_BODY.match(raw.rstrip(b"\r\n")) and self.pem_lines < PEM_MAX_LINES:
112
+ self.pem_lines += 1
113
+ self.count += 1
114
+ return b"<redigido:chave-privada>" + (b"\n" if raw.endswith(b"\n") else b"")
115
+ self.pem_lines = -1
116
+ if not _SECRET_TRIGGER.search(raw):
117
+ return raw
118
+ out = raw
119
+ for pat, rep in _SECRET_PATTERNS:
120
+ if rep == "kv":
121
+ out = pat.sub(self._kv_repl, out)
122
+ else:
123
+ out, n = pat.subn(rep, out)
124
+ self.count += n
125
+ if _PEM_BEGIN.search(out) and not _PEM_END.search(out):
126
+ self.pem_lines = 0
127
+ return out
128
+
129
+
130
+ # ---------------------------------------------------------------- sinais (todas as regex limitadas: custo linear)
131
+ _ERR = re.compile(
132
+ r"(?i)(\b(error|errors|erro|erros|fatal|panic|panicked|exception|traceback|failed|failure|failing|fail|"
133
+ r"falha|falhas|falhou|falharam|falhando|denied|negado|refused|recusad[oa]|timed out|timeout|"
134
+ r"segmentation fault|segfault|sigsegv|sigabrt|aborted|core dumped|critical|crit|"
135
+ r"cannot|can't|unable to|não foi possível|nao foi possivel|not found|não encontrad[oa]|no such file|"
136
+ r"undefined|unresolved|assertion|assert|caused by|killed|oom|oomkilled|out of memory|evicted|crashloopbackoff|imagepullbackoff|errimagepull)\b"
137
+ r"|(Error|Exception|Fault)\b|ERR!|^\s{0,8}ERR(OR)?\b|\b(BUILD|TEST) FAIL"
138
+ r"|^\s{0,8}(E|F)\s{2,}|^\s{0,12}[✗✘×✕✖●]\s"
139
+ r"|\bHTTP/\d(\.\d)?\s{1,5}[45]\d\d\b|\bstatus(code|_code)?[\"']?\s{0,3}[=:]\s{0,3}[\"']?5\d\d\b"
140
+ r"|\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b.{0,300}[\s\"]5\d\d\b"
141
+ r"|\blevel[\"']?\s{0,3}[=:]\s{0,3}[\"']?(crit|critical|error|err|fatal|panic|alert|emerg)\b"
142
+ r"|\bexit(ed)?( with)?( code| status)?[=: ]{1,3}[1-9]\d{0,2}\b)"
143
+ )
144
+ _FRAME = re.compile(r"^\s{1,40}(at\s\S|File \"[^\"\n]{0,500}\", line \d+|\.\.\. \d+ more|in <module>|#\d+\s+0x)"
145
+ r"|^\s{4,40}\S[^()\n]{0,300}\([^()\n]{0,300}:\d+(:\d+)?\)\s*$")
146
+ _WARN = re.compile(r"(?i)\b(warn|warning|warnings|deprecat\w{0,10}|aviso|avisos)\b")
147
+ _ZERO = re.compile(r"(?i)\b(0|no|zero|sem|nenhum[a]?)\s{1,3}(failed|failures?|errors?|erros?|falhas?)\b|\b(failed|errors?|failures?|erros?|falhas?)[\"']?\s{0,3}[=:]\s{0,3}0\b")
148
+
149
+
150
+ _FRAME_M = re.compile(_FRAME.pattern, re.M)
151
+ _PYTEST_M = re.compile(r"(?m)^\s{0,8}[EF]\s\s")
152
+ # palavras que qualquer linha de falha ou aviso contém (em minúsculas); bloco sem nenhuma vai pelo caminho rápido
153
+ _SIG_KEYS = ("err", "fatal", "fail", "falh", "panic", "xception", "traceback", "denied", "negad", "refus", "recusad", "timed out",
154
+ "timeout", "fault", "sigsegv", "sigabrt", "abort", "core dump", "crit", "cannot", "can't", "unable",
155
+ "foi poss", "not found", "encontrad", "no such", "undefined", "unresolved", "assert", "caused by", "killed",
156
+ "oom", "out of memory", "evicted", "backoff", "warn", "deprecat", "aviso",
157
+ "✗", "✘", "×", "✕", "✖", "●")
158
+ # formas específicas de falha que contêm palavras comuns (status=200, GET, level=info, exit 0 não disparam)
159
+ _BLOCK_SIG = re.compile(r"status(code|_code)?[\"']?\s{0,3}[=:]\s{0,3}[\"']?5\d\d|\b(get|post|put|patch|delete|head|options)\b[^\n]{0,300}[\s\"]5\d\d\b"
160
+ r"|level[\"']?\s{0,3}[=:]\s{0,3}[\"']?(crit|critical|error|err|fatal|panic|alert|emerg)\b"
161
+ r"|\bexit(ed)?( with)?( code| status)?[=: ]{1,3}[1-9]|http/\d(\.\d)?\s{1,5}[45]\d\d")
162
+ _SECRET_TRIGGER_L = re.compile(_SECRET_TRIGGER.pattern.replace(b"(?i)", b"").lower())
163
+ _CTRL_B = re.compile(rb"[\x00-\x08\x0b\x0e-\x1f\x7f]")
164
+
165
+
166
+ def match_err(text: str):
167
+ m = _FRAME.search(text)
168
+ if m:
169
+ return m
170
+ m = _ERR.search(text)
171
+ if m and _ZERO.search(text):
172
+ m = _ERR.search(_ZERO.sub(lambda z: " " * len(z.group(0)), text))
173
+ return m
174
+
175
+
176
+ def classify(text: str) -> int:
177
+ if match_err(text):
178
+ return 1
179
+ if _WARN.search(text):
180
+ return 2
181
+ return 0
182
+
183
+
184
+ _CTRL = re.compile(r"[\x00-\x08\x0b\x0e-\x1f\x7f]+|\ufffd+")
185
+
186
+
187
+ def is_binary(b: bytes) -> bool:
188
+ if not b:
189
+ return False
190
+ if b"\x00" in b:
191
+ return True
192
+ ctrl = sum(1 for c in b[:4096] if c < 32 and c not in (9, 10, 12, 13, 27))
193
+ return ctrl / min(len(b), 4096) > 0.3
194
+
195
+
196
+ # ---------------------------------------------------------------- linha lógica
197
+ class Line:
198
+ """Visão de exibição de uma linha (possivelmente enorme, com \\r de barra de progresso)."""
199
+ __slots__ = ("n", "length", "kind", "text", "binary", "cr")
200
+
201
+ def __init__(self, n):
202
+ self.n, self.length, self.kind, self.text, self.binary, self.cr = n, 0, 0, None, False, False
203
+
204
+ def feed(self, red: bytes, width: int):
205
+ self.length += len(red)
206
+ txt = red.decode("utf-8", errors="replace").rstrip("\r\n")
207
+ if is_binary(red) or (len(txt) > 8 and txt.count("\ufffd") > 0.3 * len(txt)):
208
+ self.binary = True
209
+ txt = _CTRL.sub("", txt)
210
+ m = match_err(txt)
211
+ if m:
212
+ self.kind, self.text = 1, window(txt, m.start(), width)
213
+ return
214
+ segs = txt.split("\r") if "\r" in txt else [txt]
215
+ if len(segs) > 1:
216
+ self.cr = True
217
+ for seg in segs:
218
+ m = match_err(seg)
219
+ k = 1 if m else (2 if _WARN.search(seg) else 0)
220
+ if k and (self.kind == 0 or k < self.kind):
221
+ self.kind = k
222
+ self.text = window(seg, m.start() if m else 0, width)
223
+ elif self.kind == 0 and seg.strip():
224
+ self.text = window(seg, 0, width) # último segmento visível de uma barra de progresso
225
+ if self.text is None:
226
+ self.text = ""
227
+
228
+ def show(self) -> str:
229
+ if self.binary and not self.text:
230
+ return f"<conteúdo binário, {self.length} bytes>"
231
+ extra = []
232
+ if self.cr:
233
+ extra.append("atualizações com \\r omitidas")
234
+ if self.length > len(self.text.encode("utf-8", errors="replace")) + 4 and not self.cr:
235
+ extra.append(f"linha com {self.length} bytes")
236
+ return self.text + (f" …({'; '.join(extra)}; ver arquivo)" if extra else "")
237
+
238
+
239
+ def window(s: str, pos: int, width: int) -> str:
240
+ if len(s) <= width:
241
+ return s
242
+ start = max(0, min(pos - width // 4, len(s) - width))
243
+ out = s[start:start + width]
244
+ return ("…" if start else "") + out + ("…" if start + width < len(s) else "")
245
+
246
+
247
+ # ---------------------------------------------------------------- estado em disco
248
+ def state_dir(arg: str | None) -> Path:
249
+ base = arg or os.environ.get("LEAN_CONTEXT_DIR")
250
+ if not base:
251
+ xdg = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
252
+ base = os.path.join(xdg, "lean-context")
253
+ p = Path(base)
254
+ p.mkdir(parents=True, exist_ok=True, mode=0o700)
255
+ st = os.lstat(p)
256
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
257
+ raise SystemExit(f"{TAG} recusado: {p} não é um diretório comum")
258
+ if st.st_uid != os.getuid():
259
+ raise SystemExit(f"{TAG} recusado: {p} pertence a outro usuário")
260
+ if stat.S_IMODE(st.st_mode) & 0o077:
261
+ os.chmod(p, 0o700)
262
+ if stat.S_IMODE(os.lstat(p).st_mode) & 0o077:
263
+ raise SystemExit(f"{TAG} recusado: não consegui deixar {p} privado (0700)")
264
+ return p
265
+
266
+
267
+ def purge_old(d: Path, keep_days: float) -> None:
268
+ limit = time.time() - keep_days * 86400
269
+ for pat in ("run-*.log", ".tmp-*"):
270
+ for f in d.glob(pat):
271
+ try:
272
+ if os.lstat(f).st_mtime < limit:
273
+ f.unlink()
274
+ except OSError:
275
+ pass
276
+
277
+
278
+ def private_open(path: Path, append=False):
279
+ flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
280
+ flags |= os.O_APPEND if append else os.O_EXCL
281
+ fd = os.open(path, flags, 0o600)
282
+ return os.fdopen(fd, "ab" if append else "wb")
283
+
284
+
285
+ def human(n: float) -> str:
286
+ for unit in ("B", "KB", "MB", "GB"):
287
+ if n < 1024 or unit == "GB":
288
+ return f"{int(n)} {unit}" if unit == "B" else f"{n:.1f} {unit}"
289
+ n /= 1024
290
+ return str(n)
291
+
292
+
293
+ def emit(s: str = "") -> None:
294
+ try:
295
+ sys.stdout.write(s + "\n")
296
+ except (BrokenPipeError, OSError):
297
+ pass # erro de escrita nunca troca o exit code do comando
298
+
299
+
300
+ def safe_cut(data: bytes, limit: int) -> int:
301
+ """Ponto de corte de uma linha enorme: o último espaço antes do limite cujos 96 bytes anteriores não têm
302
+ gatilho de segredo, para que nem segredo nem palavra de erro fiquem partidos entre dois pedaços."""
303
+ lo = max(1, limit - 8192)
304
+ i = limit
305
+ while True:
306
+ i = max(data.rfind(b" ", lo, i), data.rfind(b"\t", lo, i))
307
+ if i < lo:
308
+ return limit # sem espaço (ex.: minificado): corte fixo; risco residual documentado
309
+ if not _SECRET_TRIGGER_L.search(data[max(0, i - 96):i].lower()):
310
+ return i + 1
311
+
312
+
313
+ # ---------------------------------------------------------------- coleta em streaming
314
+ class Collector:
315
+ def __init__(self, args, redactor: Redactor, out):
316
+ self.a, self.red, self.out = args, redactor, out
317
+ self.lines = 0
318
+ self.bytes = 0
319
+ self.head: list[Line] = []
320
+ self.tail: collections.deque = collections.deque(maxlen=args.tail)
321
+ k = max(1, args.max_signals)
322
+ self.err_first: list[Line] = []
323
+ self.err_last: collections.deque = collections.deque(maxlen=k // 2 or 1)
324
+ self.err_total = 0
325
+ self.warn: list[Line] = []
326
+ self.warn_total = 0
327
+ self.ctx_after = 0
328
+ self.ctx_buf: collections.deque = collections.deque(maxlen=max(1, args.context))
329
+ self.pending_ctx: list = []
330
+ self.small_raw = bytearray() # cópia redigida enquanto a saída ainda é pequena
331
+ self.small_ok = True
332
+ self.altered = False
333
+ self.binary_lines = 0
334
+ self.cur: Line | None = None
335
+ self.buf = bytearray()
336
+ self.sec_any = True
337
+ self.sig_any = True
338
+ self.must_keep = False
339
+
340
+ def write(self, chunk: bytes):
341
+ data = bytes(self.buf) + chunk if self.buf else chunk
342
+ # testes de bloco: um bloco sem nenhum gatilho dispensa as regex por linha
343
+ self.sec_any = self.red.enabled and (self.red.pem_lines >= 0 or bool(_SECRET_TRIGGER_L.search(data.lower())))
344
+ if _CTRL_B.search(data):
345
+ self.sig_any = True
346
+ else:
347
+ t = data.decode("utf-8", errors="replace")
348
+ low = t.lower()
349
+ self.sig_any = ("\ufffd" in t or "\r" in t or any(k in low for k in _SIG_KEYS)
350
+ or bool(_BLOCK_SIG.search(low) or _FRAME_M.search(t) or _PYTEST_M.search(t)))
351
+ parts = data.split(b"\n")
352
+ rest = parts.pop()
353
+ for part in parts:
354
+ while len(part) >= PIECE:
355
+ cut = safe_cut(part, PIECE)
356
+ self._piece(part[:cut], end=False)
357
+ part = part[cut:]
358
+ self._piece(part + b"\n", end=True)
359
+ while len(rest) >= PIECE:
360
+ cut = safe_cut(rest, PIECE)
361
+ self._piece(rest[:cut], end=False)
362
+ rest = rest[cut:]
363
+ self.buf = bytearray(rest)
364
+
365
+ def close(self):
366
+ self.sec_any = self.sig_any = True
367
+ if self.buf:
368
+ self._piece(bytes(self.buf), end=True)
369
+ self.buf.clear()
370
+ elif self.cur is not None:
371
+ self._finish()
372
+
373
+ def _piece(self, raw: bytes, end: bool):
374
+ start = self.cur is None
375
+ if start:
376
+ self.cur = Line(self.lines + 1)
377
+ red = self.red.piece(raw, start) if self.sec_any else raw
378
+ if red != raw:
379
+ self.altered = True
380
+ self.out.write(red)
381
+ self.bytes += len(raw)
382
+ if self.small_ok:
383
+ if len(self.small_raw) + len(red) <= self.a.inline_max_bytes:
384
+ self.small_raw += red
385
+ else:
386
+ self.small_ok = False
387
+ self.small_raw = bytearray()
388
+ if not self.sig_any:
389
+ ln = self.cur
390
+ ln.length += len(red)
391
+ if ln.text is None or (ln.kind == 0 and len(ln.text) < self.a.width):
392
+ t = red[: self.a.width * 4].decode("utf-8", errors="replace").rstrip("\r\n")
393
+ ln.text = window(((ln.text or "") + t), 0, self.a.width) if ln.text else window(t, 0, self.a.width)
394
+ elif len(self.cur.text or "") < self.a.width or self.cur.kind == 0:
395
+ self.cur.feed(red, self.a.width)
396
+ else:
397
+ self.cur.length += len(red)
398
+ if end:
399
+ self._finish()
400
+
401
+ def _finish(self):
402
+ ln, self.cur = self.cur, None
403
+ self.lines += 1
404
+ if ln.binary:
405
+ self.binary_lines += 1
406
+ if len(self.head) < self.a.head:
407
+ self.head.append(ln)
408
+ self.tail.append(ln)
409
+ if ln.kind == 1:
410
+ self.err_total += 1
411
+ group = list(self.ctx_buf)[-1:] + [ln] # uma linha antes (ex.: título do teste)
412
+ self._add_err(group)
413
+ self.ctx_after = self.a.context
414
+ elif self.ctx_after > 0:
415
+ self.ctx_after -= 1
416
+ self._add_err([ln], context=True)
417
+ elif ln.kind == 2:
418
+ self.warn_total += 1
419
+ if len(self.warn) < self.a.max_signals:
420
+ self.warn.append(ln)
421
+ self.ctx_buf.append(ln)
422
+
423
+ def _add_err(self, group, context=False):
424
+ k = max(1, self.a.max_signals)
425
+ if len(self.err_first) < k:
426
+ self.err_first.extend(group)
427
+ elif context:
428
+ if self.err_last:
429
+ self.err_last[-1].extend(group)
430
+ else:
431
+ self.err_last.append(list(group))
432
+
433
+
434
+ # ---------------------------------------------------------------- execução
435
+ def run(cmd: list[str], timeout: float, coll: Collector) -> tuple[int, float, list[str]]:
436
+ notes: list[str] = []
437
+ if len(cmd) == 1 and re.search(r"[\s|&;<>()$`*?~{}\[\]'\"\\]", cmd[0]):
438
+ sh = shutil.which("bash") or "/bin/sh"
439
+ argv = [sh, "-c", cmd[0]]
440
+ else:
441
+ argv = cmd
442
+ start = time.monotonic()
443
+ try:
444
+ # grupo de processos próprio na mesma sessão (o vigia precisa poder entrar nele)
445
+ proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=None,
446
+ preexec_fn=lambda: os.setpgid(0, 0))
447
+ except FileNotFoundError:
448
+ coll.write(f"lean-context: comando não encontrado: {argv[0]}\n".encode())
449
+ coll.close()
450
+ return 127, 0.0, notes
451
+ except PermissionError:
452
+ coll.write(f"lean-context: sem permissão para executar: {argv[0]}\n".encode())
453
+ coll.close()
454
+ return 126, 0.0, notes
455
+ pgid = proc.pid
456
+ watchdog = start_watchdog(pgid)
457
+
458
+ def forward(signum, _frame):
459
+ try:
460
+ os.killpg(pgid, signum)
461
+ except (ProcessLookupError, PermissionError):
462
+ pass
463
+
464
+ old = {s: signal.signal(s, forward) for s in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
465
+ lock = threading.Lock()
466
+
467
+ def pump():
468
+ assert proc.stdout is not None
469
+ for chunk in iter(lambda: proc.stdout.read1(65536), b""):
470
+ with lock:
471
+ coll.write(chunk)
472
+
473
+ reader = threading.Thread(target=pump, daemon=True)
474
+ reader.start()
475
+ timed_out = False
476
+ try:
477
+ proc.wait(timeout=timeout if timeout > 0 else None)
478
+ except subprocess.TimeoutExpired:
479
+ timed_out = True
480
+ kill_group(pgid, proc)
481
+ notes.append(f"timeout de {timeout:g}s atingido; SIGTERM e depois SIGKILL enviados ao grupo do comando "
482
+ "(processos que criaram sessão própria com setsid ficam fora do alcance)")
483
+ reader.join(timeout=5)
484
+ if reader.is_alive():
485
+ if timed_out:
486
+ kill_group(pgid, proc)
487
+ notes.append("um processo filho em segundo plano manteve a saída aberta; a leitura parou sem matá-lo")
488
+ for s, h in old.items():
489
+ signal.signal(s, h)
490
+ if watchdog is not None:
491
+ try:
492
+ watchdog.kill()
493
+ watchdog.wait(timeout=2)
494
+ except Exception:
495
+ pass
496
+ with lock:
497
+ coll.close()
498
+ elapsed = time.monotonic() - start
499
+ rc = proc.returncode
500
+ if timed_out:
501
+ rc = 124
502
+ elif rc is None:
503
+ rc = 1
504
+ elif rc < 0:
505
+ rc = 128 + (-rc)
506
+ return rc, elapsed, notes
507
+
508
+
509
+ _WATCHDOG = r"""
510
+ import os, signal, sys, time
511
+ parent, pgid = int(sys.argv[1]), int(sys.argv[2])
512
+ while os.getppid() == parent:
513
+ time.sleep(0.5)
514
+ for sig, wait in ((signal.SIGTERM, 2.0), (signal.SIGKILL, 0)):
515
+ try:
516
+ os.killpg(pgid, sig)
517
+ except ProcessLookupError:
518
+ break
519
+ time.sleep(wait)
520
+ """
521
+
522
+
523
+ _REAP: list = []
524
+
525
+
526
+ def start_watchdog(pgid: int):
527
+ """Processo vigia dentro do grupo do comando: se o lc_run for morto (ex.: SIGKILL pelo timeout do host),
528
+ o vigia percebe a orfandade e encerra o grupo, para não sobrar comando órfão."""
529
+ try:
530
+ w = subprocess.Popen([sys.executable, "-I", "-c", _WATCHDOG, str(os.getpid()), str(pgid)],
531
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
532
+ preexec_fn=lambda: os.setpgid(0, pgid))
533
+ _REAP.append(w)
534
+ return w
535
+ except Exception:
536
+ return None
537
+
538
+
539
+ def group_alive(pgid: int) -> bool:
540
+ try:
541
+ os.killpg(pgid, 0)
542
+ return True
543
+ except (ProcessLookupError, PermissionError):
544
+ return False # macOS: EPERM quando só restam zombies no grupo
545
+
546
+
547
+ def kill_group(pgid: int, proc) -> None:
548
+ for sig, grace in ((signal.SIGTERM, 3.0), (signal.SIGKILL, 2.0)):
549
+ try:
550
+ os.killpg(pgid, sig)
551
+ except (ProcessLookupError, PermissionError):
552
+ return
553
+ deadline = time.monotonic() + grace
554
+ while time.monotonic() < deadline:
555
+ proc.poll()
556
+ for w in _REAP:
557
+ w.poll()
558
+ if not group_alive(pgid):
559
+ return
560
+ time.sleep(0.05)
561
+
562
+
563
+ # ---------------------------------------------------------------- relatório
564
+ def render(rc, elapsed, c: Collector, path: Path, args, notes, shell_cmd: bool):
565
+ small = (c.small_ok and c.lines <= args.inline_max_lines and c.binary_lines == 0)
566
+ shown = 0
567
+
568
+ def out(s: str = ""):
569
+ nonlocal shown
570
+ shown += len(s.encode("utf-8", errors="replace")) + 1
571
+ emit(s)
572
+
573
+ status = f"exit={rc} · {c.lines} linhas · {human(c.bytes)} · {elapsed:.1f}s"
574
+ if c.bytes == 0:
575
+ out(f"{TAG} {status} · o comando não escreveu nada")
576
+ elif small:
577
+ text = bytes(c.small_raw).decode("utf-8", errors="replace")
578
+ for line in text.split("\n")[:-1] if text.endswith("\n") else text.split("\n"):
579
+ out(line)
580
+ note = "saída completa acima, sem cortes"
581
+ if c.altered or "\ufffd" in text or "\r" in text:
582
+ c.must_keep = True
583
+ note += f" (exibição pode diferir em bytes: \\r, UTF-8 inválido ou segredos; original redigido em {path})"
584
+ out(f"{TAG} {status} · {note}")
585
+ else:
586
+ red = " (padrões comuns de segredo mascarados; melhor esforço)" if c.red.enabled else ""
587
+ out(f"{TAG} {status}")
588
+ out(f"{TAG} saída completa{red}: {path}")
589
+ out(f"{TAG} abaixo só há linhas reais, com a numeração original; nada foi resumido nem reescrito")
590
+ seen: set[int] = set()
591
+
592
+ def block(title: str, items):
593
+ fresh = [l for l in items if l.n not in seen]
594
+ if not fresh:
595
+ return
596
+ out(f"--- {title} ---")
597
+ prev = None
598
+ run_start = None
599
+ for l in sorted(fresh, key=lambda x: x.n):
600
+ if l.n in seen:
601
+ continue
602
+ if prev is not None and l.n > prev + 1:
603
+ if run_start is not None:
604
+ out(f"L{run_start}–L{prev}: <conteúdo binário>")
605
+ run_start = None
606
+ out(f" … L{prev + 1}–L{l.n - 1} omitidas")
607
+ if l.binary and not l.text:
608
+ if run_start is None:
609
+ run_start = l.n
610
+ else:
611
+ if run_start is not None:
612
+ out(f"L{run_start}–L{prev}: <conteúdo binário>")
613
+ run_start = None
614
+ out(f"L{l.n}: {l.show()}")
615
+ seen.add(l.n)
616
+ prev = l.n
617
+ if run_start is not None:
618
+ out(f"L{run_start}–L{prev}: <conteúdo binário>")
619
+
620
+ if c.head:
621
+ block(f"início (L1–L{c.head[-1].n})", c.head)
622
+ errs = list(c.err_first) + [l for g in c.err_last for l in g]
623
+ if c.err_total:
624
+ shown_err = len({l.n for l in errs if l.kind == 1})
625
+ title = f"falhas e contexto ({c.err_total} linha(s) de falha"
626
+ if c.err_total > shown_err:
627
+ title += f"; exibidas as primeiras e as últimas, {c.err_total - shown_err} omitidas: use Grep no arquivo"
628
+ block(title + ")", errs)
629
+ budget = max(0, args.max_signals - len(errs))
630
+ if c.warn_total and budget:
631
+ ws = c.warn[:budget]
632
+ title = f"avisos ({c.warn_total} no total"
633
+ title += f"; {c.warn_total - len(ws)} não exibidos)" if c.warn_total > len(ws) else ")"
634
+ block(title, ws)
635
+ elif c.warn_total:
636
+ out(f"{TAG} {c.warn_total} linha(s) de aviso não exibidas (prioridade para falhas)")
637
+ if c.tail:
638
+ block(f"fim (L{c.tail[0].n}–L{c.tail[-1].n})", list(c.tail))
639
+ out(f"{TAG} {len(seen)} de {c.lines} linhas exibidas; {c.lines - len(seen)} omitidas. Para ver mais: Read no arquivo com offset/limit, ou Grep nele")
640
+ if rc != 0 and not c.err_total and c.bytes:
641
+ out(f"{TAG} atenção: exit={rc} sem linha de falha reconhecida; leia o fim do arquivo antes de concluir")
642
+ if rc == 0 and c.err_total:
643
+ hint = " (num pipeline vale o exit do último estágio; use set -o pipefail)" if shell_cmd else ""
644
+ out(f"{TAG} atenção: exit=0, mas há {c.err_total} linha(s) com cara de falha{hint}")
645
+ if c.red.count:
646
+ out(f"{TAG} {c.red.count} trecho(s) com cara de segredo mascarado(s)")
647
+ for n in notes:
648
+ out(f"{TAG} {n}")
649
+ return small, shown
650
+
651
+
652
+ def record_stats(d: Path, raw_bytes: int, shown: int, rc: int, kept: bool) -> None:
653
+ try:
654
+ with private_open(d / "stats.jsonl", append=True) as f:
655
+ f.write((json.dumps({"ts": int(time.time()), "raw_bytes": raw_bytes, "shown_bytes": shown,
656
+ "exit": rc, "file_kept": kept}) + "\n").encode())
657
+ except OSError:
658
+ pass
659
+
660
+
661
+ def print_stats(d: Path) -> int:
662
+ runs = raw = shown = 0
663
+ p = d / "stats.jsonl"
664
+ if p.exists() and not p.is_symlink():
665
+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
666
+ try:
667
+ r = json.loads(line)
668
+ except ValueError:
669
+ continue
670
+ runs += 1
671
+ raw += r.get("raw_bytes", 0)
672
+ shown += r.get("shown_bytes", 0)
673
+ emit(f"{TAG} execuções: {runs}")
674
+ emit(f"{TAG} bytes produzidos pelos comandos: {human(raw)}")
675
+ emit(f"{TAG} bytes que entraram no contexto: {human(shown)}")
676
+ if raw:
677
+ emit(f"{TAG} redução medida em bytes: {100 * (1 - shown / raw):.1f}% (bytes, não tokens; não diz nada sobre a qualidade da tarefa)")
678
+ return 0
679
+
680
+
681
+ def main(argv: list[str]) -> int:
682
+ if "--" in argv:
683
+ i = argv.index("--")
684
+ opts, cmd = argv[:i], argv[i + 1:]
685
+ else:
686
+ opts, cmd = argv, []
687
+ ap = argparse.ArgumentParser(prog="lc_run.py", description="Saída enxuta e fiel de comandos para agentes.")
688
+ ap.add_argument("--timeout", type=float, default=float(os.environ.get("LEAN_CONTEXT_TIMEOUT", 110)),
689
+ help="segundos (padrão 110, abaixo do timeout padrão de 120 s do Bash do host)")
690
+ ap.add_argument("--inline-max-lines", type=int, default=150)
691
+ ap.add_argument("--inline-max-bytes", type=int, default=12000)
692
+ ap.add_argument("--head", type=int, default=30)
693
+ ap.add_argument("--tail", type=int, default=50)
694
+ ap.add_argument("--max-signals", type=int, default=40)
695
+ ap.add_argument("--context", type=int, default=3, help="linhas exibidas depois de cada falha")
696
+ ap.add_argument("--width", type=int, default=400, help="largura (marcada) de linhas muito longas")
697
+ ap.add_argument("--keep-days", type=float, default=3)
698
+ ap.add_argument("--dir", default=None)
699
+ ap.add_argument("--no-redact", action="store_true")
700
+ ap.add_argument("--stats", action="store_true")
701
+ args = ap.parse_args(opts)
702
+ d = state_dir(args.dir)
703
+ if args.stats:
704
+ return print_stats(d)
705
+ if not cmd:
706
+ ap.error("informe o comando depois de --")
707
+ purge_old(d, args.keep_days)
708
+ stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{os.getpid()}"
709
+ final_path = d / f"run-{stamp}.log"
710
+ redactor = Redactor(not args.no_redact)
711
+ with private_open(final_path) as out:
712
+ coll = Collector(args, redactor, out)
713
+ rc, elapsed, notes = run(cmd, args.timeout, coll)
714
+ shell_cmd = len(cmd) == 1 and "|" in cmd[0]
715
+ small, shown = render(rc, elapsed, coll, final_path, args, notes, shell_cmd)
716
+ keep = coll.bytes > 0 and (not small or coll.altered or coll.must_keep)
717
+ if not keep:
718
+ try:
719
+ final_path.unlink() # coube inteiro e intacto no contexto: não há o que recuperar
720
+ except OSError:
721
+ pass
722
+ record_stats(d, coll.bytes, shown, rc, keep)
723
+ try:
724
+ sys.stdout.flush()
725
+ except (BrokenPipeError, OSError):
726
+ pass
727
+ return rc
728
+
729
+
730
+ if __name__ == "__main__":
731
+ sys.exit(main(sys.argv[1:]))