openTARS / app /tars.py
ProjectOpenTARS4's picture
Mirror of openTARS 5.2 Ranger (revision 2026110101)
2695555 verified
Raw History Blame Contribute Delete
17.6 kB
#!/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()