File size: 17,569 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
#!/usr/bin/env python3

import os
import sys
import difflib
import json
import time
import shlex
import shutil
import subprocess
import tempfile
import webbrowser
import threading
import re
import io
import base64
import gettext
import functools
import unicodedata
from pathlib import Path
from urllib.parse import quote_plus
from concurrent.futures import ThreadPoolExecutor

import socket
import logging
from logging.handlers import RotatingFileHandler

import requests
import psutil
from PIL import Image

import tars_i18n as i18n
from tars_i18n import t
import tars_acessibilidade as acess
import tars_escolha as escolha
import tars_murph as murph
import tars_mouse as mouse
import tars_memoria as memoria_lib
import tars_atualizacao as atualizacao
rotinas_lib = memoria_lib  # nome antigo (o kit do Gargantua ainda usa)
import tars_ambiente as ambiente
import tars_visao as visao_lib
import tars_openai as api
import tars_voz as voz_lib
import tars_anexos as anexos_lib
import tars_rotinas as agenda_lib
import tars_primeiro_uso as primeiro_uso
import tars_modos as modos
import tars_confianca as confianca_lib
import tars_plataforma as plataforma
import tars_shell as shell_lib

# Windows: num terminal ou num pipe em cp1252 não existem ✓ ✗ ⎿; trocar o caractere é melhor que o programa cair
for _saida in (sys.stdout, sys.stderr):
    try:
        _saida.reconfigure(errors="replace")
    except (AttributeError, ValueError):
        pass
import tars_probes as probes_lib
import tars_web as web_lib


# ------------------------------------------------------------
# Sessão gráfica. O controle de mouse/teclado (pyautogui) fala com o
# servidor X. Numa sessão Wayland ele só alcança janelas XWayland, e
# sem nenhum display ele nem importa: o import falha e, antes, isso
# derrubava o openTARS inteiro, até o chat que não precisa de mouse.
# ------------------------------------------------------------

def _tipo_sessao():
    # 4.5: TARS_SEM_INTERFACE=1 trata a máquina como sem tela (servidor, SSH, só terminal)
    if ambiente.forcado_sem_interface():
        return "nenhuma"
    if os.environ.get("WAYLAND_DISPLAY") or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland":
        return "wayland"
    if os.environ.get("DISPLAY"):
        return "x11"
    # 5.2: os nomes de sessão que o Gargantua viu no treino (bloco THIS COMPUTER da "pele de sistema")
    return {"windows": "windows", "macos": "aqua"}.get(plataforma.sistema(), "nenhuma")


SESSAO_GRAFICA = _tipo_sessao()
# 4.5 Endurance: onde estamos (WSL? sem tela?). Sem tela, o openTARS vira um
# assistente de terminal: conversa, comandos, arquivos e as IAs.
AMBIENTE = ambiente.detectar()
if SESSAO_GRAFICA == "nenhuma":
    AMBIENTE["sem_interface"], AMBIENTE["tela"] = True, False
SEM_INTERFACE = AMBIENTE["sem_interface"]
EM_WSL = bool(AMBIENTE["wsl"])


class _PyautoguiIndisponivel:
    """Fica no lugar do pyautogui quando ele não carrega: qualquer uso
    (mover mouse, clicar, digitar) vira um erro com o motivo real, que
    a ferramenta devolve pra IA em vez de quebrar o programa."""

    def __init__(self, motivo):
        self.__dict__["_motivo"] = motivo

    def __getattr__(self, nome):
        raise RuntimeError(f"mouse/keyboard control unavailable: {self._motivo}")


# O pyautogui (via python-xlib) aborta a importação se não houver
# arquivo de autorização do X. Sem ~/.Xauthority e sem $XAUTHORITY não
# existe cookie nenhum mesmo, então apontar pra um arquivo vazio só
# evita o crash (os lançadores fazem o mesmo; isso cobre quem roda o
# tars.py direto).
def achar_autorizacao_x():
    """O arquivo de cookie do X. No GNOME com Wayland o XWayland guarda o seu em /run/user/<uid>/ (o mutter cria
    .mutter-Xwaylandauth.*); apontar pra /dev/null nesse caso impede a conexão ("Authorization required")."""
    try:
        pasta = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}")
        for padrao in (".mutter-Xwaylandauth.*", "xauth_*", ".Xwaylandauth*"):
            for arq in sorted(pasta.glob(padrao)):
                if arq.is_file():
                    return str(arq)
    except OSError:
        pass
    return None


if plataforma.sistema() == "linux" and not os.environ.get("XAUTHORITY") and not (Path.home() / ".Xauthority").exists():
    os.environ["XAUTHORITY"] = achar_autorizacao_x() or "/dev/null"

try:
    import pyautogui
    ERRO_PYAUTOGUI = None
except Exception as _e:  # sem display, display inacessível, Xlib ausente...
    # Texto técnico (vai pra IA nos resultados das ferramentas); pro
    # usuário, avisos_de_ambiente() explica no idioma dele.
    ERRO_PYAUTOGUI = (
        "no graphical session (DISPLAY is empty)"
        if SESSAO_GRAFICA == "nenhuma"
        else f"{type(_e).__name__}: {_e}"
    )
    pyautogui = _PyautoguiIndisponivel(ERRO_PYAUTOGUI)


# 3.0: no Wayland, mouse e teclado pelo ydotool (1.0+, com o ydotoold
# rodando), que alcança qualquer janela. Sem ele, fica o pyautogui (só
# janelas XWayland). TARS_YDOTOOL=0 desliga. Ver tars_wayland.py.
import tars_wayland as wayland  # noqa: E402

ENTRADA_WAYLAND = None      # "ydotool" quando o mouse/teclado vão por ele
MOTIVO_SEM_YDOTOOL = None
if SESSAO_GRAFICA == "wayland" and os.environ.get("TARS_YDOTOOL", "1") not in ("0", "off", "no", "nao"):
    MOTIVO_SEM_YDOTOOL = wayland.motivo_indisponivel()
    if MOTIVO_SEM_YDOTOOL is None:
        try:
            _tamanho_tela = pyautogui.size()  # o XWayland sabe o tamanho da área de trabalho
        except Exception:
            _tamanho_tela = (1920, 1080)
        pyautogui = wayland.PyautoguiYdotool(_tamanho_tela)
        ERRO_PYAUTOGUI = None
        ENTRADA_WAYLAND = "ydotool"


# ============================================================
# CONFIGURAÇÃO
# ============================================================

VERSAO = "5.2"
# 3.0 Miller: o classificador Murph e o modo AUTO.
# 4.0 Endurance: o Gargantua (IA oficial); Endurance Neo: aviso de versão nova, tarefas longas.
# 4.5 Endurance: o Cooper (visão da tela), WSL e Linux sem interface gráfica.
# 4.6 Endurance: a versão mais leve (19 ferramentas; só Ollama; sem voz, anexos, OCR, desfazer);
#     o que saiu volta a ser considerado na 5.0.
# 5.0 Tesseract: janela nova em Qt (PySide6), igual ao mockup; começa pelo assistente de primeiro uso.
# 5.1 Tesseract: modos Lite/Base/Pro, Probes, terminal no nível das CLIs de IA, Gargantua Lite no setup, nuvem pela
#     conta do Ollama e pesquisa web pelo SearXNG.
# 5.2 Ranger: Windows, macOS e outros Linux; o assistente do sistema (lançador, ações de texto, arquivos e fotos);
#     Gargantua 2 retreinado com os dados da 5.2.
CODINOME = "Ranger"
VERSAO_EXIBIDA = f"{VERSAO} {CODINOME}"
# Endurance Neo: sobe a cada pacote publicado (AAAAMMDDNN). O aviso de versão
# nova compara com a REVISAO do tars.py do GitHub (tars_atualizacao.py).
REVISAO = 2026110101

# Endereço do servidor Ollama — respeita a variável de ambiente padrão
# do próprio Ollama (OLLAMA_HOST), pra funcionar sem editar o código
# em qualquer máquina onde o Ollama não esteja no endereço padrão
# (outra porta, outro host na rede, WSL, container etc.).
def _normalizar_ollama_host(valor):
    """Interpreta OLLAMA_HOST do mesmo jeito que o próprio Ollama: o
    valor pode vir sem esquema ("0.0.0.0", "192.168.0.10:11434"), sem
    porta, ou só com a porta (":11434"). Muita gente deixa
    OLLAMA_HOST=0.0.0.0 no ambiente pra expor o Ollama na rede — usado
    cru, isso virava a URL "0.0.0.0/api/chat" e nada funcionava."""

    s = (valor or "").strip()
    if not s:
        return "http://127.0.0.1:11434"

    if "://" in s:
        esquema, resto = s.split("://", 1)
        esquema = esquema.lower()
        porta_padrao = {"http": "80", "https": "443"}.get(esquema, "11434")
    else:
        esquema, resto = "http", s
        porta_padrao = "11434"

    hostporta, _, caminho = resto.partition("/")

    m = re.fullmatch(r"\[([^\]]*)\](?::(\d+))?", hostporta)  # IPv6: [::1]:11434
    if m:
        host, porta = m.group(1), m.group(2)
    elif hostporta.count(":") == 1:
        host, porta = hostporta.split(":")
    else:
        host, porta = hostporta, None

    # Endereços "escute em todas as interfaces" servem pro servidor,
    # não pra conectar: do lado do cliente, significam esta máquina.
    if host in ("", "0.0.0.0", "::"):
        host = "127.0.0.1"
    if ":" in host:
        host = f"[{host}]"

    url = f"{esquema}://{host}:{porta or porta_padrao}"
    if caminho.strip("/"):
        url += "/" + caminho.strip("/")
    return url


OLLAMA_HOST = _normalizar_ollama_host(os.environ.get("OLLAMA_HOST"))


def _ollama_responde(url, espera=0.8):
    try:
        return requests.get(f"{url}/api/version", timeout=espera).status_code == 200
    except Exception:
        return False


# 4.5: no WSL2 o Ollama costuma estar no Windows, e o "localhost" do Linux não
# o alcança (rede NAT). Sem OLLAMA_HOST definido e sem Ollama local, tenta o IP
# do Windows (servidor DNS e gateway do WSL). O Ollama do Windows precisa
# escutar fora do 127.0.0.1 (OLLAMA_HOST=0.0.0.0 lá).
OLLAMA_NO_WINDOWS = False
if EM_WSL and not os.environ.get("OLLAMA_HOST") and not _ollama_responde(OLLAMA_HOST):
    for _candidato in ambiente.candidatos_ollama_wsl():
        if _ollama_responde(_candidato):
            OLLAMA_HOST, OLLAMA_NO_WINDOWS = _candidato, True
            break

OLLAMA_URL = f"{OLLAMA_HOST}/api/chat"
OLLAMA_TAGS_URL = f"{OLLAMA_HOST}/api/tags"
OLLAMA_SHOW_URL = f"{OLLAMA_HOST}/api/show"
OLLAMA_GENERATE_URL = f"{OLLAMA_HOST}/api/generate"
OLLAMA_PS_URL = f"{OLLAMA_HOST}/api/ps"

# Quanto tempo cada modelo fica na memória (RAM/VRAM) depois do último uso: o
# Ollama o descarrega sozinho. Curto = PC fraco liberado mais cedo; o modelo
# que ficou parado não custa nada. Tudo é carregado só quando um pedido precisa.
#   KEEP_ALIVE        modelo da conversa (varia: TARS_KEEP_ALIVE=15m)
#   KEEP_ALIVE_VISAO  Cooper e modelos que só descrevem prints (TARS_KEEP_ALIVE_VISAO)
#   KEEP_ALIVE_AJUDANTE  IA leve de bastidor
# Aceita "30s", "5m", "1h", ou "0" (descarrega logo após cada resposta).
KEEP_ALIVE = os.environ.get("TARS_KEEP_ALIVE", "5m")
KEEP_ALIVE_VISAO = os.environ.get("TARS_KEEP_ALIVE_VISAO", "2m")
KEEP_ALIVE_AJUDANTE = os.environ.get("TARS_KEEP_ALIVE_AJUDANTE", "2m")
# TARS_PRECARGA=1 volta a carregar o ajudante e o Cooper ao abrir (PC forte, quer o 1º pedido mais rápido).
PRECARGA = os.environ.get("TARS_PRECARGA", "0").lower() in ("1", "on", "yes", "sim", "true")

# Usado só como último recurso, se o catálogo dinâmico (ver mais
# abaixo) vier vazio por algum motivo (Ollama offline, sem nenhum
# modelo baixado). O TARS não depende mais de uma lista fixa de
# modelos — ele descobre o que está instalado e como cada um se
# comporta consultando o próprio Ollama.
MODELO_PADRAO = os.environ.get("TARS_MODELO_PADRAO", "qwen3:0.6b")

# Neo: o classificador por IA (qwen2.5:0.5b) saiu; quem decide o tipo do
# pedido é a helper Murph. Os ajudantes das versões antigas, se ainda
# estiverem instalados, ficam fora da escolha de conversa.
class _Ajudantes(set):
    """Modelos que não entram na escolha de conversa: os ajudantes antigos e
    o Cooper (4.5), que só olha a tela."""

    def __contains__(self, tag):
        return set.__contains__(self, tag) or visao_lib.e_cooper(tag)


MODELOS_AJUDANTES = _Ajudantes({"qwen2.5:0.5b", "gemma3:270m"})

# ------------------------------------------------------------
# Timeouts de rede (segundos) — nomeados por propósito, não por valor,
# pra ficar claro o que cada chamada está esperando e por quê.
# ------------------------------------------------------------
TIMEOUT_HTTP_CURTO = 5          # checagens rápidas: /api/tags, /api/ps
TIMEOUT_HTTP_MODELO_INFO = 6    # /api/show de um modelo
TIMEOUT_HTTP_DESCARREGAR = 30   # pedido de unload (keep_alive=0)
TIMEOUT_HTTP_AJUDANTE = 20      # consulta pontual e curta a um modelo leve
TIMEOUT_HTTP_LONGO = 600        # carregar um modelo grande / resposta de conversa completa
TIMEOUT_TERMINAL_COMANDO = 30   # comando digitado pelo usuário/IA no terminal
TIMEOUT_SUBPROCESSO_PADRAO = 15 # subprocessos internos em geral (flatpak, etc.)
TIMEOUT_GPU = 5                 # nvidia-smi
TIMEOUT_FLATPAK_LISTAGEM = 10   # flatpak list
TIMEOUT_DNS = 3                 # checar se um domínio sugerido pela IA existe de fato


# ------------------------------------------------------------
# Temperaturas — cada uso tem um propósito diferente: a classificadora
# quer sempre a mesma resposta pro mesmo texto (determinístico), a
# conversa principal quer um pouco de variação natural.
# ------------------------------------------------------------
TEMPERATURA_CONVERSA = 0.1


def temperatura_conversa():
    """5.1: no modo Pro a pessoa pode trocar a temperatura (Configurações > Ajustes avançados)."""
    escolhida = modos.avancado()["temperatura"]
    return TEMPERATURA_CONVERSA if escolhida is None else escolhida


TEMPERATURA_AJUDANTE = 0.1

# TTL (segundos) do cache de apps instalados (.desktop / flatpak).
# Evita re-escanear o sistema de arquivos e chamar subprocess a cada comando.
CACHE_APPS_TTL = 30

# ------------------------------------------------------------
# Outros limites/ajustes numéricos usados pelo código — nomeados aqui
# em vez de espalhados como números soltos no meio da lógica.
# ------------------------------------------------------------
LIMITE_CICLOS_FERRAMENTAS = 20      # máximo de idas-e-voltas com tool calls por mensagem (clicar num app leva vários)
LIMITE_HISTORICO_MENSAGENS = 30     # mensagens guardadas por sessão antes de podar
LIMITE_STDOUT_CHARS = 8000          # truncamento do stdout do terminal
LIMITE_STDERR_CHARS = 4000          # truncamento do stderr do terminal
LIMITE_CANDIDATOS_APP = 5           # nomes de executável sugeridos pela IA por pedido
LARGURA_MINIMA_CAIXA = 44           # largura mínima das caixas de status no terminal

# Confirmação de que um app abriu: em vez de dormir um tempo fixo e só
# então checar uma vez (que ou demora demais pra apps rápidos, ou é
# curto demais pra apps pesados tipo navegador), faz polling — checa
# em intervalos curtos até confirmar o processo ativo ou estourar o
# teto. Isso deixa apps leves (calculadora, editor de texto) prontos
# quase na hora, e dá mais chance real de apps pesados estarem de pé
# antes do TARS devolver sucesso — importante pra tarefa composta tipo
# "abra X e digite Y", onde o type_text roda logo em seguida.
INTERVALO_POLL_PROCESSO_SEG = 0.15
TIMEOUT_POLL_PROCESSO_SEG = 2.0

# Automação de mouse/teclado — durações bem curtas de propósito: o
# usuário quer velocidade aqui, não uma animação suave de cursor.
# pyautogui.PAUSE (configurado logo abaixo) é o que mais pesava nisso:
# por padrão ele insere 0.1s de pausa DEPOIS de toda chamada (move,
# clique, tecla...), e numa tarefa com vários passos (abrir, mover,
# clicar, digitar, apertar tecla) isso somava meio segundo só de
# pausas artificiais.
DURACAO_MOVER_MOUSE_SEG = 0.03
DURACAO_MOVER_PARA_SCROLL_SEG = 0.02
INTERVALO_DIGITACAO_SEG = 0.005
PYAUTOGUI_PAUSE_SEG = 0.01

pyautogui.PAUSE = PYAUTOGUI_PAUSE_SEG
# FAILSAFE fica ligado de propósito: jogar o mouse pro canto da tela
# ainda aborta qualquer automação em andamento, e isso não tem custo
# de velocidade nenhum.

TENTATIVAS_ENCERRAR_IAS = 4
ESPERA_ENTRE_TENTATIVAS_ENCERRAR_SEG = 1.5

# Largura máxima (em pixels) do print enviado pro modelo de visão.
# Uma tela 4K sem redimensionar gera um PNG grande o bastante pra
# pesar de verdade na codificação em base64, no envio pro Ollama e no
# processamento da imagem pelo próprio modelo (que internamente já
# reduz a resolução de qualquer jeito) — encolher ANTES de mandar
# corta esse tempo todo sem perder legibilidade prática (texto de UI
# continua lendo bem nessa largura). Configurável por variável de
# ambiente pra quem quiser mais nitidez à custa de velocidade.
LARGURA_MAXIMA_SCREENSHOT = int(os.environ.get("TARS_LARGURA_SCREENSHOT", "1280"))

# Capacidades reconhecidas pelo Ollama que o TARS realmente usa pra
# decidir comportamento — mostradas na listagem de modelos e citadas
# no prompt da IA classificadora.
_CAPACIDADES_RELEVANTES = {"tools", "vision", "thinking"}


# ============================================================
# NÚCLEO EM PARTES (3.0)
# ============================================================
#
# O núcleo tinha 7.800 linhas num arquivo só. Agora cada assunto fica em
# nucleo/<parte>.py, executado aqui, na ordem, dentro deste namespace:
# pra quem usa (tars_gui, testes, "import tars") nada muda, tars.<nome>
# continua existindo, e trocar tars.<função> num teste vale em todas as
# partes. Os tracebacks apontam o arquivo e a linha da parte.

PASTA_NUCLEO = Path(__file__).resolve().parent / "nucleo"
PARTES_NUCLEO = (
    "registro",
    "ollama",
    "hardware",
    "aplicacoes",
    "controle",
    "janelas",
    "visao",
    "elementos",
    "ferramentas",
    "selecao",
    "prompt",
    "chat",
    "nuvem",
    "anexos",
    "memoria",
    "rotinas",
    "utilidades",
    "gargantua",
    "codigo",
    "web",
    "voz",
    "terminal",
)


def _carregar_nucleo():
    for parte in PARTES_NUCLEO:
        caminho = PASTA_NUCLEO / f"{parte}.py"
        codigo = compile(caminho.read_text(encoding="utf-8"), str(caminho), "exec")
        exec(codigo, globals())


_carregar_nucleo()


if __name__ == "__main__":
    main()