ProjectOpenTARS4 commited on
Commit
697ae7c
·
verified ·
1 Parent(s): 7b82088

openTARS 5.3 Tesseract

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +7 -5
  2. app/empacotamento/conferir.sh +3 -2
  3. app/empacotamento/debian/control +11 -15
  4. app/empacotamento/debian/postinst +2 -0
  5. app/empacotamento/doc/changelog +7 -0
  6. app/empacotamento/linux/instalar.sh +14 -7
  7. app/empacotamento/macos/instalar.sh +7 -6
  8. app/empacotamento/setup.sh +16 -7
  9. app/empacotamento/windows/instalar.ps1 +9 -8
  10. app/empacotamento/windows/opentars.iss +44 -0
  11. app/empacotamento/winget/gerar.sh +57 -0
  12. app/idiomas/en.json +156 -35
  13. app/idiomas/pt_BR.json +156 -35
  14. app/nucleo/aplicacoes.py +38 -0
  15. app/nucleo/chat.py +28 -3
  16. app/nucleo/controle.py +11 -1
  17. app/nucleo/elementos.py +10 -0
  18. app/nucleo/gargantua.py +15 -8
  19. app/nucleo/hardware.py +5 -5
  20. app/nucleo/nuvem.py +1 -1
  21. app/nucleo/ollama.py +23 -4
  22. app/nucleo/registro.py +5 -3
  23. app/nucleo/selecao.py +2 -2
  24. app/nucleo/terminal.py +25 -27
  25. app/nucleo/visao.py +1 -1
  26. app/nucleo/visual.py +165 -0
  27. app/nucleo/voz.py +2 -2
  28. app/tars.py +19 -18
  29. app/tars_acessibilidade.py +51 -3
  30. app/tars_acoes_texto.py +11 -2
  31. app/tars_anexos.py +0 -43
  32. app/tars_assistente.py +2 -1
  33. app/tars_atalhos.py +316 -0
  34. app/tars_atalhos_textos.py +114 -0
  35. app/tars_atualizacao.py +44 -9
  36. app/tars_autoteste.py +1 -1
  37. app/tars_busca_arquivos.py +142 -1
  38. app/tars_confianca.py +5 -1
  39. app/tars_conta.py +58 -0
  40. app/tars_email.py +324 -0
  41. app/tars_gerenciar_modelos.py +4 -3
  42. app/tars_gui.py +0 -20
  43. app/tars_imagem.py +3 -2
  44. app/tars_integracoes.py +476 -0
  45. app/tars_lembretes.py +130 -0
  46. app/tars_mais_modelos.py +59 -8
  47. app/tars_midia.py +172 -0
  48. app/tars_modos.py +36 -25
  49. app/tars_motor.py +19 -0
  50. app/tars_motor_local.py +797 -0
README.md CHANGED
@@ -20,7 +20,7 @@ This repository is a **mirror** of the app for people who find openTARS here. Th
20
 
21
  ## What is here
22
 
23
- - `opentars_all.deb`: the Ubuntu/Debian package (version 5.2 "Ranger", revision 2026110101).
24
  - `app/`: the application source and the installers (`app/empacotamento/`).
25
  - `LICENSE`: MIT.
26
 
@@ -41,13 +41,15 @@ curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/
41
  Ubuntu, Debian and derivatives:
42
 
43
  ```bash
44
- wget -O /tmp/opentars.deb https://github.com/project-opentars/OpenTARS/raw/main/opentars_all.deb && sudo apt install -y --reinstall /tmp/opentars.deb
45
  ```
46
 
47
- The Windows and macOS installers are still early and have only been tested in automated runs and on one Windows laptop. Read the script before running it: the install commands above download and run code from GitHub.
48
 
49
  ## Models
50
 
51
- - [Gargantua 2](https://huggingface.co/OpenTARS/Gargantua-2): the main model (2B, with vision).
52
- - [Gargantua 2 Lite](https://huggingface.co/OpenTARS/Gargantua-2-Lite): the 0.8B model that runs on the CPU.
 
 
53
  - [Murph](https://huggingface.co/OpenTARS/Murph): the tiny request classifier built into the app.
 
20
 
21
  ## What is here
22
 
23
+ - `opentars_all.deb`: the Ubuntu/Debian package (version 5.3 "Tesseract", revision 2026111301).
24
  - `app/`: the application source and the installers (`app/empacotamento/`).
25
  - `LICENSE`: MIT.
26
 
 
41
  Ubuntu, Debian and derivatives:
42
 
43
  ```bash
44
+ wget -O /tmp/opentars.deb https://github.com/project-opentars/OpenTARS/releases/latest/download/opentars_all.deb && sudo apt install -y --reinstall /tmp/opentars.deb
45
  ```
46
 
47
+ The app downloads its engine (llama.cpp) and Gargantua 2.5 the first time it opens, each checked against a fixed SHA-256. The install commands above download and run code from GitHub; read the script first if you like. How to verify the package: https://project-opentars.github.io/OpenTARS/verify.html
48
 
49
  ## Models
50
 
51
+ - [Gargantua 2.5](https://huggingface.co/OpenTARS/Gargantua-2.5): the main model (Gemma 4 E4B, with vision and audio).
52
+ - [Gargantua Lite Gen 3](https://huggingface.co/OpenTARS/Gargantua-2.5-Lite): the smaller model (Gemma 4 E2B).
53
+ - [Gargantua Pro Gen 1](https://huggingface.co/OpenTARS/Gargantua-Pro): the largest model, for missions in a virtual machine (Gemma 4 12B).
54
+ - [Gargantua Code Gen 2](https://huggingface.co/OpenTARS/Gargantua-Code-Gen2): for programming.
55
  - [Murph](https://huggingface.co/OpenTARS/Murph): the tiny request classifier built into the app.
app/empacotamento/conferir.sh CHANGED
@@ -42,8 +42,9 @@ fi
42
 
43
  echo "== 4/4 REVISAO"
44
  nova=$(grep -oP '^REVISAO = \K\d+' tars.py)
45
- publicado="../../opentars_all.deb"
46
- if [ -f "$publicado" ]; then
 
47
  antiga=$(dpkg-deb -f "$publicado" Version 2>/dev/null | grep -oP '\+\K\d+$' || echo 0)
48
  if [ "${nova:-0}" -le "${antiga:-0}" ]; then
49
  problemas+=("REVISAO $nova não é maior que a do opentars_all.deb da entrega ($antiga): suba em tars.py")
 
42
 
43
  echo "== 4/4 REVISAO"
44
  nova=$(grep -oP '^REVISAO = \K\d+' tars.py)
45
+ # desde 12/11 os pacotes ficam em pacotes/atual (o mais novo do app é a entrega a superar)
46
+ publicado=$(ls -t ../../pacotes/atual/opentars_*_all.deb ../../opentars_all.deb 2>/dev/null | head -1)
47
+ if [ -n "$publicado" ] && [ -f "$publicado" ]; then
48
  antiga=$(dpkg-deb -f "$publicado" Version 2>/dev/null | grep -oP '\+\K\d+$' || echo 0)
49
  if [ "${nova:-0}" -le "${antiga:-0}" ]; then
50
  problemas+=("REVISAO $nova não é maior que a do opentars_all.deb da entrega ($antiga): suba em tars.py")
app/empacotamento/debian/control CHANGED
@@ -12,19 +12,15 @@ Provides: tars
12
  Installed-Size: @TAMANHO@
13
  Maintainer: Enzo <enzorcasao@gmail.com>
14
  Homepage: https://github.com/project-opentars/OpenTARS
15
- Description: 100% local AI assistant for the Linux desktop
16
- openTARS is an AI assistant that runs entirely on your machine (through
17
- Ollama), with no cloud. It understands requests in Portuguese and English
18
- and controls the desktop: opens and closes
19
- apps, clicks buttons by name, types, searches the web, looks at the screen
20
- and runs terminal commands.
21
  .
22
- Its official AI is Gargantua (ollama pull ProjectOpenTARS/Gargantua), a
23
- small model trained to use openTARS tools, which the installer downloads.
24
- It also adapts to whatever AI models you already have in Ollama, picking
25
- the best one for each kind of task. Comes with a window ("opentars-gui"), a
26
- quick bar opened by a global shortcut ("opentars-gui --rapido") and a
27
- terminal mode ("opentars --chat") that also works on WSL and on Linux
28
- without a graphical session. Files (images, text, PDF...) can be attached
29
- to a request. The optional Cooper model (ollama pull ProjectOpenTARS/cooper)
30
- helps it look at the screen.
 
12
  Installed-Size: @TAMANHO@
13
  Maintainer: Enzo <enzorcasao@gmail.com>
14
  Homepage: https://github.com/project-opentars/OpenTARS
15
+ Description: local AI assistant for your desktop
16
+ openTARS is an AI assistant that runs on your own machine. It understands
17
+ requests in English and Portuguese and works on the desktop: opens and
18
+ closes apps, clicks buttons by name, types, searches the web, looks at the
19
+ screen and runs terminal commands, asking before anything risky.
 
20
  .
21
+ Its official AI is Gargantua 2.5 (Gemma 4), trained to use the openTARS
22
+ tools, which sees images and understands speech. It runs on the llama.cpp
23
+ engine, downloaded with the models the first time the app opens. Comes with
24
+ a window ("opentars-gui"), a quick bar opened by a global shortcut, text
25
+ actions in any app and a terminal mode ("opentars --chat"). Local by
26
+ default; cloud models are optional and use your own key.
 
 
 
app/empacotamento/debian/postinst CHANGED
@@ -19,6 +19,8 @@ case "$1" in
19
  fi
20
 
21
  bash /usr/share/opentars/setup.sh || true
 
 
22
 
23
  if command -v update-desktop-database >/dev/null 2>&1; then
24
  update-desktop-database -q /usr/share/applications || true
 
19
  fi
20
 
21
  bash /usr/share/opentars/setup.sh || true
22
+ # sem isto cada abertura recompila tudo: quem usa o app não escreve em /usr/share (-12% ao abrir, medido em 06/11)
23
+ /usr/share/opentars/venv/bin/python3 -m compileall -q -x /venv/ /usr/share/opentars || true
24
 
25
  if command -v update-desktop-database >/dev/null 2>&1; then
26
  update-desktop-database -q /usr/share/applications || true
app/empacotamento/doc/changelog CHANGED
@@ -1,3 +1,10 @@
 
 
 
 
 
 
 
1
  opentars (5.2) unstable; urgency=medium
2
 
3
  5.2 Ranger
 
1
+ opentars (5.3) unstable; urgency=medium
2
+
3
+ 5.3 Tesseract
4
+ * Em preparo (ainda não publicado): a API do motor mora em tars_motor.py, para a troca do Ollama
5
+ pelo llama.cpp ter um lugar só; Gargantua Pro e Pi reconhecidos como variantes, com o Pi fora
6
+ da conversa do app grande (ele tem contrato próprio); Gargantua Code Gen 2 em "Mais modelos".
7
+
8
  opentars (5.2) unstable; urgency=medium
9
 
10
  5.2 Ranger
app/empacotamento/linux/instalar.sh CHANGED
@@ -6,9 +6,8 @@
6
  #
7
  # O que faz: instala os pacotes do sistema que o openTARS usa pelo gerenciador da distribuição (dnf, pacman, zypper ou
8
  # apt; é a única parte que pede a senha), baixa o código do app (pasta app/ do repositório) para a pasta do usuário, cria
9
- # um ambiente Python com o Python do sistema (que enxerga o PyGObject e o tkinter da distribuição), instala o Ollama pelo
10
- # instalador oficial se faltar, e cria os comandos opentars e opentars-gui em ~/.local/bin e o atalho no menu. O modelo
11
- # (Gargantua 2) é baixado na primeira abertura, com barra. Rodar de novo atualiza. Remover: "| bash -s -- --remover".
12
  # Suas conversas e configurações (em ~/.config/opentars e ~/.local/share/opentars) ficam; apague as pastas se quiser.
13
  set -euo pipefail
14
 
@@ -22,7 +21,7 @@ ICONE="$DADOS/icons/hicolor/scalable/apps/opentars.svg"
22
  INICIO="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
23
  ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
24
  FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
25
- SEM_OLLAMA=0 # --sem-ollama: não instala o Ollama (o CI usa)
26
  SEM_SISTEMA=0 # --sem-sistema: não instala pacotes do sistema (quem já instalou à mão, ou sem sudo)
27
  REMOVER=0
28
  while [ $# -gt 0 ]; do
@@ -30,6 +29,7 @@ while [ $# -gt 0 ]; do
30
  --remover) REMOVER=1 ;;
31
  --fonte) FONTE="$2"; shift ;;
32
  --sem-ollama) SEM_OLLAMA=1 ;;
 
33
  --sem-sistema) SEM_SISTEMA=1 ;;
34
  esac
35
  shift
@@ -129,7 +129,14 @@ if ! "$VENV/bin/python3" -c "import sys" >/dev/null 2>&1; then
129
  exit 1; }
130
  fi
131
  "$VENV/bin/python3" -m pip install --quiet --upgrade pip
132
- "$VENV/bin/python3" -m pip install --quiet requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime
 
 
 
 
 
 
 
133
 
134
  # 4. O Ollama, pelo instalador oficial (ele mesmo pede a senha). Sem ele o openTARS abre, mas não tem IA local.
135
  if [ "$SEM_OLLAMA" = 0 ]; then
@@ -165,8 +172,8 @@ sed "s|/usr/bin/opentars-gui|\"$BIN/opentars-gui\"|" "$TEMP/desktop/opentars.des
165
  cp "$APP/empacotamento/icone/opentars.svg" "$ICONE"
166
  command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database -q "$(dirname "$ATALHO")" || true
167
 
168
- dizer "Pronto. Abra o openTARS pelo menu de aplicativos (na primeira vez ele baixa o Gargantua 2, com barra)." \
169
- "Done. Open openTARS from the application menu (the first time it downloads Gargantua 2, with a progress bar)."
170
  case ":$PATH:" in *":$BIN:"*) ;; *) dizer "Os comandos ficaram em $BIN; para usar no terminal: export PATH=\"$BIN:\$PATH\"" \
171
  "The commands are in $BIN; to use them in a terminal: export PATH=\"$BIN:\$PATH\"" ;; esac
172
  dizer "Para conferir e medir: 'opentars --diagnostico' e 'opentars --prova'; mande os relatórios que eles salvam." \
 
6
  #
7
  # O que faz: instala os pacotes do sistema que o openTARS usa pelo gerenciador da distribuição (dnf, pacman, zypper ou
8
  # apt; é a única parte que pede a senha), baixa o código do app (pasta app/ do repositório) para a pasta do usuário, cria
9
+ # um ambiente Python com o Python do sistema (que enxerga o PyGObject e o tkinter da distribuição), e cria os comandos opentars e opentars-gui em ~/.local/bin e o atalho no menu. O motor llama.cpp e o
10
+ # Gargantua 2.5 vêm na primeira abertura (--com-ollama instala o Ollama também). Rodar de novo atualiza. Remover: "| bash -s -- --remover".
 
11
  # Suas conversas e configurações (em ~/.config/opentars e ~/.local/share/opentars) ficam; apague as pastas se quiser.
12
  set -euo pipefail
13
 
 
21
  INICIO="${XDG_CONFIG_HOME:-$HOME/.config}/autostart"
22
  ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
23
  FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
24
+ SEM_OLLAMA=1 # 5.3: o motor é o llama.cpp (o app baixa); --com-ollama instala o Ollama também; --sem-ollama fica aceito
25
  SEM_SISTEMA=0 # --sem-sistema: não instala pacotes do sistema (quem já instalou à mão, ou sem sudo)
26
  REMOVER=0
27
  while [ $# -gt 0 ]; do
 
29
  --remover) REMOVER=1 ;;
30
  --fonte) FONTE="$2"; shift ;;
31
  --sem-ollama) SEM_OLLAMA=1 ;;
32
+ --com-ollama) SEM_OLLAMA=0 ;;
33
  --sem-sistema) SEM_SISTEMA=1 ;;
34
  esac
35
  shift
 
129
  exit 1; }
130
  fi
131
  "$VENV/bin/python3" -m pip install --quiet --upgrade pip
132
+ "$VENV/bin/python3" -m pip install --quiet requests psutil pyautogui pillow PySide6-Essentials numpy
133
+ # 07/11: o Fedora passou ao Python 3.15 e o onnxruntime ainda não tem pacote para ele; sem ele o app roda, só as
134
+ # edições de foto com modelinho (tirar o fundo, aumentar, remover objeto) ficam de fora
135
+ "$VENV/bin/python3" -m pip install --quiet onnxruntime || dizer "Aviso: o onnxruntime não instala neste Python; tirar o fundo e as outras edições com modelinho ficam desligadas." \
136
+ "Warning: onnxruntime does not install on this Python; background removal and the other model-based photo edits are off."
137
+ # 08/11 (5.3): o cofre de senhas para o e-mail do assistente; sem ele o app roda, só o e-mail fica desligado
138
+ "$VENV/bin/python3" -m pip install --quiet keyring || dizer "Aviso: o keyring não instala neste Python; o e-mail do assistente fica desligado." \
139
+ "Warning: keyring does not install on this Python; the assistant's email stays off."
140
 
141
  # 4. O Ollama, pelo instalador oficial (ele mesmo pede a senha). Sem ele o openTARS abre, mas não tem IA local.
142
  if [ "$SEM_OLLAMA" = 0 ]; then
 
172
  cp "$APP/empacotamento/icone/opentars.svg" "$ICONE"
173
  command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database -q "$(dirname "$ATALHO")" || true
174
 
175
+ dizer "Pronto. Abra o openTARS pelo menu de aplicativos (na primeira vez ele baixa o Gargantua 2.5, com barra)." \
176
+ "Done. Open openTARS from the application menu (the first time it downloads Gargantua 2.5, with a progress bar)."
177
  case ":$PATH:" in *":$BIN:"*) ;; *) dizer "Os comandos ficaram em $BIN; para usar no terminal: export PATH=\"$BIN:\$PATH\"" \
178
  "The commands are in $BIN; to use them in a terminal: export PATH=\"$BIN:\$PATH\"" ;; esac
179
  dizer "Para conferir e medir: 'opentars --diagnostico' e 'opentars --prova'; mande os relatórios que eles salvam." \
app/empacotamento/macos/instalar.sh CHANGED
@@ -4,9 +4,9 @@
4
  # curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/macos/instalar.sh | bash
5
  #
6
  # O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (traz o Python), cria um ambiente Python com
7
- # as dependências, instala o Ollama se faltar (o app oficial, em ~/Applications), monta o openTARS.app em ~/Applications e
8
  # o assina ad-hoc (grátis, sem conta da Apple; um app montado no próprio Mac não ganha a marca de quarentena), e cria os
9
- # comandos opentars e opentars-gui em ~/.local/bin. O modelo (Gargantua 2) é baixado na primeira abertura, com barra.
10
  # Rodar de novo atualiza. Remover: o mesmo comando com "| bash -s -- --remover".
11
  # Suas conversas e configurações (em ~/Library/Application Support/opentars) ficam; apague a pasta se quiser.
12
  set -euo pipefail
@@ -18,13 +18,14 @@ BIN="$HOME/.local/bin"
18
  APP_MAC="$HOME/Applications/openTARS.app"
19
  ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
20
  FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
21
- SEM_OLLAMA=0 # --sem-ollama: não instala o Ollama (o CI usa)
22
  REMOVER=0
23
  while [ $# -gt 0 ]; do
24
  case "$1" in
25
  --remover) REMOVER=1 ;;
26
  --fonte) FONTE="$2"; shift ;;
27
  --sem-ollama) SEM_OLLAMA=1 ;;
 
28
  esac
29
  shift
30
  done
@@ -73,7 +74,7 @@ fi
73
  dizer "Preparando o Python e as dependências (alguns minutos na primeira vez)..." \
74
  "Setting up Python and the dependencies (a few minutes the first time)..."
75
  [ -x "$VENV/bin/python3" ] || "$UV" venv --python 3.12 "$VENV"
76
- "$UV" pip install --python "$VENV/bin/python3" requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime pyobjc-framework-ApplicationServices pip
77
 
78
  # 3. O Ollama (o app oficial). Sem ele o openTARS abre, mas não tem IA local.
79
  if [ "$SEM_OLLAMA" = 0 ]; then
@@ -118,8 +119,8 @@ printf '#!/bin/bash\nexec "%s/bin/python3" "%s/tars.py" "$@"\n' "$VENV" "$APP_CO
118
  printf '#!/bin/bash\ncd "%s" && exec "%s/bin/python3" -m tars_qt "$@"\n' "$APP_CODIGO" "$VENV" > "$BIN/opentars-gui"
119
  chmod +x "$BIN/opentars" "$BIN/opentars-gui"
120
 
121
- dizer "Pronto. Abra o openTARS em ~/Applications (na primeira vez ele baixa o Gargantua 2, com barra)." \
122
- "Done. Open openTARS from ~/Applications (the first time it downloads Gargantua 2, with a progress bar)."
123
  dizer "O macOS vai pedir Acessibilidade e Gravação de Tela para o openTARS clicar e ver a tela: libere em Ajustes do Sistema > Privacidade e Segurança." \
124
  "macOS will ask for Accessibility and Screen Recording so openTARS can click and see the screen: allow it in System Settings > Privacy & Security."
125
  # o Terminal do macOS (zsh) não tem ~/.local/bin no PATH: sem isto, "opentars" dá "command not found". Uma linha marcada,
 
4
  # curl -fsSL https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/macos/instalar.sh | bash
5
  #
6
  # O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (traz o Python), cria um ambiente Python com
7
+ # as dependências, monta o openTARS.app em ~/Applications e
8
  # o assina ad-hoc (grátis, sem conta da Apple; um app montado no próprio Mac não ganha a marca de quarentena), e cria os
9
+ # comandos opentars e opentars-gui em ~/.local/bin. O motor llama.cpp e o Gargantua 2.5 vêm na primeira abertura.
10
  # Rodar de novo atualiza. Remover: o mesmo comando com "| bash -s -- --remover".
11
  # Suas conversas e configurações (em ~/Library/Application Support/opentars) ficam; apague a pasta se quiser.
12
  set -euo pipefail
 
18
  APP_MAC="$HOME/Applications/openTARS.app"
19
  ZIP="https://github.com/project-opentars/OpenTARS/archive/refs/heads/main.zip"
20
  FONTE="" # --fonte PASTA: a pasta app/ local em vez de baixar (o CI usa)
21
+ SEM_OLLAMA=1 # 5.3: o motor é o llama.cpp (o app baixa); --com-ollama instala o Ollama também; --sem-ollama fica aceito
22
  REMOVER=0
23
  while [ $# -gt 0 ]; do
24
  case "$1" in
25
  --remover) REMOVER=1 ;;
26
  --fonte) FONTE="$2"; shift ;;
27
  --sem-ollama) SEM_OLLAMA=1 ;;
28
+ --com-ollama) SEM_OLLAMA=0 ;;
29
  esac
30
  shift
31
  done
 
74
  dizer "Preparando o Python e as dependências (alguns minutos na primeira vez)..." \
75
  "Setting up Python and the dependencies (a few minutes the first time)..."
76
  [ -x "$VENV/bin/python3" ] || "$UV" venv --python 3.12 "$VENV"
77
+ "$UV" pip install --python "$VENV/bin/python3" requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime keyring pyobjc-framework-ApplicationServices pip
78
 
79
  # 3. O Ollama (o app oficial). Sem ele o openTARS abre, mas não tem IA local.
80
  if [ "$SEM_OLLAMA" = 0 ]; then
 
119
  printf '#!/bin/bash\ncd "%s" && exec "%s/bin/python3" -m tars_qt "$@"\n' "$APP_CODIGO" "$VENV" > "$BIN/opentars-gui"
120
  chmod +x "$BIN/opentars" "$BIN/opentars-gui"
121
 
122
+ dizer "Pronto. Abra o openTARS em ~/Applications (na primeira vez ele baixa o Gargantua 2.5, com barra)." \
123
+ "Done. Open openTARS from ~/Applications (the first time it downloads Gargantua 2.5, with a progress bar)."
124
  dizer "O macOS vai pedir Acessibilidade e Gravação de Tela para o openTARS clicar e ver a tela: libere em Ajustes do Sistema > Privacidade e Segurança." \
125
  "macOS will ask for Accessibility and Screen Recording so openTARS can click and see the screen: allow it in System Settings > Privacy & Security."
126
  # o Terminal do macOS (zsh) não tem ~/.local/bin no PATH: sem isto, "opentars" dá "command not found". Uma linha marcada,
app/empacotamento/setup.sh CHANGED
@@ -20,7 +20,7 @@ MODELO_AJUDANTE="qwen2.5:0.5b"
20
  MODELO_AJUDANTE_ANTIGO="gemma3:270m"
21
  DIR_OPENTARS="/usr/share/opentars"
22
  VENV="$DIR_OPENTARS/venv"
23
- DEPENDENCIAS=("requests:requests" "psutil:psutil" "pyautogui:pyautogui" "PIL:pillow" "PySide6:PySide6-Essentials" "numpy:numpy" "onnxruntime:onnxruntime")
24
 
25
  # Textos no idioma do sistema: vêm de idiomas/*.json, pelo mesmo módulo
26
  # que o openTARS usa (tars_i18n.py). Se algo falhar, aparecem as chaves
@@ -100,7 +100,15 @@ modelos_instalados() {
100
  # em Docker, via Snap...) conta como instalado: instalar outro por cima
101
  # criaria um segundo serviço brigando pela mesma porta.
102
  # ------------------------------------------------------------------
103
- if command -v ollama >/dev/null 2>&1; then
 
 
 
 
 
 
 
 
104
  msg "$(t ollama_ja)"
105
  elif ollama_responde; then
106
  msg "$(t ollama_existente url="$OLLAMA_URL")"
@@ -222,12 +230,13 @@ servico_pronto=0
222
  # Recém-instalado, o serviço leva um instante pra subir. Se o Ollama
223
  # existe mas o serviço está parado (sem systemd, por exemplo), tenta
224
  # iniciar.
225
- if ! ollama_responde && command -v systemctl >/dev/null 2>&1 \
226
  && systemctl list-unit-files ollama.service >/dev/null 2>&1; then
227
  systemctl start ollama >/dev/null 2>&1 || true
228
  fi
229
 
230
  for _ in $(seq 1 10); do
 
231
  if ollama_responde; then
232
  servico_pronto=1
233
  break
@@ -238,7 +247,7 @@ done
238
  # 3.0.5: nenhum modelo ajudante é baixado. Quem decide o tipo de cada
239
  # pedido é a helper Murph, que já vem no pacote (modelos/murph.json).
240
  msg "$(t helper_murph versao="1.0")"
241
- if [ "$servico_pronto" -eq 0 ]; then
242
  aviso "$(t ollama_nao_respondeu url="$OLLAMA_URL")"
243
  # 4.5: WSL (e contêiner) costuma não ter systemd: o serviço não sobe sozinho
244
  if em_wsl || ! command -v systemctl >/dev/null 2>&1; then
@@ -253,10 +262,10 @@ fi
253
  # Com ele instalado, o passo 4 não baixa outro modelo de conversa.
254
  # TARS_SEM_GARGANTUA=1 pula; TARS_MODELO_GARGANTUA=<nome> troca.
255
  # ------------------------------------------------------------------
256
- MODELO_GARGANTUA="${TARS_MODELO_GARGANTUA:-ProjectOpenTARS/Gargantua-2}"
257
 
258
  # 5.1: máquina fraca (sem placa NVIDIA com 4 GB E com até 6 GB de RAM OU menos de 4 threads) recebe o Gargantua Lite
259
- # (Qwen3.5 0,8B com visão, 0,6 GB, Gargantua-2:lite). TARS_GARGANTUA=lite|completo força. A mesma regra do primeiro uso (tars_primeiro_uso.py).
260
  maquina_fraca() {
261
  case "${TARS_GARGANTUA:-}" in lite) return 0 ;; completo) return 1 ;; esac
262
  local vram=0 ram threads
@@ -269,7 +278,7 @@ maquina_fraca() {
269
  [ "${ram:-99999}" -le 6200 ] || [ "${threads:-4}" -lt 4 ]
270
  }
271
  if [ -z "${TARS_MODELO_GARGANTUA:-}" ] && maquina_fraca; then
272
- MODELO_GARGANTUA="ProjectOpenTARS/Gargantua-2:lite"
273
  fi
274
 
275
  gargantua_instalado() {
 
20
  MODELO_AJUDANTE_ANTIGO="gemma3:270m"
21
  DIR_OPENTARS="/usr/share/opentars"
22
  VENV="$DIR_OPENTARS/venv"
23
+ DEPENDENCIAS=("requests:requests" "psutil:psutil" "pyautogui:pyautogui" "PIL:pillow" "PySide6:PySide6-Essentials" "numpy:numpy" "onnxruntime:onnxruntime" "keyring:keyring")
24
 
25
  # Textos no idioma do sistema: vêm de idiomas/*.json, pelo mesmo módulo
26
  # que o openTARS usa (tars_i18n.py). Se algo falhar, aparecem as chaves
 
100
  # em Docker, via Snap...) conta como instalado: instalar outro por cima
101
  # criaria um segundo serviço brigando pela mesma porta.
102
  # ------------------------------------------------------------------
103
+ # 5.3: o motor padrão é o llama.cpp, que o próprio app baixa no primeiro uso (sem senha), junto com o Gargantua 2.5.
104
+ # O Ollama só entra se a pessoa escolher: TARS_MOTOR=ollama, ou "motor": "ollama" na config (Configurações > Avançado).
105
+ USAR_OLLAMA=0
106
+ if [ "${TARS_MOTOR:-}" = "ollama" ] || grep -qs '"motor": *"ollama"' "$HOME/.config/opentars/config.json"; then
107
+ USAR_OLLAMA=1
108
+ fi
109
+ if [ "$USAR_OLLAMA" != 1 ]; then
110
+ msg "$(t motor_llamacpp)"
111
+ elif command -v ollama >/dev/null 2>&1; then
112
  msg "$(t ollama_ja)"
113
  elif ollama_responde; then
114
  msg "$(t ollama_existente url="$OLLAMA_URL")"
 
230
  # Recém-instalado, o serviço leva um instante pra subir. Se o Ollama
231
  # existe mas o serviço está parado (sem systemd, por exemplo), tenta
232
  # iniciar.
233
+ if [ "$USAR_OLLAMA" = 1 ] && ! ollama_responde && command -v systemctl >/dev/null 2>&1 \
234
  && systemctl list-unit-files ollama.service >/dev/null 2>&1; then
235
  systemctl start ollama >/dev/null 2>&1 || true
236
  fi
237
 
238
  for _ in $(seq 1 10); do
239
+ [ "$USAR_OLLAMA" = 1 ] || break # com o llama.cpp os modelos vêm pelo app (passos 3d e 4 ficam de fora)
240
  if ollama_responde; then
241
  servico_pronto=1
242
  break
 
247
  # 3.0.5: nenhum modelo ajudante é baixado. Quem decide o tipo de cada
248
  # pedido é a helper Murph, que já vem no pacote (modelos/murph.json).
249
  msg "$(t helper_murph versao="1.0")"
250
+ if [ "$servico_pronto" -eq 0 ] && [ "$USAR_OLLAMA" = 1 ]; then
251
  aviso "$(t ollama_nao_respondeu url="$OLLAMA_URL")"
252
  # 4.5: WSL (e contêiner) costuma não ter systemd: o serviço não sobe sozinho
253
  if em_wsl || ! command -v systemctl >/dev/null 2>&1; then
 
262
  # Com ele instalado, o passo 4 não baixa outro modelo de conversa.
263
  # TARS_SEM_GARGANTUA=1 pula; TARS_MODELO_GARGANTUA=<nome> troca.
264
  # ------------------------------------------------------------------
265
+ MODELO_GARGANTUA="${TARS_MODELO_GARGANTUA:-ProjectOpenTARS/Gargantua-2.5}"
266
 
267
  # 5.1: máquina fraca (sem placa NVIDIA com 4 GB E com até 6 GB de RAM OU menos de 4 threads) recebe o Gargantua Lite
268
+ # (Gemma 4 E2B com visão e áudio, 4,4 GB, Gargantua-2.5:lite). TARS_GARGANTUA=lite|completo força. A mesma regra do primeiro uso (tars_primeiro_uso.py).
269
  maquina_fraca() {
270
  case "${TARS_GARGANTUA:-}" in lite) return 0 ;; completo) return 1 ;; esac
271
  local vram=0 ram threads
 
278
  [ "${ram:-99999}" -le 6200 ] || [ "${threads:-4}" -lt 4 ]
279
  }
280
  if [ -z "${TARS_MODELO_GARGANTUA:-}" ] && maquina_fraca; then
281
+ MODELO_GARGANTUA="ProjectOpenTARS/Gargantua-2.5:lite"
282
  fi
283
 
284
  gargantua_instalado() {
app/empacotamento/windows/instalar.ps1 CHANGED
@@ -3,15 +3,16 @@
3
  # irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1 | iex
4
  #
5
  # O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (um programa que traz o Python), cria um
6
- # ambiente Python com as dependências, instala o Ollama se faltar (o instalador oficial, por usuário), cria o atalho
7
- # no Menu Iniciar e os comandos opentars e opentars-gui. O modelo (Gargantua 2) é baixado na primeira abertura, com barra.
8
  # Rodar de novo atualiza. Remover:
9
  # & ([scriptblock]::Create((irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1))) -Remover
10
  # Suas conversas e configurações (em %APPDATA%\opentars) ficam; apague a pasta se quiser.
11
  param(
12
  [switch]$Remover,
13
  [string]$Fonte = "", # pasta app/ local em vez de baixar (o CI usa)
14
- [switch]$SemOllama # não instala o Ollama (o CI usa)
 
15
  )
16
  $ErrorActionPreference = "Stop"
17
  $ProgressPreference = "SilentlyContinue" # a barra do Invoke-WebRequest deixa o download muito lento no 5.1
@@ -87,11 +88,11 @@ if (-not (Test-Path (Join-Path $Venv "Scripts\python.exe"))) {
87
  & $uvExe venv --python 3.12 $Venv
88
  if ($LASTEXITCODE) { throw "uv venv failed ($LASTEXITCODE)" }
89
  }
90
- & $uvExe pip install --python (Join-Path $Venv "Scripts\python.exe") requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime uiautomation pip
91
  if ($LASTEXITCODE) { throw "uv pip install failed ($LASTEXITCODE)" }
92
 
93
- # 3. O Ollama (por usuário). Sem ele o openTARS abre, mas não tem IA local.
94
- if (-not $SemOllama) {
95
  $ollama = Get-Command ollama -ErrorAction SilentlyContinue
96
  $ollamaExe = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
97
  if (-not $ollama -and -not (Test-Path $ollamaExe)) {
@@ -146,7 +147,7 @@ $atalho.Description = "openTARS"
146
  $atalho.IconLocation = (Join-Path $App "empacotamento\windows\opentars.ico") + ",0" # sem isto o atalho mostra o ícone do pythonw
147
  $atalho.Save()
148
 
149
- Dizer "Pronto. Abra o openTARS pelo Menu Iniciar (na primeira vez ele baixa o Gargantua 2, com barra)." `
150
- "Done. Open openTARS from the Start menu (the first time it downloads Gargantua 2, with a progress bar)."
151
  Dizer "Num terminal NOVO: 'opentars --diagnostico' confere tudo e 'opentars --prova' mede a IA; mande os relatórios que eles salvam." `
152
  "In a NEW terminal: 'opentars --diagnostico' checks everything and 'opentars --prova' measures the AI; send the reports they save."
 
3
  # irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1 | iex
4
  #
5
  # O que faz: baixa o código do app (pasta app/ do repositório), instala o uv (um programa que traz o Python), cria um
6
+ # ambiente Python com as dependências, cria o atalho
7
+ # no Menu Iniciar e os comandos opentars e opentars-gui. O motor llama.cpp e o modelo (Gargantua 2.5) são baixados na primeira abertura, com barra (-ComOllama instala o Ollama também).
8
  # Rodar de novo atualiza. Remover:
9
  # & ([scriptblock]::Create((irm https://raw.githubusercontent.com/project-opentars/OpenTARS/main/app/empacotamento/windows/instalar.ps1))) -Remover
10
  # Suas conversas e configurações (em %APPDATA%\opentars) ficam; apague a pasta se quiser.
11
  param(
12
  [switch]$Remover,
13
  [string]$Fonte = "", # pasta app/ local em vez de baixar (o CI usa)
14
+ [switch]$SemOllama, # aceito por compatibilidade (CI, setup.exe): desde a 5.3 o Ollama não entra por padrão
15
+ [switch]$ComOllama # 5.3: o motor é o llama.cpp (o app baixa); -ComOllama instala o Ollama também
16
  )
17
  $ErrorActionPreference = "Stop"
18
  $ProgressPreference = "SilentlyContinue" # a barra do Invoke-WebRequest deixa o download muito lento no 5.1
 
88
  & $uvExe venv --python 3.12 $Venv
89
  if ($LASTEXITCODE) { throw "uv venv failed ($LASTEXITCODE)" }
90
  }
91
+ & $uvExe pip install --python (Join-Path $Venv "Scripts\python.exe") requests psutil pyautogui pillow PySide6-Essentials numpy onnxruntime keyring uiautomation pip
92
  if ($LASTEXITCODE) { throw "uv pip install failed ($LASTEXITCODE)" }
93
 
94
+ # 3. O Ollama (por usuário), só com -ComOllama: desde a 5.3 o app usa o motor llama.cpp, que ele mesmo baixa.
95
+ if ($ComOllama -and -not $SemOllama) {
96
  $ollama = Get-Command ollama -ErrorAction SilentlyContinue
97
  $ollamaExe = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe"
98
  if (-not $ollama -and -not (Test-Path $ollamaExe)) {
 
147
  $atalho.IconLocation = (Join-Path $App "empacotamento\windows\opentars.ico") + ",0" # sem isto o atalho mostra o ícone do pythonw
148
  $atalho.Save()
149
 
150
+ Dizer "Pronto. Abra o openTARS pelo Menu Iniciar (na primeira vez ele baixa o Gargantua 2.5, com barra)." `
151
+ "Done. Open openTARS from the Start menu (the first time it downloads Gargantua 2.5, with a progress bar)."
152
  Dizer "Num terminal NOVO: 'opentars --diagnostico' confere tudo e 'opentars --prova' mede a IA; mande os relatórios que eles salvam." `
153
  "In a NEW terminal: 'opentars --diagnostico' checks everything and 'opentars --prova' measures the AI; send the reports they save."
app/empacotamento/windows/opentars.iss ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ; Instalador .exe do openTARS para Windows: só embrulha o instalar.ps1 (que continua sendo a fonte de verdade), para o winget
2
+ ; ter um instalador de verdade e para o openTARS aparecer em "Aplicativos instalados". Sem administrador, sem assinatura paga.
3
+ ; Compilar (Windows, Inno Setup 6): iscc /DVersao=5.3 windows\opentars.iss (a saída vai para dist\opentars-setup.exe)
4
+ #ifndef Versao
5
+ #define Versao "0.0"
6
+ #endif
7
+ [Setup]
8
+ AppId={{C6BAED4F-7BE2-421A-A5D8-3516502824C6}
9
+ AppName=openTARS
10
+ UninstallDisplayName=openTARS
11
+ AppVersion={#Versao}
12
+ AppPublisher=project-opentars
13
+ AppPublisherURL=https://project-opentars.github.io/OpenTARS/
14
+ AppSupportURL=https://github.com/project-opentars/OpenTARS/issues
15
+ DefaultDirName={localappdata}\openTARS-setup
16
+ DisableDirPage=yes
17
+ DisableProgramGroupPage=yes
18
+ PrivilegesRequired=lowest
19
+ OutputDir=..\..\dist
20
+ OutputBaseFilename=opentars-setup
21
+ SetupIconFile=opentars.ico
22
+ UninstallDisplayIcon={localappdata}\openTARS\app\empacotamento\windows\opentars.ico
23
+ Compression=lzma2
24
+ SolidCompression=yes
25
+ WizardStyle=modern
26
+ ArchitecturesInstallIn64BitMode=x64compatible
27
+
28
+ [Files]
29
+ Source: "instalar.ps1"; DestDir: "{app}"; Flags: ignoreversion
30
+ Source: "..\..\*"; DestDir: "{app}\app"; Excludes: "tests,gargantua,docs,dist,__pycache__,*.pyc,.git"; Flags: ignoreversion recursesubdirs createallsubdirs
31
+
32
+ [Run]
33
+ ; o instalar.ps1 faz o resto (Python pelo uv, dependências, atalhos, PATH); com /SemOllama o CI pula o download do Ollama
34
+ Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\instalar.ps1"" -Fonte ""{app}\app""{code:SemOllama}"; \
35
+ StatusMsg: "Instalando o openTARS (alguns minutos na primeira vez)..."; Flags: runhidden waituntilterminated
36
+
37
+ [UninstallRun]
38
+ Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\instalar.ps1"" -Remover"; Flags: runhidden waituntilterminated; RunOnceId: "RemoverOpenTARS"
39
+
40
+ [Code]
41
+ function SemOllama(Param: String): String;
42
+ begin
43
+ if WizardSilent and (ExpandConstant('{param:SemOllama|0}') = '1') then Result := ' -SemOllama' else Result := '';
44
+ end;
app/empacotamento/winget/gerar.sh ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Gera os 3 manifestos do winget para uma Release do openTARS.
3
+ # bash gerar.sh 5.3 CAMINHO/opentars-setup.exe [saida]
4
+ # Depois: copiar a pasta para manifests/p/project-opentars/openTARS/<versao>/ de um fork de microsoft/winget-pkgs e abrir o PR.
5
+ set -euo pipefail
6
+ VERSAO="$1"; EXE="$2"; SAIDA="${3:-saida/$VERSAO}"
7
+ SHA=$(sha256sum "$EXE" | cut -d' ' -f1 | tr a-f A-F)
8
+ URL="https://github.com/project-opentars/OpenTARS/releases/download/$VERSAO/opentars-setup.exe"
9
+ mkdir -p "$SAIDA"
10
+ P=project-opentars.openTARS
11
+ cat > "$SAIDA/$P.yaml" <<Y
12
+ # yaml-language-server: \$schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json
13
+ PackageIdentifier: $P
14
+ PackageVersion: $VERSAO
15
+ DefaultLocale: en-US
16
+ ManifestType: version
17
+ ManifestVersion: 1.6.0
18
+ Y
19
+ cat > "$SAIDA/$P.installer.yaml" <<Y
20
+ # yaml-language-server: \$schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json
21
+ PackageIdentifier: $P
22
+ PackageVersion: $VERSAO
23
+ InstallerType: inno
24
+ Scope: user
25
+ InstallModes: [interactive, silent, silentWithProgress]
26
+ InstallerSwitches:
27
+ Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
28
+ SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART
29
+ UpgradeBehavior: install
30
+ ReleaseDate: $(date +%F)
31
+ Installers:
32
+ - Architecture: x64
33
+ InstallerUrl: $URL
34
+ InstallerSha256: $SHA
35
+ ManifestType: installer
36
+ ManifestVersion: 1.6.0
37
+ Y
38
+ cat > "$SAIDA/$P.locale.en-US.yaml" <<Y
39
+ # yaml-language-server: \$schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json
40
+ PackageIdentifier: $P
41
+ PackageVersion: $VERSAO
42
+ PackageLocale: en-US
43
+ Publisher: project-opentars
44
+ PublisherUrl: https://project-opentars.github.io/OpenTARS/
45
+ PublisherSupportUrl: https://github.com/project-opentars/OpenTARS/issues
46
+ PackageName: openTARS
47
+ PackageUrl: https://project-opentars.github.io/OpenTARS/
48
+ License: MIT
49
+ LicenseUrl: https://github.com/project-opentars/OpenTARS/blob/main/LICENSE
50
+ ShortDescription: A 100% local AI assistant for your computer, local by default.
51
+ Description: openTARS runs small AI models on your own machine (llama.cpp), controls apps, files and the screen with your approval, and keeps your data at home. Cloud is optional and off by default.
52
+ Moniker: opentars
53
+ Tags: [ai, assistant, local, llm, privacy, llama-cpp]
54
+ ManifestType: defaultLocale
55
+ ManifestVersion: 1.6.0
56
+ Y
57
+ echo "Manifestos em $SAIDA (SHA-256 $SHA)"
app/idiomas/en.json CHANGED
@@ -466,7 +466,7 @@
466
  "term.modo_auto_explica": "openTARS picks the best AI for each task by itself.",
467
  "term.voce": "You",
468
  "term.conversa_carregada": "previous conversation: {n} request(s) · /clear starts over",
469
- "term.ajuda": "openTARS {versao} · local AI assistant for Linux\n\nChat\n opentars open the chat in the terminal (continues the last one)\n opentars --new start a new conversation\n opentars --resume pick a saved conversation to continue\n opentars --plan plan mode: the AI only describes what it would do\n opentars --auto edits in this folder without asking (delete, send and install still ask)\n opentars --chat \"request\" answer one request and exit\n opentars -p \"request\" for scripts: only the answer (or --output-format json); reads stdin\n opentars-gui open the window\n opentars-gui --rapido open the quick bar (ctrl+alt+space)\n\nModels\n opentars --model <name> use this model with -p\n opentars --explicar \"request\" show how the task and the model are chosen\n opentars --avaliar-classificador measure how often Murph and automatic mode are right\n\nEnvironment\n opentars --diagnostico check Ollama, screen and accessibility (changes nothing)\n opentars --autoteste diagnosis + a real test with the calculator\n opentars --setup install whatever is missing (Ollama, models...)\n\nPreferences\n opentars --idioma <code> change the language (pt_BR or en)\n opentars --atalho [keys|off] show, change or remove the global shortcut\n opentars --version show the version\n\nInside the chat, /help lists the commands. An AGENTS.md in the folder goes into every conversation.",
470
  "idioma.lista_titulo": "Available languages:",
471
  "idioma.como_trocar": "Change it with: /language <code> (e.g. /language pt_BR)",
472
  "idioma.trocado": "Language: {nome}",
@@ -687,8 +687,8 @@
687
  "gargantua.desligado": "{modelo} installed, but TARS_SEM_GARGANTUA=1: AUTO does not prefer it",
688
  "gargantua.nao_instalado": "Gargantua is not installed. To get the official openTARS AI: ollama pull {modelo}",
689
  "setup.gargantua_ja": "Gargantua, the official openTARS AI, is already installed ({modelo}).",
690
- "setup.gargantua_baixando": "Downloading Gargantua, the official openTARS AI ({modelo}, ~1.6 GB)...",
691
- "setup.gargantua_segundo_plano": "There is no terminal here to show progress (install from the app store or an automatic install): the Gargantua download (~1.6 GB) continues in the background. openTARS shows the progress when you open it.",
692
  "setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: Gargantua was not downloaded.",
693
  "msg.folego": "long task: {etapas} steps, {acoes} actions done; continuing",
694
  "versao.nova": "openTARS {versao} is out! To update, run the install command again (it's in the GitHub README).",
@@ -717,7 +717,7 @@
717
  "pu.slogan": "Your assistant that uses your computer, without leaving it.",
718
  "pu.passo": "Step {n} of {total}",
719
  "pu.passo.idioma": "Language",
720
- "pu.passo.ollama": "Ollama",
721
  "pu.passo.nuvem": "Cloud (optional)",
722
  "pu.passo.gargantua": "Download the models",
723
  "pu.passo.teste": "Quick test",
@@ -726,16 +726,16 @@
726
  "pu.pausar": "Pause",
727
  "pu.retomar": "Resume",
728
  "pu.tentar_de_novo": "Try again",
729
- "pu.ollama.titulo": "Looking for Ollama",
730
- "pu.ollama.texto": "Ollama is the program that runs the AI model on your computer. TARS needs it to work.",
731
- "pu.ollama.procurando": "Looking for Ollama on this computer…",
732
- "pu.ollama.ok": "Ollama found on this computer (version {versao})",
733
  "pu.ollama.parado": "Ollama is installed, but not running.",
734
- "pu.ollama.faltando": "Ollama is not installed yet.",
735
- "pu.ollama.instalar": "Install Ollama",
736
  "pu.ollama.iniciar": "Start Ollama",
737
  "pu.ollama.procurar_de_novo": "Look again",
738
- "pu.ollama.instalando": "Installing Ollama. A system window may ask for your password.",
739
  "pu.ollama.iniciando": "Starting Ollama…",
740
  "pu.ollama.erro_instalar": "I couldn't install it: {motivo}",
741
  "pu.ollama.abri_download": "I opened the Ollama download page in your browser. Install it, open Ollama and click Look again.",
@@ -983,6 +983,7 @@
983
  "cfg.voz.instalada": "Voice installed.",
984
  "cfg.voz.erro": "Couldn't install: {erro}",
985
  "cfg.voz.fala": "Speech",
 
986
  "cfg.voz.responder_falando": "Answer out loud",
987
  "cfg.voz.voz": "Voice",
988
  "cfg.voz.velocidade": "Speed",
@@ -1126,9 +1127,15 @@
1126
  "mascote.dica8": "Everything runs on your computer: nothing you write leaves it, unless you turn on the cloud.",
1127
  "mascote.dica9": "In Settings you can create routines: TARS does a task by itself at the time you choose.",
1128
  "mascote.dica10": "Type /modelo auto and TARS picks the best model for each request.",
1129
- "term.slogan": "local AI assistant for Linux",
1130
  "term.bem_vindo": "Welcome to openTARS!",
1131
  "term.rotulo_ollama": "ollama:",
 
 
 
 
 
 
1132
  "term.rotulo_modelo": "model:",
1133
  "term.ollama_online": "connected ({host})",
1134
  "term.ollama_offline": "offline ({host}) · start it with: sudo systemctl start ollama",
@@ -1137,18 +1144,12 @@
1137
  "term.modo_fixo": "pinned",
1138
  "term.parar": "ctrl+c to stop",
1139
  "term.ate_logo": "See you.",
1140
- "modo.lite.nome": "Lite",
1141
- "modo.lite.desc": "Just the essentials: chat, talk and attach files. Everything else is automatic.",
1142
  "modo.base.nome": "Base",
1143
- "modo.base.desc": "Good for most people: history, memory, voice, routines and Probes.",
1144
- "modo.pro.nome": "Pro",
1145
- "modo.pro.desc": "Everything in Base, plus advanced settings and diagnostics.",
1146
- "cfg.modo": "Mode",
1147
- "cfg.modo_sub": "How much of the window you want to see. Switch any time; nothing is deleted.",
1148
  "cfg.nav.avancado": "Advanced settings",
1149
  "cfg.nav.diagnostico": "Diagnostics",
1150
  "cfg.avancado.titulo": "Advanced settings",
1151
- "cfg.avancado.sub": "Only used in Pro mode. Changing these can make the AI worse: Gargantua was trained with the defaults.",
1152
  "cfg.av.automatico": "Automatic",
1153
  "cfg.av.temperatura": "Temperature",
1154
  "cfg.av.temperatura_sub": "How much the answer varies. Lower is more predictable (the default is 0.1).",
@@ -1159,9 +1160,14 @@
1159
  "cfg.av.ferramentas": "Tools",
1160
  "cfg.av.ferramentas_sub": "Untick what the AI cannot use. It still sees the tool, but openTARS refuses to run it.",
1161
  "cfg.av.salvar": "Save",
 
 
 
 
 
1162
  "cfg.av.salvo": "Saved.",
1163
  "cfg.av.restaurar": "Back to defaults",
1164
- "cfg.av.so_pro": "You are in {modo} mode: these settings are kept, but only used in Pro.",
1165
  "cfg.diag.titulo": "Diagnostics",
1166
  "cfg.diag.sub": "What openTARS did and whether the system is in order. Nothing here leaves your computer.",
1167
  "cfg.diag.log": "Tools used",
@@ -1172,11 +1178,8 @@
1172
  "cfg.diag.ambiente_sub": "Ollama, models, screen, microphone and permissions, the same as the opentars --diagnostico command.",
1173
  "cfg.diag.conferir": "Check now",
1174
  "cfg.diag.conferindo": "Checking…",
1175
- "pu.passo.modo": "Mode",
1176
- "pu.modo.titulo": "How much do you want to see?",
1177
- "pu.modo.texto": "Choose the window mode. You can change it later in Settings > General.",
1178
  "pu.garg.nome_lite": "Gargantua Lite",
1179
- "pu.garg.lite_nota": "This computer has no graphics card with 4 GB and little memory: we download Gargantua Lite (0.6 GB, with vision), which is lighter and makes a few more mistakes. It looks at the screen to find buttons, with less precision than Gargantua 2 (1.6 GB).",
1180
  "cmd.sempre": [
1181
  "always",
1182
  "a"
@@ -1214,7 +1217,7 @@
1214
  "probes.titulo": "Probes",
1215
  "probes.sub": "Small watchers that run in the background, even with the window closed, and tell you when something matters. They only look: nothing on your computer changes unless you choose it.",
1216
  "probes.servico": "On and off",
1217
- "probes.baixar_lite": "Download Gargantua Lite (0.6 GB, with vision)",
1218
  "probes.parar_tudo": "Stop all",
1219
  "probes.nova": "Create a Probe",
1220
  "probes.nova_sub": "Three steps: pick what to watch, fill in what it asks and check the sentence of what it will do.",
@@ -1422,16 +1425,15 @@
1422
  "probes.pp.missao": "Tells you when {quando}.",
1423
  "avisos.dica_sino": "Probe alerts: {n} new",
1424
  "pu.ollama_antigo": "This model needs a newer Ollama. Update it with: curl -fsSL https://ollama.com/install.sh | sh (and try again).",
1425
- "cooper.descontinuado": "Cooper was discontinued: Gargantua 2 already sees the screen. To install it: {comando}",
1426
- "cfg.gargantua_novo": "You have Gargantua 1 or the Lite. Gargantua 2 is openTARS's main AI: 1.6 GB, more accurate, and it sees the screen more precisely (Gargantua 1 cannot see). Worth downloading.",
1427
- "cfg.baixar_gargantua2": "Download Gargantua 2",
1428
  "msg.busca_nao_lida": "The search was opened in the browser, but the AI did not read the page: what it answered above comes from its own memory and may be out of date. Check the results in the browser.",
1429
  "msg.pouca_confianca": "I am not sure about this answer: it came from the model's memory and may be wrong or made up. Check a source before relying on it.",
1430
  "msg.verificando_fato": "Low confidence in the answer above: checking it with a web search.",
1431
  "msg.busca_nada_aberto": "Nothing was opened on your computer: the answer came from SearXNG. If it says it opened something in the browser, that did not happen.",
1432
  "visao.ok": "{modelo} (looks at the screen: finds buttons and describes windows)",
1433
- "visao.nao_instalado": "not installed. To let openTARS see the screen, download Gargantua 2: {comando}",
1434
- "visao.desligado": "{modelo} is installed but turned off (TARS_COOPER=0).",
1435
  "visao.sem_resposta": "The model that looks at the screen did not answer ({motivo}).",
1436
  "cfg.nav.modelos": "More models",
1437
  "cfg.modelos.titulo": "More models",
@@ -1440,9 +1442,9 @@
1440
  "modelinhos.baixando": "Downloading the photo models (~300 MB): remove the background, upscale, select by clicking and remove an object.",
1441
  "modelinhos.pronto": "The photo models are ready.",
1442
  "modelinhos.erro": "I couldn't download the photo models ({erro}). I'll try again next time openTARS opens.",
1443
- "lite.baixando": "Downloading Gargantua 2 Lite (0.6 GB), the AI that runs in the background for the assistant, text actions and Probes.",
1444
- "lite.pronto": "Gargantua 2 Lite is ready.",
1445
- "lite.erro": "I couldn't download Gargantua 2 Lite ({erro}). I'll try again next time openTARS opens.",
1446
  "cfg.bonsai.sub": "Makes images from a sentence (\"draw a lighthouse at dusk\"). By PrismML, Apache 2.0. Optional: {tamanho} and an NVIDIA card with 6 GB (or a Mac with Apple Silicon). Works offline once installed.",
1447
  "cfg.bonsai.instalar": "Install ({tamanho})",
1448
  "cfg.bonsai.remover": "Remove",
@@ -1523,6 +1525,11 @@
1523
  "acoes.resultado.resumir": "Summary",
1524
  "acoes.resultado.traduzir": "Translation",
1525
  "acoes.resultado.perguntar": "Answer",
 
 
 
 
 
1526
  "acoes.pergunta_dica": "Ask something about the selection",
1527
  "acoes.enviar_pergunta": "Send question",
1528
  "acoes.substituir": "Replace in the text",
@@ -1533,6 +1540,7 @@
1533
  "acoes.atalho_linux": "Ctrl+Alt+R",
1534
  "acoes.atalho_windows": "Ctrl+Alt+R",
1535
  "acoes.atalho_macos": "Ctrl+Option+R",
 
1536
  "acoes.trecho": "Selected text: \"{trecho}\"",
1537
  "acoes.cortado": "(only the first {n} characters are used)",
1538
  "acoes.sem_texto": "No text selected. Select something in any app and press the shortcut again.",
@@ -1557,6 +1565,34 @@
1557
  "cfg.assist.sistema_titulo": "Start with the system",
1558
  "cfg.assist.sistema": "Open openTARS hidden when I log in",
1559
  "cfg.assist.sistema_nota": "On by default: the shortcut answers right away from the start of the day and the icon stays near the clock. Untick it to keep openTARS from starting with the system.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
  "cfg.assist.acoes_titulo": "Text actions",
1561
  "cfg.assist.acoes_sub": "Rewrite, summarize, translate and ask about the selection. The selected text is only material: nothing written in it is run, and nothing leaves your computer.",
1562
  "cfg.assist.acoes_modelo": "Text actions model: {modelo}.",
@@ -1653,6 +1689,12 @@
1653
  "ctx.nota": "I only look at the name of the app in use and the window title. Nothing is recorded or sent. Turn it off in Settings > Assistant.",
1654
  "ctx.selecione": "Select the text you want to check and click Check again (or use the text actions shortcut).",
1655
  "cfg.assist.ctx_titulo": "Suggestions based on the app in use",
 
 
 
 
 
 
1656
  "cfg.assist.ctx": "Suggest help based on the app in use (for example, checking an email)",
1657
  "ctx.nota_config": "On by default. Every 4 seconds openTARS looks only at the name of the app and the title of the window in use, records and sends nothing, and suggests once per window.",
1658
  "arq.acao.foto": "Edit the photo",
@@ -1695,5 +1737,84 @@
1695
  "foto.salvar": "Save a copy: {nome}",
1696
  "foto.salvar_n": "Save {n} copies",
1697
  "foto.aviso": "Always a copy in the same folder: the original stays as it is. The copy leaves out the location and device data. Cropping, rotating and brightening are instant; removing the background, upscaling and removing an object use small models on this computer and take a few seconds.",
1698
- "foto.salvas": "Copy saved: {nomes}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1699
  }
 
466
  "term.modo_auto_explica": "openTARS picks the best AI for each task by itself.",
467
  "term.voce": "You",
468
  "term.conversa_carregada": "previous conversation: {n} request(s) · /clear starts over",
469
+ "term.ajuda": "openTARS {versao} · local AI assistant\n\nChat\n opentars open the chat in the terminal (continues the last one)\n opentars --new start a new conversation\n opentars --resume pick a saved conversation to continue\n opentars --plan plan mode: the AI only describes what it would do\n opentars --auto edits in this folder without asking (delete, send and install still ask)\n opentars --chat \"request\" answer one request and exit\n opentars -p \"request\" for scripts: only the answer (or --output-format json); reads stdin\n opentars-gui open the window\n opentars-gui --rapido open the quick bar (ctrl+alt+space)\n\nModels\n opentars --model <name> use this model with -p\n opentars --explicar \"request\" show how the task and the model are chosen\n opentars --avaliar-classificador measure how often Murph and automatic mode are right\n\nEnvironment\n opentars --diagnostico check the engine, screen and accessibility (changes nothing)\n opentars --autoteste diagnosis + a real test with the calculator\n opentars --setup install whatever is missing (engine, models...)\n opentars --missao-vm \"task\" run an agent mission inside a disposable virtual machine (needs the Pro model)\n\nPreferences\n opentars --idioma <code> change the language (pt_BR or en)\n opentars --atalho [keys|off] show, change or remove the global shortcut\n opentars --version show the version\n\nInside the chat, /help lists the commands. An AGENTS.md in the folder goes into every conversation.",
470
  "idioma.lista_titulo": "Available languages:",
471
  "idioma.como_trocar": "Change it with: /language <code> (e.g. /language pt_BR)",
472
  "idioma.trocado": "Language: {nome}",
 
687
  "gargantua.desligado": "{modelo} installed, but TARS_SEM_GARGANTUA=1: AUTO does not prefer it",
688
  "gargantua.nao_instalado": "Gargantua is not installed. To get the official openTARS AI: ollama pull {modelo}",
689
  "setup.gargantua_ja": "Gargantua, the official openTARS AI, is already installed ({modelo}).",
690
+ "setup.gargantua_baixando": "Downloading Gargantua, the official openTARS AI ({modelo}, ~6.4 GB)...",
691
+ "setup.gargantua_segundo_plano": "There is no terminal here to show progress (install from the app store or an automatic install): the Gargantua download (~6.4 GB) continues in the background. openTARS shows the progress when you open it.",
692
  "setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: Gargantua was not downloaded.",
693
  "msg.folego": "long task: {etapas} steps, {acoes} actions done; continuing",
694
  "versao.nova": "openTARS {versao} is out! To update, run the install command again (it's in the GitHub README).",
 
717
  "pu.slogan": "Your assistant that uses your computer, without leaving it.",
718
  "pu.passo": "Step {n} of {total}",
719
  "pu.passo.idioma": "Language",
720
+ "pu.passo.ollama": "AI engine",
721
  "pu.passo.nuvem": "Cloud (optional)",
722
  "pu.passo.gargantua": "Download the models",
723
  "pu.passo.teste": "Quick test",
 
726
  "pu.pausar": "Pause",
727
  "pu.retomar": "Resume",
728
  "pu.tentar_de_novo": "Try again",
729
+ "pu.ollama.titulo": "Setting up the AI engine",
730
+ "pu.ollama.texto": "The engine runs the AI model on your computer. openTARS uses llama.cpp; if you already have Ollama, that works too.",
731
+ "pu.ollama.procurando": "Looking for an AI engine on this computer…",
732
+ "pu.ollama.ok": "AI engine ready ({versao})",
733
  "pu.ollama.parado": "Ollama is installed, but not running.",
734
+ "pu.ollama.faltando": "The AI engine is missing. It's about 35 MB, no password needed.",
735
+ "pu.ollama.instalar": "Install the engine",
736
  "pu.ollama.iniciar": "Start Ollama",
737
  "pu.ollama.procurar_de_novo": "Look again",
738
+ "pu.ollama.instalando": "Downloading the engine (llama.cpp)…",
739
  "pu.ollama.iniciando": "Starting Ollama…",
740
  "pu.ollama.erro_instalar": "I couldn't install it: {motivo}",
741
  "pu.ollama.abri_download": "I opened the Ollama download page in your browser. Install it, open Ollama and click Look again.",
 
983
  "cfg.voz.instalada": "Voice installed.",
984
  "cfg.voz.erro": "Couldn't install: {erro}",
985
  "cfg.voz.fala": "Speech",
986
+ "cfg.voz.resposta_completa": "Read long answers in full (by default only the first sentences are spoken; the rest stays on the screen)",
987
  "cfg.voz.responder_falando": "Answer out loud",
988
  "cfg.voz.voz": "Voice",
989
  "cfg.voz.velocidade": "Speed",
 
1127
  "mascote.dica8": "Everything runs on your computer: nothing you write leaves it, unless you turn on the cloud.",
1128
  "mascote.dica9": "In Settings you can create routines: TARS does a task by itself at the time you choose.",
1129
  "mascote.dica10": "Type /modelo auto and TARS picks the best model for each request.",
1130
+ "term.slogan": "local AI assistant",
1131
  "term.bem_vindo": "Welcome to openTARS!",
1132
  "term.rotulo_ollama": "ollama:",
1133
+ "term.rotulo_motor": "engine:",
1134
+ "term.rotulo_pasta": "folder:",
1135
+ "term.motor_offline": "offline ({host}) · restart openTARS to start the engine",
1136
+ "cli.regua.perguntar": "ask",
1137
+ "cli.regua.plano": "plan",
1138
+ "cli.regua.automatico": "auto",
1139
  "term.rotulo_modelo": "model:",
1140
  "term.ollama_online": "connected ({host})",
1141
  "term.ollama_offline": "offline ({host}) · start it with: sudo systemctl start ollama",
 
1144
  "term.modo_fixo": "pinned",
1145
  "term.parar": "ctrl+c to stop",
1146
  "term.ate_logo": "See you.",
 
 
1147
  "modo.base.nome": "Base",
1148
+ "modo.base.desc": "Everything for every day: history, memory, voice, routines, Probes and cloud.",
 
 
 
 
1149
  "cfg.nav.avancado": "Advanced settings",
1150
  "cfg.nav.diagnostico": "Diagnostics",
1151
  "cfg.avancado.titulo": "Advanced settings",
1152
+ "cfg.avancado.sub": "Only used with dev mode on. Changing these can make the AI worse: Gargantua was trained with the defaults.",
1153
  "cfg.av.automatico": "Automatic",
1154
  "cfg.av.temperatura": "Temperature",
1155
  "cfg.av.temperatura_sub": "How much the answer varies. Lower is more predictable (the default is 0.1).",
 
1160
  "cfg.av.ferramentas": "Tools",
1161
  "cfg.av.ferramentas_sub": "Untick what the AI cannot use. It still sees the tool, but openTARS refuses to run it.",
1162
  "cfg.av.salvar": "Save",
1163
+ "cfg.av.motor": "AI engine",
1164
+ "cfg.av.motor_sub": "llama.cpp is the default. Switch to Ollama if you prefer to keep using it. Takes effect the next time openTARS starts.",
1165
+ "cfg.av.motor_llamacpp": "llama.cpp (default)",
1166
+ "cfg.av.motor_ollama": "Ollama",
1167
+ "cfg.av.motor_reiniciar": "Saved. Restart openTARS to switch the engine.",
1168
  "cfg.av.salvo": "Saved.",
1169
  "cfg.av.restaurar": "Back to defaults",
1170
+ "cfg.av.so_pro": "Dev mode is off: these settings are kept, but only used when it is on (Settings > General).",
1171
  "cfg.diag.titulo": "Diagnostics",
1172
  "cfg.diag.sub": "What openTARS did and whether the system is in order. Nothing here leaves your computer.",
1173
  "cfg.diag.log": "Tools used",
 
1178
  "cfg.diag.ambiente_sub": "Ollama, models, screen, microphone and permissions, the same as the opentars --diagnostico command.",
1179
  "cfg.diag.conferir": "Check now",
1180
  "cfg.diag.conferindo": "Checking…",
 
 
 
1181
  "pu.garg.nome_lite": "Gargantua Lite",
1182
+ "pu.garg.lite_nota": "This computer has no graphics card with 4 GB and little memory: we download Gargantua 2.5 Lite (4.4 GB, with vision and audio), which is lighter. It reads the screen but does not point to small buttons by position; clicking by name works through the app's accessibility.",
1183
  "cmd.sempre": [
1184
  "always",
1185
  "a"
 
1217
  "probes.titulo": "Probes",
1218
  "probes.sub": "Small watchers that run in the background, even with the window closed, and tell you when something matters. They only look: nothing on your computer changes unless you choose it.",
1219
  "probes.servico": "On and off",
1220
+ "probes.baixar_lite": "Download Gargantua 2.5 Lite (4.4 GB, with vision and audio)",
1221
  "probes.parar_tudo": "Stop all",
1222
  "probes.nova": "Create a Probe",
1223
  "probes.nova_sub": "Three steps: pick what to watch, fill in what it asks and check the sentence of what it will do.",
 
1425
  "probes.pp.missao": "Tells you when {quando}.",
1426
  "avisos.dica_sino": "Probe alerts: {n} new",
1427
  "pu.ollama_antigo": "This model needs a newer Ollama. Update it with: curl -fsSL https://ollama.com/install.sh | sh (and try again).",
1428
+ "cfg.gargantua_novo": "You have Gargantua 1 or an older model. Gargantua 2.5 is openTARS's main AI, on Gemma 4: 6.3 GB, with vision and audio support. Worth downloading.",
1429
+ "cfg.baixar_gargantua2": "Download Gargantua 2.5",
 
1430
  "msg.busca_nao_lida": "The search was opened in the browser, but the AI did not read the page: what it answered above comes from its own memory and may be out of date. Check the results in the browser.",
1431
  "msg.pouca_confianca": "I am not sure about this answer: it came from the model's memory and may be wrong or made up. Check a source before relying on it.",
1432
  "msg.verificando_fato": "Low confidence in the answer above: checking it with a web search.",
1433
  "msg.busca_nada_aberto": "Nothing was opened on your computer: the answer came from SearXNG. If it says it opened something in the browser, that did not happen.",
1434
  "visao.ok": "{modelo} (looks at the screen: finds buttons and describes windows)",
1435
+ "visao.nao_instalado": "not installed. To let openTARS see the screen, download Gargantua 2.5: {comando}",
1436
+ "visao.desligado": "{modelo} is installed but turned off (TARS_VISAO=0).",
1437
  "visao.sem_resposta": "The model that looks at the screen did not answer ({motivo}).",
1438
  "cfg.nav.modelos": "More models",
1439
  "cfg.modelos.titulo": "More models",
 
1442
  "modelinhos.baixando": "Downloading the photo models (~300 MB): remove the background, upscale, select by clicking and remove an object.",
1443
  "modelinhos.pronto": "The photo models are ready.",
1444
  "modelinhos.erro": "I couldn't download the photo models ({erro}). I'll try again next time openTARS opens.",
1445
+ "lite.baixando": "Downloading Gargantua 2.5 Lite (4.4 GB), the AI that runs in the background for the assistant, text actions and Probes.",
1446
+ "lite.pronto": "Gargantua 2.5 Lite is ready.",
1447
+ "lite.erro": "I couldn't download Gargantua 2.5 Lite ({erro}). I'll try again next time openTARS opens.",
1448
  "cfg.bonsai.sub": "Makes images from a sentence (\"draw a lighthouse at dusk\"). By PrismML, Apache 2.0. Optional: {tamanho} and an NVIDIA card with 6 GB (or a Mac with Apple Silicon). Works offline once installed.",
1449
  "cfg.bonsai.instalar": "Install ({tamanho})",
1450
  "cfg.bonsai.remover": "Remove",
 
1525
  "acoes.resultado.resumir": "Summary",
1526
  "acoes.resultado.traduzir": "Translation",
1527
  "acoes.resultado.perguntar": "Answer",
1528
+ "acoes.aba.responder": "Reply",
1529
+ "acoes.opcao.curto": "Shorter",
1530
+ "acoes.opcao.lista": "Bullet list",
1531
+ "acoes.resultado.responder": "Draft reply",
1532
+ "acoes.responder_dica": "What do you want to say? (e.g. I can make it tomorrow)",
1533
  "acoes.pergunta_dica": "Ask something about the selection",
1534
  "acoes.enviar_pergunta": "Send question",
1535
  "acoes.substituir": "Replace in the text",
 
1540
  "acoes.atalho_linux": "Ctrl+Alt+R",
1541
  "acoes.atalho_windows": "Ctrl+Alt+R",
1542
  "acoes.atalho_macos": "Ctrl+Option+R",
1543
+ "acoes.da_janela": "Text read from the window: {janela}",
1544
  "acoes.trecho": "Selected text: \"{trecho}\"",
1545
  "acoes.cortado": "(only the first {n} characters are used)",
1546
  "acoes.sem_texto": "No text selected. Select something in any app and press the shortcut again.",
 
1565
  "cfg.assist.sistema_titulo": "Start with the system",
1566
  "cfg.assist.sistema": "Open openTARS hidden when I log in",
1567
  "cfg.assist.sistema_nota": "On by default: the shortcut answers right away from the start of the day and the icon stays near the clock. Untick it to keep openTARS from starting with the system.",
1568
+ "cfg.assist.agenda_titulo": "Calendar",
1569
+ "cfg.assist.agenda_sub": "Ask \"what do I have tomorrow?\". Point to a .ics file or a calendar link (Google, Outlook and Apple can export one). Read only; off until you set it.",
1570
+ "cfg.assist.agenda_dica": "/path/to/calendar.ics or https://… (separate several with ;)",
1571
+ "cfg.assist.agenda_ligada": "On: reading {n} calendar(s). openTARS never changes your calendar.",
1572
+ "cfg.assist.agenda_nao_achei": "I can't find {fonte}: use a file that exists or a link that starts with https://",
1573
+ "cfg.assist.desligada": "Off.",
1574
+ "cfg.assist.notas_titulo": "Notes",
1575
+ "cfg.assist.notas_sub": "Say \"create a note: …\" or \"my notes about …\". Notes are Markdown files in the folder you choose; off until you set it.",
1576
+ "cfg.assist.notas_dica": "/path/to/Notes (Obsidian and Joplin folders work)",
1577
+ "cfg.assist.notas_ligada": "On: notes are saved and searched in this folder. A note is never written over another one.",
1578
+ "cfg.assist.salvar": "Save",
1579
+ "cfg.assist.email_titulo": "Email",
1580
+ "cfg.assist.email_sub": "Ask \"what email came in today?\", \"read the last email from Ana\" or \"send an email to ana@example.com saying…\". Reading never marks anything as read; sending always asks you first. Off until you set it.",
1581
+ "cfg.assist.email_usuario": "Email address",
1582
+ "cfg.assist.email_senha": "Password (Gmail and Outlook need an app password)",
1583
+ "cfg.assist.email_senha_guardada": "Password saved in the system keychain (type a new one to change it)",
1584
+ "cfg.assist.email_imap": "Incoming server (IMAP), blank for Gmail, Outlook, iCloud, Yahoo",
1585
+ "cfg.assist.email_smtp": "Outgoing server (SMTP), blank for the same providers",
1586
+ "cfg.assist.email_ligado": "On: {usuario}. The password stays in the system keychain, never in a file.",
1587
+ "cfg.assist.email_servidores": "I don't know this provider: fill in the IMAP and SMTP servers.",
1588
+ "cfg.assist.email_sem_senha": "Type the password.",
1589
+ "cfg.assist.email_sem_cofre": "This computer has no password keychain for openTARS (install the keyring package). I did not save anything.",
1590
+ "cfg.assist.email_testar": "Test",
1591
+ "cfg.assist.email_desligar": "Turn off and forget",
1592
+ "cfg.assist.email_testando": "Testing…",
1593
+ "cfg.assist.email_ok": "Connected. Nothing was changed in your mailbox.",
1594
+ "cfg.assist.email_erro": "It didn't work: {erro}",
1595
+ "cfg.assist.email_salvar_antes": "Save the account first.",
1596
  "cfg.assist.acoes_titulo": "Text actions",
1597
  "cfg.assist.acoes_sub": "Rewrite, summarize, translate and ask about the selection. The selected text is only material: nothing written in it is run, and nothing leaves your computer.",
1598
  "cfg.assist.acoes_modelo": "Text actions model: {modelo}.",
 
1689
  "ctx.nota": "I only look at the name of the app in use and the window title. Nothing is recorded or sent. Turn it off in Settings > Assistant.",
1690
  "ctx.selecione": "Select the text you want to check and click Check again (or use the text actions shortcut).",
1691
  "cfg.assist.ctx_titulo": "Suggestions based on the app in use",
1692
+ "cfg.assist.indice_titulo": "File search",
1693
+ "cfg.assist.indice": "Index my files so search is instant (off by default)",
1694
+ "cfg.assist.indice_nota": "The index keeps file names and the start of each text on this computer only, never sends them anywhere, and is erased when you turn this off.",
1695
+ "cfg.assist.indice_montando": "Building the index in the background…",
1696
+ "cfg.assist.indice_pronto": "Index ready: {n} files.",
1697
+ "cfg.assist.indice_erro": "The index failed: {erro}",
1698
  "cfg.assist.ctx": "Suggest help based on the app in use (for example, checking an email)",
1699
  "ctx.nota_config": "On by default. Every 4 seconds openTARS looks only at the name of the app and the title of the window in use, records and sends nothing, and suggests once per window.",
1700
  "arq.acao.foto": "Edit the photo",
 
1737
  "foto.salvar": "Save a copy: {nome}",
1738
  "foto.salvar_n": "Save {n} copies",
1739
  "foto.aviso": "Always a copy in the same folder: the original stays as it is. The copy leaves out the location and device data. Cropping, rotating and brightening are instant; removing the background, upscaling and removing an object use small models on this computer and take a few seconds.",
1740
+ "foto.salvas": "Copy saved: {nomes}.",
1741
+ "cfg.dev": "Dev mode",
1742
+ "cfg.dev_sub": "For people who want to adjust what is automatic.",
1743
+ "cfg.dev_ligar": "Show advanced settings and diagnostics",
1744
+ "modo.dev.nome": "Dev",
1745
+ "modo.dev.desc": "Everything in Base, plus advanced settings and diagnostics.",
1746
+ "motor.preparando": "Setting up the new engine (llama.cpp) in the background. You can keep using openTARS.",
1747
+ "motor.pronto": "New engine ready: openTARS now runs on llama.cpp.",
1748
+ "motor.erro": "Couldn't set up the new engine ({erro}). openTARS keeps running on Ollama.",
1749
+ "cfg.modelos.busca_ph": "Search models (name, vision, tools...)",
1750
+ "cfg.modelos.busca_web": "Search the Ollama library",
1751
+ "cfg.modelos.buscando": "Searching the Ollama library...",
1752
+ "cfg.modelos.busca_nada": "Nothing found in the Ollama library for this search.",
1753
+ "cfg.modelos.busca_erro": "Could not reach the Ollama library. Check your connection and try again.",
1754
+ "cfg.modelos.g.web": "From the Ollama library",
1755
+ "cfg.modelos.g.web_sub": "Results from ollama.com. The size shows when you click: it is checked before the download starts.",
1756
+ "cfg.modelos.calculando": "Checking the download size...",
1757
+ "cfg.assist.reabrir_titulo": "Apps that hide their buttons",
1758
+ "cfg.assist.reabrir": "Let openTARS reopen an app so it can click its buttons",
1759
+ "cfg.assist.reabrir_nota": "Chrome-based apps that were already open (Claude, Discord, VS Code, Slack, Chrome) hide their buttons from other programs. With this on, when openTARS needs to click in one of them, it closes the app and opens it again with accessibility on. Unsaved work in that app can be lost, and it never force-quits: if the app refuses to close, it stays open. Off by default.",
1760
+ "vm.cli.uso": "Usage: opentars --missao-vm \"what the agent should do inside the virtual machine\"",
1761
+ "vm.cli.falta": "The virtual machine is not ready: missing {falta}.",
1762
+ "vm.cli.sem_base": "The virtual machine image is not built yet.",
1763
+ "vm.cli.sem_pro": "This needs the Gargantua Pro model ({modelo}). Add it in Settings > More models.",
1764
+ "vm.cli.comecando": "Starting in a disposable virtual machine: {pedido}",
1765
+ "vm.cli.resultado": "Finished ({motivo}, {segundos} s): {resultado}",
1766
+ "vm.cli.acesso": " site visited: {acesso}",
1767
+ "vm.cli.arquivos": "The agent left {n} file(s) in the output folder: {lista}",
1768
+ "vm.cli.liberar": "Copy them to {pasta}? [y/N] ",
1769
+ "vm.cli.descartados": "Not copied. They were deleted with the virtual machine.",
1770
+ "vm.secao": "Missions in a virtual machine",
1771
+ "vm.secao_sub": "Gargantua Pro works on an isolated, disposable computer: it opens programs, browses and types. Nothing on your PC is visible inside, and whatever it makes only reaches you if you approve it.",
1772
+ "vm.abrir": "Open the VM window",
1773
+ "vm.so_linux": "For now, VM missions work only on Linux with KVM.",
1774
+ "vm.janela.titulo": "Mission in a virtual machine",
1775
+ "vm.janela.sub": "Write what the agent should do. It uses the mouse and keyboard of a disposable VM; when it finishes, the VM is deleted.",
1776
+ "vm.janela.falta": "The virtual machine is not ready: missing {falta}.",
1777
+ "vm.janela.sem_base": "The VM image does not exist yet. Building it takes about 5 minutes and uses about 1.6 GB.",
1778
+ "vm.janela.sem_pro": "VM missions use Gargantua Pro (about 7.6 GB).",
1779
+ "vm.janela.conferindo": "Checking the model...",
1780
+ "vm.janela.montar": "Build the image",
1781
+ "vm.janela.montando": "Building the VM image...",
1782
+ "vm.janela.montando_pct": "Downloading the VM system: {pct}%",
1783
+ "vm.janela.baixar_pro": "Download Gargantua Pro",
1784
+ "vm.janela.baixando": "Downloading Gargantua Pro...",
1785
+ "vm.janela.erro": "That did not work: {erro}",
1786
+ "vm.janela.pedido_ph": "E.g.: open the terminal and create notes.txt in the output folder with the shopping list",
1787
+ "vm.janela.comecar": "Start",
1788
+ "vm.janela.parar": "Stop",
1789
+ "vm.janela.internet": "Internet (every site is logged)",
1790
+ "vm.janela.sem_tela": "The VM screen shows up here.",
1791
+ "vm.janela.tela": "Virtual machine screen",
1792
+ "vm.janela.passos": "Steps",
1793
+ "vm.janela.sites": "Sites visited",
1794
+ "vm.janela.ligando": "Starting the VM...",
1795
+ "vm.janela.trabalhando": "The agent is working.",
1796
+ "vm.janela.parando": "Stopping...",
1797
+ "vm.janela.fim": "Finished ({motivo}, {segundos} s): {resultado}",
1798
+ "vm.janela.arquivos": "The agent left {n} file(s) in the output folder. Check what should come to your PC:",
1799
+ "vm.janela.trazer": "Bring to {pasta}",
1800
+ "vm.janela.descartar": "Discard all",
1801
+ "vm.janela.trazidos": "{n} file(s) copied to {pasta}. The rest was deleted.",
1802
+ "vm.janela.descartados": "Nothing was copied. The files were deleted with the VM.",
1803
+ "vm.motivo.terminou": "done",
1804
+ "vm.motivo.cancelada": "stopped by you",
1805
+ "vm.motivo.tempo": "time ran out",
1806
+ "vm.motivo.passos": "step limit",
1807
+ "vm.passo.clicar": "clicked at {detalhe}",
1808
+ "vm.passo.duplo": "double-clicked at {detalhe}",
1809
+ "vm.passo.direito": "right-clicked at {detalhe}",
1810
+ "vm.passo.atalho": "pressed {detalhe}",
1811
+ "vm.passo.digitou": "typed text",
1812
+ "vm.passo.rolou": "scrolled",
1813
+ "vm.passo.esperou": "waited",
1814
+ "cli.barra_dica": "/help · ctrl+c stops",
1815
+ "jp.atualizando_script": "Updating with the installer... openTARS reopens on its own when it is done.",
1816
+ "jp.erro_script": "The update did not finish. The log is in {arquivo}.",
1817
+ "setup.motor_llamacpp": "Engine: llama.cpp. openTARS downloads the engine and Gargantua 2.5 the first time it opens, with a progress bar and no password.",
1818
+ "g25.oferta": "Gargantua 2.5 is here, the new openTARS AI: it sees your screen and understands your voice.",
1819
+ "g25.ver": "Get Gargantua 2.5"
1820
  }
app/idiomas/pt_BR.json CHANGED
@@ -504,7 +504,7 @@
504
  "term.modo_auto_explica": "o openTARS escolhe sozinho a IA mais adequada pra cada tarefa.",
505
  "term.voce": "Você",
506
  "term.conversa_carregada": "conversa anterior: {n} pedido(s) · /limpar começa do zero",
507
- "term.ajuda": "openTARS {versao} · assistente de IA local para Linux\n\nConversa\n opentars abre a conversa no terminal (continua a última)\n opentars --new começa uma conversa nova\n opentars --resume escolhe uma conversa salva para continuar\n opentars --plan modo plano: a IA só descreve o que faria\n opentars --auto edições nesta pasta sem perguntar (apagar, enviar e instalar ainda perguntam)\n opentars --chat \"pedido\" responde um pedido e sai\n opentars -p \"pedido\" para scripts: só a resposta (ou --output-format json); lê o stdin\n opentars-gui abre a janela\n opentars-gui --rapido abre a barra rápida (ctrl+alt+espaço)\n\nModelos\n opentars --model <nome> usa este modelo no -p\n opentars --explicar \"pedido\" mostra como a tarefa e o modelo são escolhidos\n opentars --avaliar-classificador mede o acerto da Murph e do modo automático\n\nAmbiente\n opentars --diagnostico confere Ollama, tela e acessibilidade (não muda nada)\n opentars --autoteste diagnóstico + um teste real com a calculadora\n opentars --setup instala o que estiver faltando (Ollama, modelos...)\n\nPreferências\n opentars --idioma <código> troca o idioma (pt_BR ou en)\n opentars --atalho [teclas|off] mostra, troca ou remove o atalho global\n opentars --version mostra a versão\n\nDentro da conversa, /ajuda lista os comandos. Um AGENTS.md na pasta entra em toda conversa.",
508
  "idioma.lista_titulo": "Idiomas disponíveis:",
509
  "idioma.como_trocar": "Troque com: /idioma <código> (ex: /idioma en)",
510
  "idioma.trocado": "Idioma: {nome}",
@@ -755,8 +755,8 @@
755
  "gargantua.desligado": "{modelo} instalado, mas TARS_SEM_GARGANTUA=1: o AUTO não dá preferência pra ele",
756
  "gargantua.nao_instalado": "Gargantua não instalado. Pra baixar a IA oficial do openTARS: ollama pull {modelo}",
757
  "setup.gargantua_ja": "Gargantua, a IA oficial do openTARS, já está instalado ({modelo}).",
758
- "setup.gargantua_baixando": "Baixando o Gargantua, a IA oficial do openTARS ({modelo}, ~1,6 GB)...",
759
- "setup.gargantua_segundo_plano": "Sem terminal aqui para mostrar o andamento (instalação pela loja de aplicativos ou automática): o download do Gargantua (~1,6 GB) continua em segundo plano. O openTARS mostra o progresso quando você abrir.",
760
  "setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: o Gargantua não foi baixado.",
761
  "msg.folego": "tarefa longa: {etapas} etapas, {acoes} ações feitas; continuando",
762
  "versao.nova": "Saiu o openTARS {versao}! Pra atualizar, rode de novo o comando de instalação (está no README do GitHub).",
@@ -785,7 +785,7 @@
785
  "pu.slogan": "Seu assistente que mexe no computador, sem sair dele.",
786
  "pu.passo": "Passo {n} de {total}",
787
  "pu.passo.idioma": "Idioma",
788
- "pu.passo.ollama": "Ollama",
789
  "pu.passo.nuvem": "Nuvem (opcional)",
790
  "pu.passo.gargantua": "Baixar os modelos",
791
  "pu.passo.teste": "Teste rápido",
@@ -794,16 +794,16 @@
794
  "pu.pausar": "Pausar",
795
  "pu.retomar": "Retomar",
796
  "pu.tentar_de_novo": "Tentar de novo",
797
- "pu.ollama.titulo": "Procurando o Ollama",
798
- "pu.ollama.texto": "O Ollama é o programa que roda o modelo de IA no seu computador. O TARS precisa dele para funcionar.",
799
- "pu.ollama.procurando": "Procurando o Ollama neste computador…",
800
- "pu.ollama.ok": "Ollama encontrado neste computador (versão {versao})",
801
  "pu.ollama.parado": "O Ollama está instalado, mas parado.",
802
- "pu.ollama.faltando": "O Ollama ainda não está instalado.",
803
- "pu.ollama.instalar": "Instalar o Ollama",
804
  "pu.ollama.iniciar": "Iniciar o Ollama",
805
  "pu.ollama.procurar_de_novo": "Procurar de novo",
806
- "pu.ollama.instalando": "Instalando o Ollama. Uma janela do sistema pode pedir a sua senha.",
807
  "pu.ollama.iniciando": "Iniciando o Ollama…",
808
  "pu.ollama.erro_instalar": "Não consegui instalar: {motivo}",
809
  "pu.ollama.abri_download": "Abri a página de download do Ollama no navegador. Instale, abra o Ollama e clique em Procurar de novo.",
@@ -1051,6 +1051,7 @@
1051
  "cfg.voz.instalada": "Voz instalada.",
1052
  "cfg.voz.erro": "Não deu pra instalar: {erro}",
1053
  "cfg.voz.fala": "Fala",
 
1054
  "cfg.voz.responder_falando": "Responder falando",
1055
  "cfg.voz.voz": "Voz",
1056
  "cfg.voz.velocidade": "Velocidade",
@@ -1194,9 +1195,15 @@
1194
  "mascote.dica8": "Tudo roda no seu computador: nada do que você escreve sai daqui, a menos que você ligue a nuvem.",
1195
  "mascote.dica9": "Em Configurações dá pra criar rotinas: o TARS faz uma tarefa sozinho no horário que você escolher.",
1196
  "mascote.dica10": "Escreva /modelo auto pro TARS escolher o melhor modelo pra cada pedido.",
1197
- "term.slogan": "assistente de IA local para Linux",
1198
  "term.bem_vindo": "Bem-vindo ao openTARS!",
1199
  "term.rotulo_ollama": "ollama:",
 
 
 
 
 
 
1200
  "term.rotulo_modelo": "modelo:",
1201
  "term.ollama_online": "conectado ({host})",
1202
  "term.ollama_offline": "desligado ({host}) · inicie com: sudo systemctl start ollama",
@@ -1205,18 +1212,12 @@
1205
  "term.modo_fixo": "fixo",
1206
  "term.parar": "ctrl+c para parar",
1207
  "term.ate_logo": "Até mais.",
1208
- "modo.lite.nome": "Lite",
1209
- "modo.lite.desc": "Só o essencial: conversar, falar e anexar. Tudo fica no automático.",
1210
  "modo.base.nome": "Base",
1211
- "modo.base.desc": "Bom para a maioria: histórico, memória, voz, rotinas e Probes.",
1212
- "modo.pro.nome": "Pro",
1213
- "modo.pro.desc": "Tudo do Base, mais ajustes avançados e diagnóstico.",
1214
- "cfg.modo": "Modo",
1215
- "cfg.modo_sub": "Quanto da janela você quer ver. Dá para trocar a qualquer hora; nada é apagado.",
1216
  "cfg.nav.avancado": "Ajustes avançados",
1217
  "cfg.nav.diagnostico": "Diagnóstico",
1218
  "cfg.avancado.titulo": "Ajustes avançados",
1219
- "cfg.avancado.sub": "Só valem no modo Pro. Mudar estes valores pode deixar a IA pior: o Gargantua foi treinado com os padrões.",
1220
  "cfg.av.automatico": "Automático",
1221
  "cfg.av.temperatura": "Temperatura",
1222
  "cfg.av.temperatura_sub": "Quanto a resposta varia. Mais baixa é mais previsível (o padrão é 0,1).",
@@ -1227,9 +1228,14 @@
1227
  "cfg.av.ferramentas": "Ferramentas",
1228
  "cfg.av.ferramentas_sub": "Desmarque o que a IA não pode usar. Ela ainda vê a ferramenta, mas o openTARS recusa.",
1229
  "cfg.av.salvar": "Salvar",
 
 
 
 
 
1230
  "cfg.av.salvo": "Salvo.",
1231
  "cfg.av.restaurar": "Voltar aos padrões",
1232
- "cfg.av.so_pro": "Você está no modo {modo}: estes ajustes ficam guardados, mas só valem no Pro.",
1233
  "cfg.diag.titulo": "Diagnóstico",
1234
  "cfg.diag.sub": "O que o openTARS fez e se o ambiente está em ordem. Nada daqui sai do seu computador.",
1235
  "cfg.diag.log": "Ferramentas usadas",
@@ -1240,11 +1246,8 @@
1240
  "cfg.diag.ambiente_sub": "Ollama, modelos, tela, microfone e permissões, o mesmo do comando opentars --diagnostico.",
1241
  "cfg.diag.conferir": "Conferir agora",
1242
  "cfg.diag.conferindo": "Conferindo…",
1243
- "pu.passo.modo": "Modo",
1244
- "pu.modo.titulo": "Quanto você quer ver?",
1245
- "pu.modo.texto": "Escolha o modo da janela. Dá para trocar depois, em Configurações > Geral.",
1246
  "pu.garg.nome_lite": "Gargantua Lite",
1247
- "pu.garg.lite_nota": "Este computador não tem placa de vídeo com 4 GB e tem pouca memória: baixamos o Gargantua Lite (0,6 GB, com visão), que é mais leve e erra um pouco mais. Ele olha a tela para achar botões, só que com menos precisão que o Gargantua 2 (1,6 GB).",
1248
  "cmd.sempre": [
1249
  "sempre",
1250
  "a"
@@ -1282,7 +1285,7 @@
1282
  "probes.titulo": "Probes",
1283
  "probes.sub": "Pequenos vigias que rodam em segundo plano, mesmo com a janela fechada, e avisam quando algo importa. Eles só olham: nada muda no seu computador sem você escolher.",
1284
  "probes.servico": "Ligar e desligar",
1285
- "probes.baixar_lite": "Baixar o Gargantua Lite (0,6 GB, com visão)",
1286
  "probes.parar_tudo": "Parar tudo",
1287
  "probes.nova": "Criar um Probe",
1288
  "probes.nova_sub": "Três passos: escolha o que vigiar, preencha o que pedir e confira a frase do que ele vai fazer.",
@@ -1490,16 +1493,15 @@
1490
  "probes.pp.missao": "Avisa quando {quando}.",
1491
  "avisos.dica_sino": "Avisos dos Probes: {n} novo(s)",
1492
  "pu.ollama_antigo": "Este modelo pede um Ollama mais novo. Atualize com: curl -fsSL https://ollama.com/install.sh | sh (e tente de novo).",
1493
- "cooper.descontinuado": "O Cooper foi descontinuado: o Gargantua 2 já enxerga a tela. Para instalar: {comando}",
1494
- "cfg.gargantua_novo": "Você tem o Gargantua 1 ou o Lite. O Gargantua 2 é a IA principal do openTARS: 1,6 GB, acerta mais e enxerga a tela com mais precisão (o Gargantua 1 não enxerga). Vale baixar.",
1495
- "cfg.baixar_gargantua2": "Baixar o Gargantua 2",
1496
  "msg.busca_nao_lida": "A busca foi aberta no navegador, mas a IA não leu a página: o que ela respondeu acima vem da memória dela e pode estar desatualizado. Confira os resultados no navegador.",
1497
  "msg.pouca_confianca": "Não tenho certeza dessa resposta: ela veio da memória do modelo e pode estar errada ou inventada. Confira numa fonte antes de usar.",
1498
  "msg.verificando_fato": "Pouca confiança na resposta acima: vou conferir pela busca na web.",
1499
  "msg.busca_nada_aberto": "Nada foi aberto no seu computador: a resposta veio do SearXNG. Se ela diz que abriu algo no navegador, isso não aconteceu.",
1500
  "visao.ok": "{modelo} (olha a tela: acha botões e descreve janelas)",
1501
- "visao.nao_instalado": "não instalado. Para o openTARS enxergar a tela, baixe o Gargantua 2: {comando}",
1502
- "visao.desligado": "{modelo} está instalado, mas desligado (TARS_COOPER=0).",
1503
  "visao.sem_resposta": "O modelo que olha a tela não respondeu ({motivo}).",
1504
  "cfg.nav.modelos": "Mais modelos",
1505
  "cfg.modelos.titulo": "Mais modelos",
@@ -1508,9 +1510,9 @@
1508
  "modelinhos.baixando": "Baixando os modelinhos de foto (~300 MB): tirar o fundo, aumentar a resolução, selecionar por clique e remover um objeto.",
1509
  "modelinhos.pronto": "Os modelinhos de foto estão prontos.",
1510
  "modelinhos.erro": "Não consegui baixar os modelinhos de foto ({erro}). Tento de novo na próxima abertura.",
1511
- "lite.baixando": "Baixando o Gargantua 2 Lite (0,6 GB), a IA que fica em segundo plano para o assistente, as ações de texto e os Probes.",
1512
- "lite.pronto": "O Gargantua 2 Lite está pronto.",
1513
- "lite.erro": "Não consegui baixar o Gargantua 2 Lite ({erro}). Tento de novo na próxima abertura.",
1514
  "cfg.bonsai.sub": "Gera imagens a partir de uma frase (\"desenhe um farol ao entardecer\"). Da PrismML, Apache 2.0. Opcional: {tamanho} e uma placa NVIDIA com 6 GB (ou um Mac com Apple Silicon). Roda sem internet depois de instalado.",
1515
  "cfg.bonsai.instalar": "Instalar ({tamanho})",
1516
  "cfg.bonsai.remover": "Remover",
@@ -1591,6 +1593,11 @@
1591
  "acoes.resultado.resumir": "Resumo",
1592
  "acoes.resultado.traduzir": "Tradução",
1593
  "acoes.resultado.perguntar": "Resposta",
 
 
 
 
 
1594
  "acoes.pergunta_dica": "Pergunte algo sobre o trecho",
1595
  "acoes.enviar_pergunta": "Enviar pergunta",
1596
  "acoes.substituir": "Substituir no texto",
@@ -1601,6 +1608,7 @@
1601
  "acoes.atalho_linux": "Ctrl+Alt+R",
1602
  "acoes.atalho_windows": "Ctrl+Alt+R",
1603
  "acoes.atalho_macos": "Ctrl+Option+R",
 
1604
  "acoes.trecho": "Texto selecionado: \"{trecho}\"",
1605
  "acoes.cortado": "(só os primeiros {n} caracteres entram)",
1606
  "acoes.sem_texto": "Nenhum texto selecionado. Marque um trecho em qualquer programa e aperte o atalho de novo.",
@@ -1625,6 +1633,34 @@
1625
  "cfg.assist.sistema_titulo": "Iniciar com o sistema",
1626
  "cfg.assist.sistema": "Abrir o openTARS escondido quando eu entrar no computador",
1627
  "cfg.assist.sistema_nota": "Ligado por padrão: o atalho responde na hora desde o começo do dia e o ícone fica perto do relógio. Desmarque para o openTARS não abrir com o sistema.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1628
  "cfg.assist.acoes_titulo": "Ações de texto",
1629
  "cfg.assist.acoes_sub": "Reescrever, resumir, traduzir e perguntar sobre o trecho. O texto selecionado é só material: nada do que está escrito nele é executado, e nada sai do seu computador.",
1630
  "cfg.assist.acoes_modelo": "Modelo das ações: {modelo}.",
@@ -1721,6 +1757,12 @@
1721
  "ctx.nota": "Só olho o nome do programa em uso e o título da janela. Nada é gravado nem enviado. Desligue em Configurações > Assistente.",
1722
  "ctx.selecione": "Selecione o texto que quer revisar e clique em Revisar de novo (ou use o atalho das ações de texto).",
1723
  "cfg.assist.ctx_titulo": "Sugestões pelo programa em uso",
 
 
 
 
 
 
1724
  "cfg.assist.ctx": "Sugerir ajuda conforme o programa em uso (por exemplo, revisar um e-mail)",
1725
  "ctx.nota_config": "Ligado por padrão. O openTARS olha a cada 4 segundos só o nome do programa e o título da janela em uso, sem gravar nem enviar nada, e sugere uma vez por janela.",
1726
  "arq.acao.foto": "Editar a foto",
@@ -1763,5 +1805,84 @@
1763
  "foto.salvar": "Salvar cópia: {nome}",
1764
  "foto.salvar_n": "Salvar {n} cópias",
1765
  "foto.aviso": "Sempre uma cópia na mesma pasta: a original fica como está. A cópia sai sem os dados de localização e do aparelho. Cortar, girar e clarear são na hora; tirar o fundo, aumentar e remover um objeto usam modelinhos neste computador e levam alguns segundos.",
1766
- "foto.salvas": "Cópia salva: {nomes}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1767
  }
 
504
  "term.modo_auto_explica": "o openTARS escolhe sozinho a IA mais adequada pra cada tarefa.",
505
  "term.voce": "Você",
506
  "term.conversa_carregada": "conversa anterior: {n} pedido(s) · /limpar começa do zero",
507
+ "term.ajuda": "openTARS {versao} · assistente de IA local\n\nConversa\n opentars abre a conversa no terminal (continua a última)\n opentars --new começa uma conversa nova\n opentars --resume escolhe uma conversa salva para continuar\n opentars --plan modo plano: a IA só descreve o que faria\n opentars --auto edições nesta pasta sem perguntar (apagar, enviar e instalar ainda perguntam)\n opentars --chat \"pedido\" responde um pedido e sai\n opentars -p \"pedido\" para scripts: só a resposta (ou --output-format json); lê o stdin\n opentars-gui abre a janela\n opentars-gui --rapido abre a barra rápida (ctrl+alt+espaço)\n\nModelos\n opentars --model <nome> usa este modelo no -p\n opentars --explicar \"pedido\" mostra como a tarefa e o modelo são escolhidos\n opentars --avaliar-classificador mede o acerto da Murph e do modo automático\n\nAmbiente\n opentars --diagnostico confere o motor, a tela e a acessibilidade (não muda nada)\n opentars --autoteste diagnóstico + um teste real com a calculadora\n opentars --setup instala o que estiver faltando (motor, modelos...)\n opentars --missao-vm \"tarefa\" roda uma missão de agente numa máquina virtual descartável (precisa do modelo Pro)\n\nPreferências\n opentars --idioma <código> troca o idioma (pt_BR ou en)\n opentars --atalho [teclas|off] mostra, troca ou remove o atalho global\n opentars --version mostra a versão\n\nDentro da conversa, /ajuda lista os comandos. Um AGENTS.md na pasta entra em toda conversa.",
508
  "idioma.lista_titulo": "Idiomas disponíveis:",
509
  "idioma.como_trocar": "Troque com: /idioma <código> (ex: /idioma en)",
510
  "idioma.trocado": "Idioma: {nome}",
 
755
  "gargantua.desligado": "{modelo} instalado, mas TARS_SEM_GARGANTUA=1: o AUTO não dá preferência pra ele",
756
  "gargantua.nao_instalado": "Gargantua não instalado. Pra baixar a IA oficial do openTARS: ollama pull {modelo}",
757
  "setup.gargantua_ja": "Gargantua, a IA oficial do openTARS, já está instalado ({modelo}).",
758
+ "setup.gargantua_baixando": "Baixando o Gargantua, a IA oficial do openTARS ({modelo}, ~6,4 GB)...",
759
+ "setup.gargantua_segundo_plano": "Sem terminal aqui para mostrar o andamento (instalação pela loja de aplicativos ou automática): o download do Gargantua (~6,4 GB) continua em segundo plano. O openTARS mostra o progresso quando você abrir.",
760
  "setup.gargantua_pulado": "TARS_SEM_GARGANTUA=1: o Gargantua não foi baixado.",
761
  "msg.folego": "tarefa longa: {etapas} etapas, {acoes} ações feitas; continuando",
762
  "versao.nova": "Saiu o openTARS {versao}! Pra atualizar, rode de novo o comando de instalação (está no README do GitHub).",
 
785
  "pu.slogan": "Seu assistente que mexe no computador, sem sair dele.",
786
  "pu.passo": "Passo {n} de {total}",
787
  "pu.passo.idioma": "Idioma",
788
+ "pu.passo.ollama": "Motor de IA",
789
  "pu.passo.nuvem": "Nuvem (opcional)",
790
  "pu.passo.gargantua": "Baixar os modelos",
791
  "pu.passo.teste": "Teste rápido",
 
794
  "pu.pausar": "Pausar",
795
  "pu.retomar": "Retomar",
796
  "pu.tentar_de_novo": "Tentar de novo",
797
+ "pu.ollama.titulo": "Preparando o motor de IA",
798
+ "pu.ollama.texto": "O motor roda o modelo de IA no seu computador. O openTARS usa o llama.cpp; se você já tem o Ollama, ele também serve.",
799
+ "pu.ollama.procurando": "Procurando um motor de IA neste computador…",
800
+ "pu.ollama.ok": "Motor de IA pronto ({versao})",
801
  "pu.ollama.parado": "O Ollama está instalado, mas parado.",
802
+ "pu.ollama.faltando": "Falta o motor de IA. São uns 35 MB, sem pedir senha.",
803
+ "pu.ollama.instalar": "Instalar o motor",
804
  "pu.ollama.iniciar": "Iniciar o Ollama",
805
  "pu.ollama.procurar_de_novo": "Procurar de novo",
806
+ "pu.ollama.instalando": "Baixando o motor (llama.cpp)…",
807
  "pu.ollama.iniciando": "Iniciando o Ollama…",
808
  "pu.ollama.erro_instalar": "Não consegui instalar: {motivo}",
809
  "pu.ollama.abri_download": "Abri a página de download do Ollama no navegador. Instale, abra o Ollama e clique em Procurar de novo.",
 
1051
  "cfg.voz.instalada": "Voz instalada.",
1052
  "cfg.voz.erro": "Não deu pra instalar: {erro}",
1053
  "cfg.voz.fala": "Fala",
1054
+ "cfg.voz.resposta_completa": "Ler as respostas longas inteiras (por padrão só as primeiras frases são faladas; o resto fica na tela)",
1055
  "cfg.voz.responder_falando": "Responder falando",
1056
  "cfg.voz.voz": "Voz",
1057
  "cfg.voz.velocidade": "Velocidade",
 
1195
  "mascote.dica8": "Tudo roda no seu computador: nada do que você escreve sai daqui, a menos que você ligue a nuvem.",
1196
  "mascote.dica9": "Em Configurações dá pra criar rotinas: o TARS faz uma tarefa sozinho no horário que você escolher.",
1197
  "mascote.dica10": "Escreva /modelo auto pro TARS escolher o melhor modelo pra cada pedido.",
1198
+ "term.slogan": "assistente de IA local",
1199
  "term.bem_vindo": "Bem-vindo ao openTARS!",
1200
  "term.rotulo_ollama": "ollama:",
1201
+ "term.rotulo_motor": "motor:",
1202
+ "term.rotulo_pasta": "pasta:",
1203
+ "term.motor_offline": "fora do ar ({host}) · reinicie o openTARS para ligar o motor",
1204
+ "cli.regua.perguntar": "perguntar",
1205
+ "cli.regua.plano": "plano",
1206
+ "cli.regua.automatico": "automático",
1207
  "term.rotulo_modelo": "modelo:",
1208
  "term.ollama_online": "conectado ({host})",
1209
  "term.ollama_offline": "desligado ({host}) · inicie com: sudo systemctl start ollama",
 
1212
  "term.modo_fixo": "fixo",
1213
  "term.parar": "ctrl+c para parar",
1214
  "term.ate_logo": "Até mais.",
 
 
1215
  "modo.base.nome": "Base",
1216
+ "modo.base.desc": "Tudo para o dia a dia: histórico, memória, voz, rotinas, Probes e nuvem.",
 
 
 
 
1217
  "cfg.nav.avancado": "Ajustes avançados",
1218
  "cfg.nav.diagnostico": "Diagnóstico",
1219
  "cfg.avancado.titulo": "Ajustes avançados",
1220
+ "cfg.avancado.sub": "Só valem com o modo dev ligado. Mudar estes valores pode deixar a IA pior: o Gargantua foi treinado com os padrões.",
1221
  "cfg.av.automatico": "Automático",
1222
  "cfg.av.temperatura": "Temperatura",
1223
  "cfg.av.temperatura_sub": "Quanto a resposta varia. Mais baixa é mais previsível (o padrão é 0,1).",
 
1228
  "cfg.av.ferramentas": "Ferramentas",
1229
  "cfg.av.ferramentas_sub": "Desmarque o que a IA não pode usar. Ela ainda vê a ferramenta, mas o openTARS recusa.",
1230
  "cfg.av.salvar": "Salvar",
1231
+ "cfg.av.motor": "Motor de IA",
1232
+ "cfg.av.motor_sub": "O llama.cpp é o padrão. Troque para o Ollama se preferir continuar com ele. Vale na próxima vez que o openTARS abrir.",
1233
+ "cfg.av.motor_llamacpp": "llama.cpp (padrão)",
1234
+ "cfg.av.motor_ollama": "Ollama",
1235
+ "cfg.av.motor_reiniciar": "Salvo. Reinicie o openTARS para trocar o motor.",
1236
  "cfg.av.salvo": "Salvo.",
1237
  "cfg.av.restaurar": "Voltar aos padrões",
1238
+ "cfg.av.so_pro": "O modo dev está desligado: estes ajustes ficam guardados, mas só valem com ele ligado (Configurações > Geral).",
1239
  "cfg.diag.titulo": "Diagnóstico",
1240
  "cfg.diag.sub": "O que o openTARS fez e se o ambiente está em ordem. Nada daqui sai do seu computador.",
1241
  "cfg.diag.log": "Ferramentas usadas",
 
1246
  "cfg.diag.ambiente_sub": "Ollama, modelos, tela, microfone e permissões, o mesmo do comando opentars --diagnostico.",
1247
  "cfg.diag.conferir": "Conferir agora",
1248
  "cfg.diag.conferindo": "Conferindo…",
 
 
 
1249
  "pu.garg.nome_lite": "Gargantua Lite",
1250
+ "pu.garg.lite_nota": "Este computador não tem placa de vídeo com 4 GB e tem pouca memória: baixamos o Gargantua 2.5 Lite (4,4 GB, com visão e áudio), que é mais leve. Ele lê a tela, mas não aponta botões pequenos pela posição; o clique pelo nome funciona pela acessibilidade do app.",
1251
  "cmd.sempre": [
1252
  "sempre",
1253
  "a"
 
1285
  "probes.titulo": "Probes",
1286
  "probes.sub": "Pequenos vigias que rodam em segundo plano, mesmo com a janela fechada, e avisam quando algo importa. Eles só olham: nada muda no seu computador sem você escolher.",
1287
  "probes.servico": "Ligar e desligar",
1288
+ "probes.baixar_lite": "Baixar o Gargantua 2.5 Lite (4,4 GB, com visão e áudio)",
1289
  "probes.parar_tudo": "Parar tudo",
1290
  "probes.nova": "Criar um Probe",
1291
  "probes.nova_sub": "Três passos: escolha o que vigiar, preencha o que pedir e confira a frase do que ele vai fazer.",
 
1493
  "probes.pp.missao": "Avisa quando {quando}.",
1494
  "avisos.dica_sino": "Avisos dos Probes: {n} novo(s)",
1495
  "pu.ollama_antigo": "Este modelo pede um Ollama mais novo. Atualize com: curl -fsSL https://ollama.com/install.sh | sh (e tente de novo).",
1496
+ "cfg.gargantua_novo": "Você tem o Gargantua 1 ou um modelo mais antigo. O Gargantua 2.5 é a IA principal do openTARS, em Gemma 4: 6,3 GB, com visão e suporte a áudio. Vale baixar.",
1497
+ "cfg.baixar_gargantua2": "Baixar o Gargantua 2.5",
 
1498
  "msg.busca_nao_lida": "A busca foi aberta no navegador, mas a IA não leu a página: o que ela respondeu acima vem da memória dela e pode estar desatualizado. Confira os resultados no navegador.",
1499
  "msg.pouca_confianca": "Não tenho certeza dessa resposta: ela veio da memória do modelo e pode estar errada ou inventada. Confira numa fonte antes de usar.",
1500
  "msg.verificando_fato": "Pouca confiança na resposta acima: vou conferir pela busca na web.",
1501
  "msg.busca_nada_aberto": "Nada foi aberto no seu computador: a resposta veio do SearXNG. Se ela diz que abriu algo no navegador, isso não aconteceu.",
1502
  "visao.ok": "{modelo} (olha a tela: acha botões e descreve janelas)",
1503
+ "visao.nao_instalado": "não instalado. Para o openTARS enxergar a tela, baixe o Gargantua 2.5: {comando}",
1504
+ "visao.desligado": "{modelo} está instalado, mas desligado (TARS_VISAO=0).",
1505
  "visao.sem_resposta": "O modelo que olha a tela não respondeu ({motivo}).",
1506
  "cfg.nav.modelos": "Mais modelos",
1507
  "cfg.modelos.titulo": "Mais modelos",
 
1510
  "modelinhos.baixando": "Baixando os modelinhos de foto (~300 MB): tirar o fundo, aumentar a resolução, selecionar por clique e remover um objeto.",
1511
  "modelinhos.pronto": "Os modelinhos de foto estão prontos.",
1512
  "modelinhos.erro": "Não consegui baixar os modelinhos de foto ({erro}). Tento de novo na próxima abertura.",
1513
+ "lite.baixando": "Baixando o Gargantua 2.5 Lite (4,4 GB), a IA que fica em segundo plano para o assistente, as ações de texto e os Probes.",
1514
+ "lite.pronto": "O Gargantua 2.5 Lite está pronto.",
1515
+ "lite.erro": "Não consegui baixar o Gargantua 2.5 Lite ({erro}). Tento de novo na próxima abertura.",
1516
  "cfg.bonsai.sub": "Gera imagens a partir de uma frase (\"desenhe um farol ao entardecer\"). Da PrismML, Apache 2.0. Opcional: {tamanho} e uma placa NVIDIA com 6 GB (ou um Mac com Apple Silicon). Roda sem internet depois de instalado.",
1517
  "cfg.bonsai.instalar": "Instalar ({tamanho})",
1518
  "cfg.bonsai.remover": "Remover",
 
1593
  "acoes.resultado.resumir": "Resumo",
1594
  "acoes.resultado.traduzir": "Tradução",
1595
  "acoes.resultado.perguntar": "Resposta",
1596
+ "acoes.aba.responder": "Responder",
1597
+ "acoes.opcao.curto": "Mais curto",
1598
+ "acoes.opcao.lista": "Em tópicos",
1599
+ "acoes.resultado.responder": "Rascunho da resposta",
1600
+ "acoes.responder_dica": "O que você quer dizer? (ex.: topo amanhã)",
1601
  "acoes.pergunta_dica": "Pergunte algo sobre o trecho",
1602
  "acoes.enviar_pergunta": "Enviar pergunta",
1603
  "acoes.substituir": "Substituir no texto",
 
1608
  "acoes.atalho_linux": "Ctrl+Alt+R",
1609
  "acoes.atalho_windows": "Ctrl+Alt+R",
1610
  "acoes.atalho_macos": "Ctrl+Option+R",
1611
+ "acoes.da_janela": "Texto lido da janela: {janela}",
1612
  "acoes.trecho": "Texto selecionado: \"{trecho}\"",
1613
  "acoes.cortado": "(só os primeiros {n} caracteres entram)",
1614
  "acoes.sem_texto": "Nenhum texto selecionado. Marque um trecho em qualquer programa e aperte o atalho de novo.",
 
1633
  "cfg.assist.sistema_titulo": "Iniciar com o sistema",
1634
  "cfg.assist.sistema": "Abrir o openTARS escondido quando eu entrar no computador",
1635
  "cfg.assist.sistema_nota": "Ligado por padrão: o atalho responde na hora desde o começo do dia e o ícone fica perto do relógio. Desmarque para o openTARS não abrir com o sistema.",
1636
+ "cfg.assist.agenda_titulo": "Agenda",
1637
+ "cfg.assist.agenda_sub": "Pergunte \"o que tenho amanhã?\". Aponte um arquivo .ics ou o link de um calendário (Google, Outlook e Apple exportam). Só leitura; desligada até você configurar.",
1638
+ "cfg.assist.agenda_dica": "/caminho/agenda.ics ou https://… (vários separados por ;)",
1639
+ "cfg.assist.agenda_ligada": "Ligada: lendo {n} calendário(s). O openTARS nunca altera o seu calendário.",
1640
+ "cfg.assist.agenda_nao_achei": "Não achei {fonte}: use um arquivo que existe ou um link que comece com https://",
1641
+ "cfg.assist.desligada": "Desligada.",
1642
+ "cfg.assist.notas_titulo": "Notas",
1643
+ "cfg.assist.notas_sub": "Diga \"cria uma nota: …\" ou \"minhas notas sobre …\". As notas são arquivos Markdown na pasta que você escolher; desligadas até você configurar.",
1644
+ "cfg.assist.notas_dica": "/caminho/Notas (pastas do Obsidian e do Joplin servem)",
1645
+ "cfg.assist.notas_ligada": "Ligadas: as notas são salvas e procuradas nesta pasta. Uma nota nunca é escrita por cima de outra.",
1646
+ "cfg.assist.salvar": "Salvar",
1647
+ "cfg.assist.email_titulo": "E-mail",
1648
+ "cfg.assist.email_sub": "Pergunte \"que e-mails chegaram hoje?\", \"lê o último e-mail da Ana\" ou \"envia um e-mail para ana@exemplo.com dizendo…\". Ler nunca marca nada como lido; enviar sempre pergunta antes. Desligado até você configurar.",
1649
+ "cfg.assist.email_usuario": "Endereço de e-mail",
1650
+ "cfg.assist.email_senha": "Senha (Gmail e Outlook pedem uma senha de app)",
1651
+ "cfg.assist.email_senha_guardada": "Senha guardada no cofre do sistema (digite outra para trocar)",
1652
+ "cfg.assist.email_imap": "Servidor de entrada (IMAP); vazio para Gmail, Outlook, iCloud e Yahoo",
1653
+ "cfg.assist.email_smtp": "Servidor de saída (SMTP); vazio para os mesmos",
1654
+ "cfg.assist.email_ligado": "Ligado: {usuario}. A senha fica no cofre do sistema, nunca em arquivo.",
1655
+ "cfg.assist.email_servidores": "Não conheço este provedor: preencha os servidores IMAP e SMTP.",
1656
+ "cfg.assist.email_sem_senha": "Digite a senha.",
1657
+ "cfg.assist.email_sem_cofre": "Este computador não tem cofre de senhas para o openTARS (instale o pacote keyring). Não guardei nada.",
1658
+ "cfg.assist.email_testar": "Testar",
1659
+ "cfg.assist.email_desligar": "Desligar e esquecer",
1660
+ "cfg.assist.email_testando": "Testando…",
1661
+ "cfg.assist.email_ok": "Conectou. Nada foi alterado na sua caixa.",
1662
+ "cfg.assist.email_erro": "Não deu: {erro}",
1663
+ "cfg.assist.email_salvar_antes": "Salve a conta primeiro.",
1664
  "cfg.assist.acoes_titulo": "Ações de texto",
1665
  "cfg.assist.acoes_sub": "Reescrever, resumir, traduzir e perguntar sobre o trecho. O texto selecionado é só material: nada do que está escrito nele é executado, e nada sai do seu computador.",
1666
  "cfg.assist.acoes_modelo": "Modelo das ações: {modelo}.",
 
1757
  "ctx.nota": "Só olho o nome do programa em uso e o título da janela. Nada é gravado nem enviado. Desligue em Configurações > Assistente.",
1758
  "ctx.selecione": "Selecione o texto que quer revisar e clique em Revisar de novo (ou use o atalho das ações de texto).",
1759
  "cfg.assist.ctx_titulo": "Sugestões pelo programa em uso",
1760
+ "cfg.assist.indice_titulo": "Busca de arquivos",
1761
+ "cfg.assist.indice": "Indexar meus arquivos para a busca ser instantânea (desligado por padrão)",
1762
+ "cfg.assist.indice_nota": "O índice guarda só neste computador os nomes dos arquivos e o começo de cada texto, nunca envia nada e é apagado quando você desliga.",
1763
+ "cfg.assist.indice_montando": "Montando o índice em segundo plano…",
1764
+ "cfg.assist.indice_pronto": "Índice pronto: {n} arquivos.",
1765
+ "cfg.assist.indice_erro": "O índice falhou: {erro}",
1766
  "cfg.assist.ctx": "Sugerir ajuda conforme o programa em uso (por exemplo, revisar um e-mail)",
1767
  "ctx.nota_config": "Ligado por padrão. O openTARS olha a cada 4 segundos só o nome do programa e o título da janela em uso, sem gravar nem enviar nada, e sugere uma vez por janela.",
1768
  "arq.acao.foto": "Editar a foto",
 
1805
  "foto.salvar": "Salvar cópia: {nome}",
1806
  "foto.salvar_n": "Salvar {n} cópias",
1807
  "foto.aviso": "Sempre uma cópia na mesma pasta: a original fica como está. A cópia sai sem os dados de localização e do aparelho. Cortar, girar e clarear são na hora; tirar o fundo, aumentar e remover um objeto usam modelinhos neste computador e levam alguns segundos.",
1808
+ "foto.salvas": "Cópia salva: {nomes}.",
1809
+ "cfg.dev": "Modo dev",
1810
+ "cfg.dev_sub": "Para quem quer mexer no que fica no automático.",
1811
+ "cfg.dev_ligar": "Mostrar os ajustes avançados e o diagnóstico",
1812
+ "modo.dev.nome": "Dev",
1813
+ "modo.dev.desc": "Tudo do Base, mais ajustes avançados e diagnóstico.",
1814
+ "motor.preparando": "Preparando o motor novo (llama.cpp) em segundo plano. Pode continuar usando o openTARS.",
1815
+ "motor.pronto": "Motor novo pronto: o openTARS agora roda no llama.cpp.",
1816
+ "motor.erro": "Não deu para preparar o motor novo ({erro}). O openTARS continua no Ollama.",
1817
+ "cfg.modelos.busca_ph": "Pesquisar modelos (nome, visão, ferramentas...)",
1818
+ "cfg.modelos.busca_web": "Procurar na biblioteca do Ollama",
1819
+ "cfg.modelos.buscando": "Procurando na biblioteca do Ollama...",
1820
+ "cfg.modelos.busca_nada": "Nada encontrado na biblioteca do Ollama para essa pesquisa.",
1821
+ "cfg.modelos.busca_erro": "Não deu para acessar a biblioteca do Ollama. Confira a conexão e tente de novo.",
1822
+ "cfg.modelos.g.web": "Da biblioteca do Ollama",
1823
+ "cfg.modelos.g.web_sub": "Resultados do ollama.com. O tamanho aparece ao clicar: ele é conferido antes de começar o download.",
1824
+ "cfg.modelos.calculando": "Conferindo o tamanho do download...",
1825
+ "cfg.assist.reabrir_titulo": "Apps que escondem os botões",
1826
+ "cfg.assist.reabrir": "Deixar o openTARS reabrir um app para conseguir clicar nos botões dele",
1827
+ "cfg.assist.reabrir_nota": "Apps baseados no Chrome que já estavam abertos (Claude, Discord, VS Code, Slack, Chrome) escondem os botões de outros programas. Com isto ligado, quando o openTARS precisar clicar em um deles, fecha o app e abre de novo com a acessibilidade ligada. O que não foi salvo nesse app pode se perder, e o app nunca é forçado a fechar: se ele recusar, continua aberto. Desligado por padrão.",
1828
+ "vm.cli.uso": "Uso: opentars --missao-vm \"o que o agente deve fazer dentro da máquina virtual\"",
1829
+ "vm.cli.falta": "A máquina virtual não está pronta: falta {falta}.",
1830
+ "vm.cli.sem_base": "A imagem da máquina virtual ainda não foi montada.",
1831
+ "vm.cli.sem_pro": "Isto precisa do modelo Gargantua Pro ({modelo}). Adicione em Configurações > Mais modelos.",
1832
+ "vm.cli.comecando": "Começando numa máquina virtual descartável: {pedido}",
1833
+ "vm.cli.resultado": "Terminou ({motivo}, {segundos} s): {resultado}",
1834
+ "vm.cli.acesso": " site visitado: {acesso}",
1835
+ "vm.cli.arquivos": "O agente deixou {n} arquivo(s) na pasta de saída: {lista}",
1836
+ "vm.cli.liberar": "Copiar para {pasta}? [s/N] ",
1837
+ "vm.cli.descartados": "Não copiados. Foram apagados com a máquina virtual.",
1838
+ "vm.secao": "Missões numa máquina virtual",
1839
+ "vm.secao_sub": "O Gargantua Pro trabalha num computador isolado e descartável: abre programas, navega e escreve. Nada do seu PC fica visível lá dentro, e o que ele produzir só chega aqui se você aprovar.",
1840
+ "vm.abrir": "Abrir a janela da VM",
1841
+ "vm.so_linux": "Por enquanto, as missões em VM funcionam só no Linux com KVM.",
1842
+ "vm.janela.titulo": "Missão numa máquina virtual",
1843
+ "vm.janela.sub": "Escreva o que o agente deve fazer. Ele usa o mouse e o teclado de uma VM descartável; ao terminar, a VM é apagada.",
1844
+ "vm.janela.falta": "A máquina virtual não está pronta: falta {falta}.",
1845
+ "vm.janela.sem_base": "A imagem da VM ainda não existe. Montar leva uns 5 minutos e ocupa cerca de 1,6 GB.",
1846
+ "vm.janela.sem_pro": "As missões em VM usam o Gargantua Pro (cerca de 7,6 GB).",
1847
+ "vm.janela.conferindo": "Conferindo o modelo...",
1848
+ "vm.janela.montar": "Montar a imagem",
1849
+ "vm.janela.montando": "Montando a imagem da VM...",
1850
+ "vm.janela.montando_pct": "Baixando o sistema da VM: {pct}%",
1851
+ "vm.janela.baixar_pro": "Baixar o Gargantua Pro",
1852
+ "vm.janela.baixando": "Baixando o Gargantua Pro...",
1853
+ "vm.janela.erro": "Não deu certo: {erro}",
1854
+ "vm.janela.pedido_ph": "Ex.: abra o terminal e crie o arquivo notas.txt na pasta de saída com a lista de compras",
1855
+ "vm.janela.comecar": "Começar",
1856
+ "vm.janela.parar": "Parar",
1857
+ "vm.janela.internet": "Internet (cada site fica registrado)",
1858
+ "vm.janela.sem_tela": "A tela da VM aparece aqui.",
1859
+ "vm.janela.tela": "Tela da máquina virtual",
1860
+ "vm.janela.passos": "Passos",
1861
+ "vm.janela.sites": "Sites visitados",
1862
+ "vm.janela.ligando": "Ligando a VM...",
1863
+ "vm.janela.trabalhando": "O agente está trabalhando.",
1864
+ "vm.janela.parando": "Parando...",
1865
+ "vm.janela.fim": "Terminou ({motivo}, {segundos} s): {resultado}",
1866
+ "vm.janela.arquivos": "O agente deixou {n} arquivo(s) na pasta de saída. Marque o que deve vir para o seu PC:",
1867
+ "vm.janela.trazer": "Trazer para {pasta}",
1868
+ "vm.janela.descartar": "Descartar tudo",
1869
+ "vm.janela.trazidos": "{n} arquivo(s) copiado(s) para {pasta}. O resto foi apagado.",
1870
+ "vm.janela.descartados": "Nada foi copiado. Os arquivos foram apagados com a VM.",
1871
+ "vm.motivo.terminou": "concluída",
1872
+ "vm.motivo.cancelada": "parada por você",
1873
+ "vm.motivo.tempo": "tempo esgotado",
1874
+ "vm.motivo.passos": "limite de passos",
1875
+ "vm.passo.clicar": "clicou em {detalhe}",
1876
+ "vm.passo.duplo": "clique duplo em {detalhe}",
1877
+ "vm.passo.direito": "clique direito em {detalhe}",
1878
+ "vm.passo.atalho": "apertou {detalhe}",
1879
+ "vm.passo.digitou": "digitou um texto",
1880
+ "vm.passo.rolou": "rolou a tela",
1881
+ "vm.passo.esperou": "esperou",
1882
+ "cli.barra_dica": "/ajuda · ctrl+c para",
1883
+ "jp.atualizando_script": "Atualizando pelo instalador... o openTARS reabre sozinho quando terminar.",
1884
+ "jp.erro_script": "A atualização não terminou. O registro está em {arquivo}.",
1885
+ "setup.motor_llamacpp": "Motor: llama.cpp. O openTARS baixa o motor e o Gargantua 2.5 na primeira abertura, com barra e sem pedir senha.",
1886
+ "g25.oferta": "Chegou o Gargantua 2.5, a IA nova do openTARS: vê a tela e entende a sua voz.",
1887
+ "g25.ver": "Baixar o Gargantua 2.5"
1888
  }
app/nucleo/aplicacoes.py CHANGED
@@ -820,6 +820,44 @@ def _eh_electron(comando):
820
  return False
821
 
822
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  # Nomes que são SITES, não programas (18/10: open_application("google") abria o Google Chrome, que só tem "google" no
824
  # nome). Só vira site quando não há um programa com exatamente esse nome: um Spotify ou Discord instalado continua app.
825
  _SITES_NAO_APPS = frozenset({"google", "youtube", "gmail", "wikipedia", "reddit", "amazon", "github", "facebook",
 
820
  return False
821
 
822
 
823
+ def comando_com_acessibilidade(argv):
824
+ """O mesmo comando com a opção que faz o Chromium/Electron montar a árvore de acessibilidade (sem repetir se já tem)."""
825
+ argv = list(argv)
826
+ return argv if OPCAO_ACESSIBILIDADE_CHROMIUM in argv else argv[:1] + [OPCAO_ACESSIBILIDADE_CHROMIUM] + argv[1:]
827
+
828
+
829
+ def reabrir_com_acessibilidade(pid, esperar=10):
830
+ """11/11 (F11): um app Electron/Chromium que já estava aberto não expõe seus botões (medido: ligar a acessibilidade da sessão,
831
+ registrar ouvinte AT-SPI e ligar ScreenReaderEnabled não acordam um app aberto). O único caminho que funciona é abrir de novo
832
+ com a opção. Fecha o app com SIGTERM (nunca SIGKILL: se ele não sair, desiste) e abre igual, com a opção.
833
+ Só chamar com a permissão da pessoa (config reabrir_com_acessibilidade). Devolve (ok, mensagem)."""
834
+ import signal
835
+ try:
836
+ argv = [a.decode() for a in open(f"/proc/{pid}/cmdline", "rb").read().split(b"\0") if a]
837
+ pasta = os.readlink(f"/proc/{pid}/cwd")
838
+ ambiente = dict(os.environ)
839
+ except (OSError, UnicodeDecodeError):
840
+ return False, "could not read the app's command line"
841
+ if len(argv) == 1 and " " in argv[0]: # o Chromium reescreve o título do processo: tudo vem numa palavra só
842
+ # shortcut: separa por espaço, então um argumento com espaço no valor quebra; trocar por leitura do `ps -o args` se aparecer
843
+ argv = [os.path.realpath(f"/proc/{pid}/exe")] + argv[0].split(" ")[1:]
844
+ if not argv or not _eh_electron(argv):
845
+ return False, "not an Electron/Chromium app"
846
+ try:
847
+ os.kill(pid, signal.SIGTERM)
848
+ except OSError as erro:
849
+ return False, f"could not close it: {erro}"
850
+ limite = time.time() + esperar
851
+ while time.time() < limite and os.path.exists(f"/proc/{pid}"):
852
+ time.sleep(0.2)
853
+ if os.path.exists(f"/proc/{pid}") and "zombie" not in open(f"/proc/{pid}/status").read().lower():
854
+ return False, "the app did not close (maybe it is asking to save something); it was left open"
855
+ ambiente["ACCESSIBILITY_ENABLED"] = "1"
856
+ subprocess.Popen(comando_com_acessibilidade(argv), cwd=pasta, env=ambiente, stdin=subprocess.DEVNULL,
857
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
858
+ return True, "reopened with accessibility on"
859
+
860
+
861
  # Nomes que são SITES, não programas (18/10: open_application("google") abria o Google Chrome, que só tem "google" no
862
  # nome). Só vira site quando não há um programa com exatamente esse nome: um Spotify ou Discord instalado continua app.
863
  _SITES_NAO_APPS = frozenset({"google", "youtube", "gmail", "wikipedia", "reddit", "amazon", "github", "facebook",
app/nucleo/chat.py CHANGED
@@ -103,7 +103,7 @@ def _print_em_texto(imagem_b64, pedido, modelo, janela=None, arquivo=None):
103
  f"'{arquivo}' was loaded, but you cannot see images and the vision model "
104
  + ("did not answer this time" if visao_instalada() else "is not installed")
105
  + ". Don't retry. Tell the user plainly that you could not look at the image, and that Gargantua 2 "
106
- "is the model that lets you see it (ollama pull ProjectOpenTARS/Gargantua-2). "
107
  f"The user's request was: \"{pedido}\"."
108
  )
109
  return (
@@ -463,6 +463,11 @@ def _mensagens_sem_pensar(mensagens):
463
  return copia
464
 
465
 
 
 
 
 
 
466
  def _familia_do_modelo(modelo):
467
  """A família que o Ollama informa ('qwen3', 'qwen35', ...). Lê o catálogo se ainda não foi lido: no modo de modelo fixo
468
  (o da prova do Lab) ele não era, a família vinha vazia, o Gargantua em Qwen3.5 ia sem think=false, pensava em vez de
@@ -490,7 +495,7 @@ def _executar_turno_streaming(modelo, mensagens, tools, pensar=True):
490
  _carregar_pensa_sem_pedir()
491
  # o "/no_think" é truque do Qwen3; o Qwen3.5 não conhece e o lê como parte do pedido (17/10: abriu o site "/no_think" e
492
  # tentou "apt install no_think"). Ele obedece ao think=false.
493
- forcar_nothink = (not pensar) and modelo in _PENSA_MESMO_SEM_PEDIR and _familia_do_modelo(modelo) != "qwen35"
494
  payload = {
495
  "model": modelo,
496
  "messages": _mensagens_sem_pensar(mensagens) if forcar_nothink else mensagens,
@@ -499,7 +504,7 @@ def _executar_turno_streaming(modelo, mensagens, tools, pensar=True):
499
  "think": bool(pensar),
500
  "options": {"temperature": temperatura_conversa(), "num_ctx": contexto_ia()},
501
  }
502
- if eh_gargantua(modelo) and not pensar and _familia_do_modelo(modelo) != "qwen35":
503
  # Treinado sem raciocínio: com think=false o molde do .gguf abre a resposta com "<think></think>", que no treino só
504
  # vinha antes da resposta final em texto, e o modelo respondia sem chamar ferramenta (prova de 14/10/2026).
505
  # 17/10: o Gargantua em Qwen3.5 é o contrário: o molde dele põe o <think> vazio também antes das ferramentas e o
@@ -831,6 +836,7 @@ _PERGUNTA_DE_IDENTIDADE = re.compile(
831
  r"who (?:are you|made you|created you|built you|developed you|trained you|is your (?:creator|developer|founder|maker))|"
832
  r"what(?:'s| is) your name|your (?:name|creator|developer|founder)|who (?:is|are) (?:the )?(?:founder|creator) of opentars|"
833
  r"(?:fundador|criador|dono|founder|creator) (?:do |da |of )?opentars)\b")
 
834
  _VERBOS_DE_BUSCA = re.compile(r"\b(?:pesquis\w*|busc\w*|procur\w*|search\w*|find|look up|look for|ache|achar)\b")
835
 
836
 
@@ -860,6 +866,10 @@ def _contexto_do_pedido(texto, tarefa, multi_etapas, retoma=None):
860
  linhas.append("About you: your name is Gargantua. You were developed by Enzo, the founder of openTARS, and you run "
861
  "inside the openTARS app (openTARS is the app, Gargantua is you). Say so in your own words; never "
862
  "name another company or model as your maker.")
 
 
 
 
863
  sites = _sites_citados_na_busca(texto_n)
864
  if sites:
865
  linhas.append("To search INSIDE a site, call search_web with query = only the search terms and service = the site "
@@ -1649,6 +1659,17 @@ def _gerar_imagem_pelo_chat(texto):
1649
  return _responder_sem_modelo(texto, t("imagem.pronta", arquivo=arquivo.name, pasta=arquivo.parent) + f"\n\n![{o_que}]({arquivo})")
1650
 
1651
 
 
 
 
 
 
 
 
 
 
 
 
1652
  def processar_mensagem(texto, anexos=None):
1653
  """Um pedido do usuário do começo ao fim: comandos (/modelo,
1654
  /limpar...), escolha do modelo, resposta e salvamento. Usado pelo
@@ -1684,6 +1705,10 @@ def processar_mensagem(texto, anexos=None):
1684
  return gerada
1685
  import tars_imagem
1686
  tars_imagem.parar() # o Bonsai segura a placa; a conversa (Ollama) precisa dela agora
 
 
 
 
1687
 
1688
  # "use a ferramenta", "faz de verdade", "try again": a tarefa e o modelo saem do pedido anterior, que é o que vai ser feito
1689
  retoma = _ultimo_pedido_real() if not anexos and escolha.eh_retomada(normalizar(texto)) else None
 
103
  f"'{arquivo}' was loaded, but you cannot see images and the vision model "
104
  + ("did not answer this time" if visao_instalada() else "is not installed")
105
  + ". Don't retry. Tell the user plainly that you could not look at the image, and that Gargantua 2 "
106
+ "is the model that lets you see it (ollama pull ProjectOpenTARS/Gargantua-2.5). "
107
  f"The user's request was: \"{pedido}\"."
108
  )
109
  return (
 
463
  return copia
464
 
465
 
466
+ # Famílias do Gargantua que obedecem ao think=false e não conhecem o "/no_think" do Qwen3: o Qwen3.5 (2 e Lite de hoje) e o
467
+ # Gemma 4 (Gargantua 2.5, 10/11: o molde do Gemma 4 põe o pensamento vazio sozinho quando enable_thinking é falso).
468
+ _FAMILIAS_THINK_FALSE = ("qwen35", "gemma4")
469
+
470
+
471
  def _familia_do_modelo(modelo):
472
  """A família que o Ollama informa ('qwen3', 'qwen35', ...). Lê o catálogo se ainda não foi lido: no modo de modelo fixo
473
  (o da prova do Lab) ele não era, a família vinha vazia, o Gargantua em Qwen3.5 ia sem think=false, pensava em vez de
 
495
  _carregar_pensa_sem_pedir()
496
  # o "/no_think" é truque do Qwen3; o Qwen3.5 não conhece e o lê como parte do pedido (17/10: abriu o site "/no_think" e
497
  # tentou "apt install no_think"). Ele obedece ao think=false.
498
+ forcar_nothink = (not pensar) and modelo in _PENSA_MESMO_SEM_PEDIR and _familia_do_modelo(modelo) not in _FAMILIAS_THINK_FALSE
499
  payload = {
500
  "model": modelo,
501
  "messages": _mensagens_sem_pensar(mensagens) if forcar_nothink else mensagens,
 
504
  "think": bool(pensar),
505
  "options": {"temperature": temperatura_conversa(), "num_ctx": contexto_ia()},
506
  }
507
+ if eh_gargantua(modelo) and not pensar and _familia_do_modelo(modelo) not in _FAMILIAS_THINK_FALSE:
508
  # Treinado sem raciocínio: com think=false o molde do .gguf abre a resposta com "<think></think>", que no treino só
509
  # vinha antes da resposta final em texto, e o modelo respondia sem chamar ferramenta (prova de 14/10/2026).
510
  # 17/10: o Gargantua em Qwen3.5 é o contrário: o molde dele põe o <think> vazio também antes das ferramentas e o
 
836
  r"who (?:are you|made you|created you|built you|developed you|trained you|is your (?:creator|developer|founder|maker))|"
837
  r"what(?:'s| is) your name|your (?:name|creator|developer|founder)|who (?:is|are) (?:the )?(?:founder|creator) of opentars|"
838
  r"(?:fundador|criador|dono|founder|creator) (?:do |da |of )?opentars)\b")
839
+ _PEDE_CALCULADORA = re.compile(r"\b(?:calculadora|calculator)\b")
840
  _VERBOS_DE_BUSCA = re.compile(r"\b(?:pesquis\w*|busc\w*|procur\w*|search\w*|find|look up|look for|ache|achar)\b")
841
 
842
 
 
866
  linhas.append("About you: your name is Gargantua. You were developed by Enzo, the founder of openTARS, and you run "
867
  "inside the openTARS app (openTARS is the app, Gargantua is you). Say so in your own words; never "
868
  "name another company or model as your maker.")
869
+ if _PEDE_CALCULADORA.search(texto_n):
870
+ # 10/11: o Gemma 4 trocava "quanto é 310 mais 506? usa a calculadora" por search_web (3 de 3); o Lite de 23/10 também
871
+ linhas.append("The user wants the Calculator app: call open_application with name=\"calculator\" and do the math "
872
+ "there. Do not use search_web for it.")
873
  sites = _sites_citados_na_busca(texto_n)
874
  if sites:
875
  linhas.append("To search INSIDE a site, call search_web with query = only the search terms and service = the site "
 
1659
  return _responder_sem_modelo(texto, t("imagem.pronta", arquivo=arquivo.name, pasta=arquivo.parent) + f"\n\n![{o_que}]({arquivo})")
1660
 
1661
 
1662
+ def _resolver_sem_modelo(texto):
1663
+ """5.3 (F4): o que sai por regra, sem o modelo (tars_atalhos): estado do PC, memória, contas, arquivos, vários passos e
1664
+ perguntas sobre o mundo pelo SearXNG. None: segue para o modelo. Um erro aqui nunca impede a conversa."""
1665
+ try:
1666
+ import tars_atalhos
1667
+ return tars_atalhos.resolver(texto, sys.modules[__name__])
1668
+ except Exception as e: # noqa: BLE001
1669
+ logging.warning("atalhos sem modelo falharam: %s", e)
1670
+ return None
1671
+
1672
+
1673
  def processar_mensagem(texto, anexos=None):
1674
  """Um pedido do usuário do começo ao fim: comandos (/modelo,
1675
  /limpar...), escolha do modelo, resposta e salvamento. Usado pelo
 
1705
  return gerada
1706
  import tars_imagem
1707
  tars_imagem.parar() # o Bonsai segura a placa; a conversa (Ollama) precisa dela agora
1708
+ if not anexos:
1709
+ direta = _resolver_sem_modelo(texto)
1710
+ if direta is not None:
1711
+ return _responder_sem_modelo(texto, direta)
1712
 
1713
  # "use a ferramenta", "faz de verdade", "try again": a tarefa e o modelo saem do pedido anterior, que é o que vai ser feito
1714
  retoma = _ultimo_pedido_real() if not anexos and escolha.eh_retomada(normalizar(texto)) else None
app/nucleo/controle.py CHANGED
@@ -264,7 +264,17 @@ def _abrir_em_janela_de_terminal(comando, nome):
264
  "Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")}
265
 
266
 
 
 
 
 
 
 
 
 
 
267
  def _executar_terminal_de_fato(comando):
 
268
 
269
  # 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a
270
  # saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema.
@@ -563,7 +573,7 @@ def _mirar_bruto(x=None, y=None, alvo=None, janela=None):
563
  return ox + nx, oy + ny, como
564
  if not visao_instalada():
565
  return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is "
566
- "installed (ollama pull ProjectOpenTARS/Gargantua-2). Pass x/y from a screenshot.")
567
  return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). "
568
  "Pass x/y from a screenshot.")
569
 
 
264
  "Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")}
265
 
266
 
267
+ def til_entre_aspas(comando):
268
+ """O modelo escreve mkdir -p "~/Documentos/Jogos": o bash não expande o ~ entre aspas e cria uma pasta chamada "~" na pasta
269
+ atual, e a ferramenta ainda diz que deu certo (Gargantua 2.5 em Gemma 4, 10/11: 4 tarefas de pasta da prova de 80). O ~ sai
270
+ das aspas e o resto fica entre elas (~/"Área de Trabalho/x"): vale no bash e no mini-shell."""
271
+ comando = re.sub(r'"~/([^"]*)"', r'~/"\1"', comando)
272
+ comando = re.sub(r"'~/([^']*)'", r"~/'\1'", comando)
273
+ return re.sub(r'(["\'])~\1', "~", comando)
274
+
275
+
276
  def _executar_terminal_de_fato(comando):
277
+ comando = til_entre_aspas(comando)
278
 
279
  # 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a
280
  # saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema.
 
573
  return ox + nx, oy + ny, como
574
  if not visao_instalada():
575
  return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is "
576
+ "installed (ollama pull ProjectOpenTARS/Gargantua-2.5). Pass x/y from a screenshot.")
577
  return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). "
578
  "Pass x/y from a screenshot.")
579
 
app/nucleo/elementos.py CHANGED
@@ -218,6 +218,16 @@ def _janela_acessivel(nome):
218
  if alvo is not None:
219
  alvo = dict(alvo, aberto_agora=True)
220
 
 
 
 
 
 
 
 
 
 
 
221
  if alvo is None:
222
  if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
223
  _marcar_sem_arvore(nome)
 
218
  if alvo is not None:
219
  alvo = dict(alvo, aberto_agora=True)
220
 
221
+ # 11/11 (F11): app Electron já aberto sem árvore: com a permissão da pessoa, reabre com a acessibilidade ligada e procura de novo
222
+ if alvo is None and nome and i18n.ler_config().get("reabrir_com_acessibilidade") and not _sem_arvore(nome):
223
+ janela_x = encontrar_janela_por_nome(nome)
224
+ if janela_x and janela_x.get("pid"):
225
+ ok, _ = reabrir_com_acessibilidade(janela_x["pid"])
226
+ limite = time.time() + (TIMEOUT_JANELA_ACESSIVEL_SEG if ok else 0)
227
+ while ok and alvo is None and time.time() < limite:
228
+ time.sleep(0.5)
229
+ alvo = procurar()
230
+
231
  if alvo is None:
232
  if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
233
  _marcar_sem_arvore(nome)
app/nucleo/gargantua.py CHANGED
@@ -19,7 +19,7 @@
19
 
20
  # ollama pull ProjectOpenTARS/Gargantua-2 (18/10: o Gargantua 2, Qwen3.5 2B com visão, é o principal; o repositório
21
  # antigo, ProjectOpenTARS/Gargantua, continua servindo quem já o tem e os apps antigos)
22
- MODELO_GARGANTUA = os.environ.get("TARS_MODELO_GARGANTUA", "ProjectOpenTARS/Gargantua-2")
23
  REPOSITORIO_ANTIGO = "ProjectOpenTARS/Gargantua"
24
  VERSAO_GARGANTUA = "1.2" # a do kit (gargantua/gargantua.py): 19 ferramentas
25
 
@@ -62,9 +62,16 @@ def gargantua_desligado():
62
  return normalizar(os.environ.get("TARS_SEM_GARGANTUA") or "") in ("1", "sim", "yes", "on", "true")
63
 
64
 
 
 
 
 
 
 
 
65
  def eh_gargantua(modelo):
66
- """'gargantua', 'gargantua:1.0', 'ProjectOpenTARS/Gargantua:latest'..."""
67
- return str(modelo or "").strip().lower().split("/")[-1].startswith("gargantua")
68
 
69
 
70
  def e_gargantua_2(modelo):
@@ -73,23 +80,23 @@ def e_gargantua_2(modelo):
73
 
74
 
75
  def variante_do_gargantua(modelo):
76
- """'lite' ou 'code' se o nome ou a etiqueta dizem que é uma VARIANTE do Gargantua (gargantua-code,
77
  ProjectOpenTARS/Gargantua:code, Gargantua:1.2-lite); '' se é o oficial (ou qualquer outro Gargantua).
78
  A mesma regra está no kit (gargantua.py: variante_do_nome) e em tars_primeiro_uso.py."""
79
  base, _, tag = str(modelo or "").strip().lower().partition(":")
80
- segmento = base.split("/")[-1]
81
  partes = re.split(r"[-_. ]", segmento[len("gargantua"):] if segmento.startswith("gargantua") else segmento)
82
  for parte in partes + re.split(r"[-_. ]", tag):
83
- if parte in ("lite", "code"):
84
  return parte
85
  return ""
86
 
87
 
88
  def ordem_do_gargantua(tag):
89
- """Quem vai na frente quando há vários Gargantua: o Gargantua 2, o oficial antigo, os outros, o Code e por fim o Lite."""
90
  variante = variante_do_gargantua(tag)
91
  if variante:
92
- return {"code": 3, "lite": 4}[variante]
93
  if e_gargantua_2(tag):
94
  return 0
95
  return 1 if str(tag).lower().startswith(REPOSITORIO_ANTIGO.lower()) else 2
 
19
 
20
  # ollama pull ProjectOpenTARS/Gargantua-2 (18/10: o Gargantua 2, Qwen3.5 2B com visão, é o principal; o repositório
21
  # antigo, ProjectOpenTARS/Gargantua, continua servindo quem já o tem e os apps antigos)
22
+ MODELO_GARGANTUA = os.environ.get("TARS_MODELO_GARGANTUA", "ProjectOpenTARS/Gargantua-2.5")
23
  REPOSITORIO_ANTIGO = "ProjectOpenTARS/Gargantua"
24
  VERSAO_GARGANTUA = "1.2" # a do kit (gargantua/gargantua.py): 19 ferramentas
25
 
 
62
  return normalizar(os.environ.get("TARS_SEM_GARGANTUA") or "") in ("1", "sim", "yes", "on", "true")
63
 
64
 
65
+ def _segmento_gargantua(base):
66
+ """O pedaço do nome que diz Gargantua. Um nome local com barra ('Gargantua-lite-teste-4/11') vira namespace/modelo no
67
+ Ollama: olhando só o último pedaço, o app tratava o modelo como outro qualquer, ligava o raciocínio e trocava o prompt."""
68
+ partes = str(base or "").strip().lower().split("/")
69
+ return next((p for p in partes if p.startswith("gargantua")), partes[-1])
70
+
71
+
72
  def eh_gargantua(modelo):
73
+ """'gargantua', 'gargantua:1.0', 'ProjectOpenTARS/Gargantua:latest', 'Gargantua-teste-4/11'..."""
74
+ return _segmento_gargantua(modelo).startswith("gargantua")
75
 
76
 
77
  def e_gargantua_2(modelo):
 
80
 
81
 
82
  def variante_do_gargantua(modelo):
83
+ """'lite', 'code', 'pro' ou 'pi' se o nome ou a etiqueta dizem que é uma VARIANTE do Gargantua (gargantua-code,
84
  ProjectOpenTARS/Gargantua:code, Gargantua:1.2-lite); '' se é o oficial (ou qualquer outro Gargantua).
85
  A mesma regra está no kit (gargantua.py: variante_do_nome) e em tars_primeiro_uso.py."""
86
  base, _, tag = str(modelo or "").strip().lower().partition(":")
87
+ segmento = _segmento_gargantua(base)
88
  partes = re.split(r"[-_. ]", segmento[len("gargantua"):] if segmento.startswith("gargantua") else segmento)
89
  for parte in partes + re.split(r"[-_. ]", tag):
90
+ if parte in ("lite", "code", "pro", "pi"):
91
  return parte
92
  return ""
93
 
94
 
95
  def ordem_do_gargantua(tag):
96
+ """Quem vai na frente quando há vários Gargantua: o Gargantua 2, o oficial antigo, os outros, o Code e o Pro, o Lite e por fim o Pi."""
97
  variante = variante_do_gargantua(tag)
98
  if variante:
99
+ return {"code": 3, "pro": 3, "lite": 4, "pi": 5}[variante]
100
  if e_gargantua_2(tag):
101
  return 0
102
  return 1 if str(tag).lower().startswith(REPOSITORIO_ANTIGO.lower()) else 2
app/nucleo/hardware.py CHANGED
@@ -375,6 +375,9 @@ def _categorizar_modelo(tag, capacidades, parametros_b, familia):
375
  return "embedding"
376
  if re.search(r"(^|/)(flux|z-image)", nome):
377
  return "embedding"
 
 
 
378
  # Ollama antigo não informa capacidades: reconhece pelo nome os modelos
379
  # de embeddings conhecidos (granite-embedding, nomic-embed-text...).
380
  if not capacidades and re.search(r"embed|minilm|bge-|paraphrase-multilingual|snowflake-arctic|e5-", nome):
@@ -494,9 +497,8 @@ def modelo_ajudante(catalogo=None):
494
  um nome fixo que pode nem estar instalado nesta máquina."""
495
 
496
  catalogo = catalogo if catalogo is not None else catalogar_modelos()
497
- # O Cooper só olha a tela e embedding não conversa: nenhum dos dois resolve nome de app.
498
- catalogo = {t_: m for t_, m in catalogo.items()
499
- if m["categoria"] != "embedding" and not visao_lib.e_cooper(t_) and not m.get("nuvem")}
500
 
501
  candidatos = modelos_com_capacidade(catalogo, "tools") or [
502
  m for m in catalogo.values() if m["categoria"] != "embedding"
@@ -544,8 +546,6 @@ def construir_cadeia_fallback(tarefa, catalogo=None):
544
  candidatos = [m for m in catalogo.values() if m["categoria"] != "embedding" and not m.get("nuvem")]
545
  # Os ajudantes não conversam: só entram se não houver mais nada.
546
  candidatos = [m for m in candidatos if m["tag"] not in MODELOS_AJUDANTES] or candidatos
547
- # O Cooper nunca conversa, nem como último recurso (ele só responde sobre prints).
548
- candidatos = [m for m in candidatos if not visao_lib.e_cooper(m["tag"])]
549
  if not candidatos:
550
  return []
551
 
 
375
  return "embedding"
376
  if re.search(r"(^|/)(flux|z-image)", nome):
377
  return "embedding"
378
+ # 5.3: o Gargantua Pi tem prompt e 4 ferramentas próprios, do openTARS Pi: com as 19 do app grande ele só atrapalharia.
379
+ if variante_do_gargantua(tag) == "pi":
380
+ return "embedding"
381
  # Ollama antigo não informa capacidades: reconhece pelo nome os modelos
382
  # de embeddings conhecidos (granite-embedding, nomic-embed-text...).
383
  if not capacidades and re.search(r"embed|minilm|bge-|paraphrase-multilingual|snowflake-arctic|e5-", nome):
 
497
  um nome fixo que pode nem estar instalado nesta máquina."""
498
 
499
  catalogo = catalogo if catalogo is not None else catalogar_modelos()
500
+ # Embedding não conversa: não resolve nome de app.
501
+ catalogo = {t_: m for t_, m in catalogo.items() if m["categoria"] != "embedding" and not m.get("nuvem")}
 
502
 
503
  candidatos = modelos_com_capacidade(catalogo, "tools") or [
504
  m for m in catalogo.values() if m["categoria"] != "embedding"
 
546
  candidatos = [m for m in catalogo.values() if m["categoria"] != "embedding" and not m.get("nuvem")]
547
  # Os ajudantes não conversam: só entram se não houver mais nada.
548
  candidatos = [m for m in candidatos if m["tag"] not in MODELOS_AJUDANTES] or candidatos
 
 
549
  if not candidatos:
550
  return []
551
 
app/nucleo/nuvem.py CHANGED
@@ -28,7 +28,7 @@ def servidor_api():
28
  dados = nuvem_config()
29
  if not (dados["ativa"] and dados["endereco"] and dados["chave"]):
30
  return ""
31
- return dados["endereco"].rstrip("/")
32
 
33
 
34
  def _cabecalhos_api():
 
28
  dados = nuvem_config()
29
  if not (dados["ativa"] and dados["endereco"] and dados["chave"]):
30
  return ""
31
+ return api.normalizar_url(dados["endereco"]) # "localhost:1234" escrito à mão não chegava a lugar nenhum
32
 
33
 
34
  def _cabecalhos_api():
app/nucleo/ollama.py CHANGED
@@ -80,9 +80,8 @@ def _chat_unico(
80
  "keep_alive": keep_alive,
81
  "options": {"temperature": temperatura},
82
  }
83
- if not visao_lib.e_cooper(modelo):
84
- # mesmo num_ctx do chat: senão o Ollama recarrega o modelo a cada consulta de bastidor
85
- corpo["options"]["num_ctx"] = contexto_ia()
86
  if opcoes:
87
  corpo["options"].update(opcoes)
88
 
@@ -189,6 +188,26 @@ def aquecer_modelo_da_conversa():
189
  threading.Thread(target=_pedir, daemon=True).start()
190
 
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  def descarregar_modelo(modelo):
193
  """Pede ao Ollama para descarregar um modelo (keep_alive=0).
194
  Retorna True se a chamada foi aceita — isso NÃO garante que o
@@ -218,7 +237,7 @@ def descarregar_modelo(modelo):
218
  def ollama_online():
219
  """O servidor do Ollama responde no endereço configurado?"""
220
  try:
221
- return _SESSION.get(f"{OLLAMA_HOST}/api/version", timeout=TIMEOUT_HTTP_CURTO).status_code == 200
222
  except Exception:
223
  return False
224
 
 
80
  "keep_alive": keep_alive,
81
  "options": {"temperature": temperatura},
82
  }
83
+ # mesmo num_ctx do chat: senão o Ollama recarrega o modelo a cada consulta de bastidor
84
+ corpo["options"]["num_ctx"] = contexto_ia()
 
85
  if opcoes:
86
  corpo["options"].update(opcoes)
87
 
 
188
  threading.Thread(target=_pedir, daemon=True).start()
189
 
190
 
191
+ _KEEP_ALIVE_EM_SEGUNDO_PLANO = KEEP_ALIVE
192
+
193
+
194
+ def manter_modelo_com_janela(aberta):
195
+ """Janela aberta: o modelo fica na memória e responder não recarrega. Janela escondida (bandeja) ou fechada: volta ao tempo
196
+ normal, para não gastar memória em segundo plano. TARS_KEEP_ALIVE escolhido à mão continua valendo."""
197
+ global KEEP_ALIVE
198
+ if os.environ.get("TARS_KEEP_ALIVE"):
199
+ return
200
+ # O número -1, não o texto "-1": o Ollama 0.40.13 recusa "-1" (HTTP 400, 'time: missing unit in duration') e todo pedido falhava.
201
+ KEEP_ALIVE = -1 if aberta else _KEEP_ALIVE_EM_SEGUNDO_PLANO
202
+ # o Ollama guarda o tempo do último pedido: sem este aviso, o modelo carregado com -1 nunca sairia da memória
203
+ for modelo in (listar_modelos_rodando() or set()) & (modelos_do_tars | ({modelo_atual} if modelo_atual else set())):
204
+ try:
205
+ _SESSION.post(OLLAMA_GENERATE_URL, json={"model": modelo, "prompt": "", "keep_alive": KEEP_ALIVE,
206
+ "options": {"num_ctx": contexto_ia()}}, timeout=TIMEOUT_HTTP_DESCARREGAR)
207
+ except Exception:
208
+ pass
209
+
210
+
211
  def descarregar_modelo(modelo):
212
  """Pede ao Ollama para descarregar um modelo (keep_alive=0).
213
  Retorna True se a chamada foi aceita — isso NÃO garante que o
 
237
  def ollama_online():
238
  """O servidor do Ollama responde no endereço configurado?"""
239
  try:
240
+ return _SESSION.get(motor_lib.url("versao", OLLAMA_HOST), timeout=TIMEOUT_HTTP_CURTO).status_code == 200
241
  except Exception:
242
  return False
243
 
app/nucleo/registro.py CHANGED
@@ -434,7 +434,7 @@ class _Girando:
434
  quadro = self.QUADROS[i % len(self.QUADROS)]
435
  segundos = int(time.time() - self.desde)
436
  linha = (f"\r\033[K{Cor.MAGENTA}{quadro}{Cor.RESET} {self.texto}… "
437
- f"{Cor.CINZA}({segundos}s · {t('term.parar')}){Cor.RESET}")
438
  self.saida.write(linha)
439
  self.saida.flush()
440
  self.visivel = True
@@ -519,7 +519,9 @@ def _term_resposta(d):
519
  if d.get("apos_pensamento"):
520
  print()
521
  print("\n" + c("● ", Cor.VERDE), end="")
522
- print(_recuar(d["texto"]), end="", flush=True)
 
 
523
 
524
 
525
  def _term_tempo(d):
@@ -554,7 +556,7 @@ _TERMINAL = {
554
  "descarregando": _term_descarregando,
555
  "pensamento": _term_pensamento,
556
  "resposta": _term_resposta,
557
- "fim_stream": lambda d: print(flush=True),
558
  "tempo": _term_tempo,
559
  "folego": _term_folego,
560
  "analisando": _term_analisando,
 
434
  quadro = self.QUADROS[i % len(self.QUADROS)]
435
  segundos = int(time.time() - self.desde)
436
  linha = (f"\r\033[K{Cor.MAGENTA}{quadro}{Cor.RESET} {self.texto}… "
437
+ f"{Cor.CINZA}({_tempo_curto(segundos)} · {t('term.parar')}){Cor.RESET}")
438
  self.saida.write(linha)
439
  self.saida.flush()
440
  self.visivel = True
 
519
  if d.get("apos_pensamento"):
520
  print()
521
  print("\n" + c("● ", Cor.VERDE), end="")
522
+ _MD.reiniciar()
523
+ texto = _MD.alimentar(d["texto"]) if _girando.ligado else d["texto"] # 5.3: formatado só num terminal de verdade
524
+ print(_recuar(texto), end="", flush=True)
525
 
526
 
527
  def _term_tempo(d):
 
556
  "descarregando": _term_descarregando,
557
  "pensamento": _term_pensamento,
558
  "resposta": _term_resposta,
559
+ "fim_stream": lambda d: print(_recuar(_MD.terminar()) if _girando.ligado else "", flush=True),
560
  "tempo": _term_tempo,
561
  "folego": _term_folego,
562
  "analisando": _term_analisando,
app/nucleo/selecao.py CHANGED
@@ -582,9 +582,9 @@ _CRITERIOS_MODELO = {
582
 
583
 
584
  def _so_de_conversa(catalogo):
585
- """"/modelo o menor", "o de visão"...: só entra modelo que conversa (nem embedding, nem o Cooper)."""
586
  return {t_: m for t_, m in catalogo.items()
587
- if m["categoria"] != "embedding" and not visao_lib.e_cooper(t_) and not m.get("nuvem")}
588
 
589
 
590
  def _extremo_por_tamanho(catalogo, menor):
 
582
 
583
 
584
  def _so_de_conversa(catalogo):
585
+ """"/modelo o menor", "o de visão"...: só entra modelo que conversa (não embedding)."""
586
  return {t_: m for t_, m in catalogo.items()
587
+ if m["categoria"] != "embedding" and not m.get("nuvem")}
588
 
589
 
590
  def _extremo_por_tamanho(catalogo, menor):
app/nucleo/terminal.py CHANGED
@@ -216,14 +216,7 @@ def _tratar_comando_do_chat(linha):
216
 
217
 
218
  def _linha_de_status():
219
- modelo = modelo_atual or (modelo_manual if modo_modelo == "MANUAL" else "auto")
220
- usados = estimar_tokens(conversa)
221
- partes = [str(modelo), t("cli.status_contexto", pct=min(100, int(100 * usados / max(1, contexto_ia()))))]
222
- if MODO_PERMISSAO != "perguntar":
223
- partes.append(t(f"cli.status_modo.{MODO_PERMISSAO}"))
224
- if CONTEXTO_PROJETO["caminho"]:
225
- partes.append("AGENTS.md")
226
- print(c(" " + " · ".join(partes), Cor.CINZA))
227
 
228
 
229
  def _mostrar_plano():
@@ -285,12 +278,16 @@ def _boas_vindas(pedidos):
285
  else:
286
  garg = gargantua_instalado() if online else None
287
  modelo = t("term.modelo_auto") + (f" · {garg.split('/')[-1]}" if garg else "")
288
- ollama = (c("●", Cor.VERDE) + " " + t("term.ollama_online", host=OLLAMA_HOST) if online
289
- else c("●", Cor.VERMELHO) + " " + t("term.ollama_offline", host=OLLAMA_HOST))
290
- linhas = [c("✻ ", Cor.MAGENTA) + c(t("term.bem_vindo"), Cor.NEGRITO), "",
 
 
 
291
  c(" " + t("chat.dica"), Cor.CINZA), "",
292
- c(" " + t("term.rotulo_ollama") + " ", Cor.CINZA) + ollama,
293
- c(" " + t("term.rotulo_modelo") + " ", Cor.CINZA) + modelo]
 
294
  if pedidos:
295
  linhas.append(c(" " + t("term.conversa_carregada", n=pedidos), Cor.CINZA))
296
  print()
@@ -376,14 +373,20 @@ def _conversar(pedido_unico, retomar=None):
376
  if retomar == "escolher":
377
  _escolher_conversa()
378
  interativo = sys.stdin.isatty()
 
 
379
  ctrl_c = 0
380
  try:
381
  while True:
382
  try:
383
  # \001..\002 avisam o readline que as cores não ocupam coluna (senão a edição da linha embaralha)
384
  ini, fim = ("\001", "\002") if TEM_READLINE else ("", "")
385
- linha = input("\n" + ini + Cor.NEGRITO + Cor.VERDE + fim + "❯ "
386
- + ini + Cor.RESET + fim if interativo else "").strip()
 
 
 
 
387
  except EOFError:
388
  print()
389
  break
@@ -404,7 +407,7 @@ def _conversar(pedido_unico, retomar=None):
404
  _executar_pedido_no_terminal(linha)
405
  _mostrar_plano()
406
  _guardar_no_historico()
407
- if interativo:
408
  _linha_de_status()
409
  finally:
410
  if ollama_online():
@@ -508,13 +511,6 @@ def modo_print(pedido, formato="text", modelo=None):
508
  return 0 if ok else 1
509
 
510
 
511
- def comando_cooper(instalar=True):
512
- """opentars --cooper: o Cooper foi descontinuado (18/10/2026); o Gargantua 2 enxerga a tela. Só avisa e diz como instalar."""
513
- print(c(t("cooper.descontinuado", comando=visao_lib.COMANDO_INSTALAR), Cor.AMARELO))
514
- return 0
515
-
516
-
517
-
518
  # ------------------------------------------------------------
519
  # Logo no terminal: o monólito do TARS (quatro colunas e a faixa verde, como o ícone) com o nome ao lado.
520
  # Só aparece num terminal de verdade (não em pipe nem em script) e respeita NO_COLOR.
@@ -598,6 +594,10 @@ def main():
598
  import tars_prova
599
  sys.exit(tars_prova.executar(sys.modules[__name__], argumentos))
600
 
 
 
 
 
601
  if any(a in ("--desinstalar", "--uninstall") for a in argumentos):
602
  import tars_desinstalar
603
  sys.exit(tars_desinstalar.executar(OLLAMA_HOST, t))
@@ -627,10 +627,6 @@ def main():
627
  if "--searxng" in argumentos:
628
  sys.exit(comando_searxng())
629
 
630
- # --cooper: o Cooper foi descontinuado, o comando só avisa
631
- if any(a in ("--cooper",) for a in argumentos):
632
- sys.exit(comando_cooper())
633
-
634
  # 5.1: modo de permissão (perguntar, plano, automático) e qual conversa retomar
635
  global MODO_PERMISSAO
636
  if any(a in ("--plan", "--plano") for a in argumentos):
@@ -665,6 +661,8 @@ def main():
665
  palavras.append(arg)
666
  i += 1
667
  pedido = " ".join(palavras).strip()
 
 
668
  sys.exit(modo_chat_terminal(pedido_unico=pedido or None, retomar=retomar))
669
 
670
  # --voz: fica ouvindo "TARS" e responde falando (nucleo/voz.py)
 
216
 
217
 
218
  def _linha_de_status():
219
+ print(c(" " + " · ".join(_partes_do_status()), Cor.CINZA))
 
 
 
 
 
 
 
220
 
221
 
222
  def _mostrar_plano():
 
278
  else:
279
  garg = gargantua_instalado() if online else None
280
  modelo = t("term.modelo_auto") + (f" · {garg.split('/')[-1]}" if garg else "")
281
+ nome_motor = _nome_do_motor()
282
+ motor_linha = (c("●", Cor.VERDE) + " " + nome_motor + " · " + t("term.ollama_online", host=OLLAMA_HOST) if online
283
+ else c("●", Cor.VERMELHO) + " " + nome_motor + " · "
284
+ + t("term.ollama_offline" if nome_motor == "Ollama" else "term.motor_offline", host=OLLAMA_HOST))
285
+ ramo = _ramo_git()
286
+ linhas = [c("✻ ", Cor.MAGENTA) + c(t("term.bem_vindo"), Cor.NEGRITO) + c(f" {VERSAO_EXIBIDA}", Cor.CINZA), "",
287
  c(" " + t("chat.dica"), Cor.CINZA), "",
288
+ c(" " + t("term.rotulo_motor") + " ", Cor.CINZA) + motor_linha,
289
+ c(" " + t("term.rotulo_modelo") + " ", Cor.CINZA) + modelo,
290
+ c(" " + t("term.rotulo_pasta") + " ", Cor.CINZA) + _pasta_curta() + (c(f" ⎇ {ramo}", Cor.CINZA) if ramo else "")]
291
  if pedidos:
292
  linhas.append(c(" " + t("term.conversa_carregada", n=pedidos), Cor.CINZA))
293
  print()
 
373
  if retomar == "escolher":
374
  _escolher_conversa()
375
  interativo = sys.stdin.isatty()
376
+ # 5.3 (F8, só aparência): caixa de entrada do Claude Code com a barra do opencode; sem readline ou sem cor, a régua de antes
377
+ caixa = interativo and sys.stdout.isatty() and TEM_READLINE and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
378
  ctrl_c = 0
379
  try:
380
  while True:
381
  try:
382
  # \001..\002 avisam o readline que as cores não ocupam coluna (senão a edição da linha embaralha)
383
  ini, fim = ("\001", "\002") if TEM_READLINE else ("", "")
384
+ if caixa:
385
+ linha = _pedir_na_caixa(ini, fim).strip()
386
+ else:
387
+ if interativo:
388
+ print("\n" + _regua(MODO_PERMISSAO)) # 5.3: régua com o modo da conversa, como a barra do opencode
389
+ linha = input(ini + Cor.NEGRITO + Cor.VERDE + fim + "❯ " + ini + Cor.RESET + fim if interativo else "").strip()
390
  except EOFError:
391
  print()
392
  break
 
407
  _executar_pedido_no_terminal(linha)
408
  _mostrar_plano()
409
  _guardar_no_historico()
410
+ if interativo and not caixa: # com a caixa, a barra de estado já vem embaixo dela
411
  _linha_de_status()
412
  finally:
413
  if ollama_online():
 
511
  return 0 if ok else 1
512
 
513
 
 
 
 
 
 
 
 
514
  # ------------------------------------------------------------
515
  # Logo no terminal: o monólito do TARS (quatro colunas e a faixa verde, como o ícone) com o nome ao lado.
516
  # Só aparece num terminal de verdade (não em pipe nem em script) e respeita NO_COLOR.
 
594
  import tars_prova
595
  sys.exit(tars_prova.executar(sys.modules[__name__], argumentos))
596
 
597
+ if "--missao-vm" in argumentos: # 5.3 (F7): missão do Gargantua Pro numa VM descartável
598
+ import tars_vm_cli
599
+ sys.exit(tars_vm_cli.executar(OLLAMA_HOST, argumentos, t, instalados=lambda: primeiro_uso.modelos_instalados(OLLAMA_HOST)))
600
+
601
  if any(a in ("--desinstalar", "--uninstall") for a in argumentos):
602
  import tars_desinstalar
603
  sys.exit(tars_desinstalar.executar(OLLAMA_HOST, t))
 
627
  if "--searxng" in argumentos:
628
  sys.exit(comando_searxng())
629
 
 
 
 
 
630
  # 5.1: modo de permissão (perguntar, plano, automático) e qual conversa retomar
631
  global MODO_PERMISSAO
632
  if any(a in ("--plan", "--plano") for a in argumentos):
 
661
  palavras.append(arg)
662
  i += 1
663
  pedido = " ".join(palavras).strip()
664
+ if not pedido and sys.stdin.isatty():
665
+ _mostrar_logo() # 5.3: a conversa abre com o logo, como sem argumento
666
  sys.exit(modo_chat_terminal(pedido_unico=pedido or None, retomar=retomar))
667
 
668
  # --voz: fica ouvindo "TARS" e responde falando (nucleo/voz.py)
app/nucleo/visao.py CHANGED
@@ -76,7 +76,7 @@ def _visao_cabe_junto(tag):
76
  catalogo = catalogar_modelos()
77
  meu = (catalogo.get(tag) or {}).get("vram_estimado_gb")
78
  outros = [m.get("vram_estimado_gb") for m in catalogo.values()
79
- if not visao_lib.e_cooper(m["tag"]) and m.get("categoria") != "embedding"
80
  and m.get("vram_estimado_gb") is not None]
81
  if not gpu or meu is None or not outros:
82
  return False
 
76
  catalogo = catalogar_modelos()
77
  meu = (catalogo.get(tag) or {}).get("vram_estimado_gb")
78
  outros = [m.get("vram_estimado_gb") for m in catalogo.values()
79
+ if m.get("categoria") != "embedding"
80
  and m.get("vram_estimado_gb") is not None]
81
  if not gpu or meu is None or not outros:
82
  return False
app/nucleo/visual.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # openTARS — nucleo/visual.py
2
+ # Aparência do terminal novo da 5.3 (F8): um híbrido do CLI do Claude Code (● passo, ⎿ resultado, resposta formatada, aviso
3
+ # de contexto) com o do opencode (régua com o modo da conversa e barra de estado com pasta e ramo). Só desenho: nada aqui
4
+ # decide o que a IA faz. Parte do núcleo: roda dentro do namespace do tars.py, como as outras (ver _carregar_nucleo).
5
+ # flake8: noqa
6
+
7
+ import re as _re
8
+ import shutil as _shutil
9
+
10
+
11
+ def _largura_do_terminal(maximo=100):
12
+ return max(40, min(maximo, _shutil.get_terminal_size((80, 20)).columns))
13
+
14
+
15
+ def _tempo_curto(segundos):
16
+ """12 -> "12s"; 65 -> "1m 05s"."""
17
+ segundos = int(segundos)
18
+ return f"{segundos}s" if segundos < 60 else f"{segundos // 60}m {segundos % 60:02d}s"
19
+
20
+
21
+ def _nome_do_motor():
22
+ """"llama.cpp" se o app fala com o motor próprio (porta 11435); senão "Ollama"."""
23
+ try:
24
+ import tars_motor_local
25
+ if str(OLLAMA_HOST).rstrip("/") == tars_motor_local.URL:
26
+ return "llama.cpp"
27
+ except Exception: # noqa: BLE001
28
+ pass
29
+ return "Ollama"
30
+
31
+
32
+ def _pasta_curta():
33
+ """A pasta atual, com ~ no lugar da pasta pessoal."""
34
+ try:
35
+ pasta = os.path.realpath(os.getcwd()) # realpath dos dois lados: no macOS /var é /private/var e o ~ não casava
36
+ except OSError: # a pasta atual foi apagada: o enfeite não derruba o chat
37
+ return ""
38
+ casa = os.path.realpath(os.path.expanduser("~"))
39
+ if pasta == casa or pasta.startswith(casa + os.sep):
40
+ pasta = "~" + pasta[len(casa):]
41
+ return pasta if len(pasta) <= 40 else "…" + pasta[-39:]
42
+
43
+
44
+ def _ramo_git():
45
+ """O ramo do git da pasta atual, ou "" (sem git, fora de repositório, ou demorou)."""
46
+ try:
47
+ r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=1.0,
48
+ cwd=os.getcwd(), env=_env_filho() if "_env_filho" in globals() else None)
49
+ return r.stdout.strip() if r.returncode == 0 else ""
50
+ except Exception: # noqa: BLE001 (sem git, ou um teste que troca o subprocess: o enfeite nunca derruba o chat)
51
+ return ""
52
+
53
+
54
+ def _regua(modo):
55
+ """A linha que separa um pedido do outro, com o modo da conversa à direita (como a barra do opencode)."""
56
+ rotulo = " " + t(f"cli.regua.{modo}") + " "
57
+ largura = _largura_do_terminal()
58
+ return c("─" * max(4, largura - len(rotulo) - 2), Cor.CINZA) + c(rotulo, Cor.CINZA) + c("──", Cor.CINZA)
59
+
60
+
61
+ def _partes_do_status():
62
+ """Modelo, contexto usado, pasta com o ramo, modo (quando não é o padrão) e AGENTS.md: a mesma conta da linha de status."""
63
+ modelo = modelo_atual or (modelo_manual if modo_modelo == "MANUAL" else "auto")
64
+ usados = estimar_tokens(conversa)
65
+ partes = [str(modelo).split("/")[-1], t("cli.status_contexto", pct=min(100, int(100 * usados / max(1, contexto_ia()))))]
66
+ ramo = _ramo_git()
67
+ partes.append(_pasta_curta() + (f" ⎇ {ramo}" if ramo else ""))
68
+ if MODO_PERMISSAO != "perguntar":
69
+ partes.append(t(f"cli.status_modo.{MODO_PERMISSAO}"))
70
+ if CONTEXTO_PROJETO["caminho"]:
71
+ partes.append("AGENTS.md")
72
+ return partes
73
+
74
+
75
+ _SELO = "\033[1;38;5;16;48;5;78m" # preto em negrito sobre verde (256 cores): o selo do openTARS na barra (como o do modo no opencode)
76
+
77
+
78
+ def _barra_de_estado(largura):
79
+ """A barra do opencode embaixo da caixa: selo "openTARS", modelo · motor · contexto · pasta ⎇ ramo, e a dica à direita."""
80
+ partes = _partes_do_status()
81
+ partes.insert(1, _nome_do_motor())
82
+ esquerda = " openTARS "
83
+ meio = " " + " · ".join(partes)
84
+ dica = t("cli.barra_dica")
85
+ sobra = largura - len(esquerda) - len(meio) - len(dica) - 1
86
+ if sobra < 1:
87
+ meio, sobra = meio[:max(0, largura - len(esquerda) - 2)], 0
88
+ dica = ""
89
+ return _SELO + esquerda + Cor.RESET + c(meio, Cor.CINZA) + " " * sobra + c(dica, Cor.CINZA)
90
+
91
+
92
+ def _pedir_na_caixa(ini, fim):
93
+ """Claude Code: o pedido é digitado dentro de uma caixa de cantos redondos, com a barra de estado logo abaixo. A caixa e a
94
+ barra são desenhadas antes e o cursor sobe para dentro; depois do Enter a linha ganha a borda da direita e o cursor desce
95
+ além da barra. Só num terminal de verdade com readline (as marcas \\001 e \\002 mantêm a edição da linha certa)."""
96
+ largura = _largura_do_terminal() - 1
97
+ borda = lambda s: c(s, Cor.CINZA)
98
+ sys.stdout.write("\n" + borda("╭" + "─" * (largura - 2) + "╮") + "\n" + borda("│") + " " * (largura - 2) + borda("│") + "\n"
99
+ + borda("╰" + "─" * (largura - 2) + "╯") + "\n" + _barra_de_estado(largura) + "\033[2A\r")
100
+ sys.stdout.flush()
101
+ try:
102
+ linha = input(ini + Cor.CINZA + fim + "│ " + ini + Cor.NEGRITO + Cor.VERDE + fim + "❯ " + ini + Cor.RESET + fim)
103
+ except BaseException:
104
+ sys.stdout.write("\r\n\033[J") # Ctrl+C ou Ctrl+D: some a caixa de baixo e a barra, a mensagem sai limpa
105
+ sys.stdout.flush()
106
+ raise
107
+ visivel = "│ ❯ " + linha
108
+ if len(visivel) <= largura - 2: # coube numa linha: fecha a borda da direita e pula a borda de baixo e a barra
109
+ sys.stdout.write("\033[1A\r" + borda("│") + " " + c("❯ ", Cor.NEGRITO + Cor.VERDE) + linha
110
+ + " " * (largura - 1 - len(visivel)) + borda("│") + "\n\033[1B\n")
111
+ else: # quebrou em várias linhas: a caixa já se desfez; só limpa o que sobrou embaixo
112
+ sys.stdout.write("\033[J")
113
+ sys.stdout.flush()
114
+ return linha
115
+
116
+
117
+ class _MarkdownTerminal:
118
+ """Formata a resposta, que chega aos pedaços, linha por linha: títulos em negrito, listas com •, **negrito**, `código` em
119
+ ciano e blocos de código com uma barra. Só vale num terminal de verdade (a janela e os pipes recebem o texto puro)."""
120
+
121
+ def __init__(self):
122
+ self.buf = ""
123
+ self.em_codigo = False
124
+
125
+ def reiniciar(self):
126
+ self.buf, self.em_codigo = "", False
127
+
128
+ def alimentar(self, texto):
129
+ """Devolve o que já pode ser impresso (as linhas completas); o resto espera a próxima quebra de linha."""
130
+ self.buf += texto
131
+ if "\n" not in self.buf:
132
+ return ""
133
+ *linhas, self.buf = self.buf.split("\n")
134
+ return "".join(self._linha(l) + "\n" for l in linhas)
135
+
136
+ def terminar(self):
137
+ resto, self.buf = self.buf, ""
138
+ saida = self._linha(resto) if resto else ""
139
+ self.em_codigo = False
140
+ return saida
141
+
142
+ @staticmethod
143
+ def _inline(linha):
144
+ linha = _re.sub(r"\*\*(.+?)\*\*", lambda m: Cor.NEGRITO + m.group(1) + Cor.RESET, linha)
145
+ return _re.sub(r"`([^`\n]+)`", lambda m: Cor.CIANO + m.group(1) + Cor.RESET, linha)
146
+
147
+ def _linha(self, linha):
148
+ if linha.lstrip().startswith("```"):
149
+ self.em_codigo = not self.em_codigo
150
+ rotulo = linha.strip().strip("`").strip()
151
+ return c("╭─ " + rotulo if self.em_codigo and rotulo else "╭─" if self.em_codigo else "╰─", Cor.CINZA)
152
+ if self.em_codigo:
153
+ return c("│ ", Cor.CINZA) + linha
154
+ m = _re.match(r"^(#{1,6})\s+(.*)$", linha)
155
+ if m:
156
+ return Cor.NEGRITO + (Cor.CIANO if len(m.group(1)) == 1 else "") + self._inline(m.group(2)) + Cor.RESET
157
+ m = _re.match(r"^(\s*)[-*]\s+(.*)$", linha)
158
+ if m:
159
+ return m.group(1) + c("•", Cor.CINZA) + " " + self._inline(m.group(2))
160
+ if linha.startswith("> "):
161
+ return c("│ ", Cor.CINZA) + c(self._inline(linha[2:]), Cor.ITALICO)
162
+ return self._inline(linha)
163
+
164
+
165
+ _MD = _MarkdownTerminal()
app/nucleo/voz.py CHANGED
@@ -61,12 +61,12 @@ def preferencias_voz():
61
  velocidade = float(dados.get("voz_velocidade") or 1.0)
62
  except (TypeError, ValueError):
63
  velocidade = 1.0
64
- return {"vozes": vozes, "velocidade": velocidade}
65
 
66
 
67
  def aplicar_preferencias_voz():
68
  p = preferencias_voz()
69
- voz_lib.configurar(vozes=p["vozes"], velocidade=p["velocidade"], idioma=i18n.idioma_atual())
70
 
71
 
72
  def voz_atual(idioma=None):
 
61
  velocidade = float(dados.get("voz_velocidade") or 1.0)
62
  except (TypeError, ValueError):
63
  velocidade = 1.0
64
+ return {"vozes": vozes, "velocidade": velocidade, "curta": not dados.get("voz_resposta_completa")}
65
 
66
 
67
  def aplicar_preferencias_voz():
68
  p = preferencias_voz()
69
+ voz_lib.configurar(vozes=p["vozes"], velocidade=p["velocidade"], idioma=i18n.idioma_atual(), curta=p["curta"])
70
 
71
 
72
  def voz_atual(idioma=None):
app/tars.py CHANGED
@@ -29,6 +29,7 @@ import requests
29
  import psutil
30
  from PIL import Image
31
 
 
32
  import tars_i18n as i18n
33
  from tars_i18n import t
34
  import tars_acessibilidade as acess
@@ -160,7 +161,7 @@ if SESSAO_GRAFICA == "wayland" and os.environ.get("TARS_YDOTOOL", "1") not in ("
160
  # CONFIGURAÇÃO
161
  # ============================================================
162
 
163
- VERSAO = "5.2"
164
  # 3.0 Miller: o classificador Murph e o modo AUTO.
165
  # 4.0 Endurance: o Gargantua (IA oficial); Endurance Neo: aviso de versão nova, tarefas longas.
166
  # 4.5 Endurance: o Cooper (visão da tela), WSL e Linux sem interface gráfica.
@@ -171,11 +172,11 @@ VERSAO = "5.2"
171
  # conta do Ollama e pesquisa web pelo SearXNG.
172
  # 5.2 Ranger: Windows, macOS e outros Linux; o assistente do sistema (lançador, ações de texto, arquivos e fotos);
173
  # Gargantua 2 retreinado com os dados da 5.2.
174
- CODINOME = "Ranger"
175
  VERSAO_EXIBIDA = f"{VERSAO} {CODINOME}"
176
  # Endurance Neo: sobe a cada pacote publicado (AAAAMMDDNN). O aviso de versão
177
  # nova compara com a REVISAO do tars.py do GitHub (tars_atualizacao.py).
178
- REVISAO = 2026110101
179
 
180
  # Endereço do servidor Ollama — respeita a variável de ambiente padrão
181
  # do próprio Ollama (OLLAMA_HOST), pra funcionar sem editar o código
@@ -224,11 +225,18 @@ def _normalizar_ollama_host(valor):
224
 
225
 
226
  OLLAMA_HOST = _normalizar_ollama_host(os.environ.get("OLLAMA_HOST"))
 
 
 
 
 
 
 
227
 
228
 
229
  def _ollama_responde(url, espera=0.8):
230
  try:
231
- return requests.get(f"{url}/api/version", timeout=espera).status_code == 200
232
  except Exception:
233
  return False
234
 
@@ -244,11 +252,11 @@ if EM_WSL and not os.environ.get("OLLAMA_HOST") and not _ollama_responde(OLLAMA_
244
  OLLAMA_HOST, OLLAMA_NO_WINDOWS = _candidato, True
245
  break
246
 
247
- OLLAMA_URL = f"{OLLAMA_HOST}/api/chat"
248
- OLLAMA_TAGS_URL = f"{OLLAMA_HOST}/api/tags"
249
- OLLAMA_SHOW_URL = f"{OLLAMA_HOST}/api/show"
250
- OLLAMA_GENERATE_URL = f"{OLLAMA_HOST}/api/generate"
251
- OLLAMA_PS_URL = f"{OLLAMA_HOST}/api/ps"
252
 
253
  # Quanto tempo cada modelo fica na memória (RAM/VRAM) depois do último uso: o
254
  # Ollama o descarrega sozinho. Curto = PC fraco liberado mais cedo; o modelo
@@ -273,15 +281,7 @@ MODELO_PADRAO = os.environ.get("TARS_MODELO_PADRAO", "qwen3:0.6b")
273
  # Neo: o classificador por IA (qwen2.5:0.5b) saiu; quem decide o tipo do
274
  # pedido é a helper Murph. Os ajudantes das versões antigas, se ainda
275
  # estiverem instalados, ficam fora da escolha de conversa.
276
- class _Ajudantes(set):
277
- """Modelos que não entram na escolha de conversa: os ajudantes antigos e
278
- o Cooper (4.5), que só olha a tela."""
279
-
280
- def __contains__(self, tag):
281
- return set.__contains__(self, tag) or visao_lib.e_cooper(tag)
282
-
283
-
284
- MODELOS_AJUDANTES = _Ajudantes({"qwen2.5:0.5b", "gemma3:270m"})
285
 
286
  # ------------------------------------------------------------
287
  # Timeouts de rede (segundos) — nomeados por propósito, não por valor,
@@ -390,6 +390,7 @@ _CAPACIDADES_RELEVANTES = {"tools", "vision", "thinking"}
390
  PASTA_NUCLEO = Path(__file__).resolve().parent / "nucleo"
391
  PARTES_NUCLEO = (
392
  "registro",
 
393
  "ollama",
394
  "hardware",
395
  "aplicacoes",
 
29
  import psutil
30
  from PIL import Image
31
 
32
+ import tars_motor as motor_lib
33
  import tars_i18n as i18n
34
  from tars_i18n import t
35
  import tars_acessibilidade as acess
 
161
  # CONFIGURAÇÃO
162
  # ============================================================
163
 
164
+ VERSAO = "5.3"
165
  # 3.0 Miller: o classificador Murph e o modo AUTO.
166
  # 4.0 Endurance: o Gargantua (IA oficial); Endurance Neo: aviso de versão nova, tarefas longas.
167
  # 4.5 Endurance: o Cooper (visão da tela), WSL e Linux sem interface gráfica.
 
172
  # conta do Ollama e pesquisa web pelo SearXNG.
173
  # 5.2 Ranger: Windows, macOS e outros Linux; o assistente do sistema (lançador, ações de texto, arquivos e fotos);
174
  # Gargantua 2 retreinado com os dados da 5.2.
175
+ CODINOME = "Tesseract"
176
  VERSAO_EXIBIDA = f"{VERSAO} {CODINOME}"
177
  # Endurance Neo: sobe a cada pacote publicado (AAAAMMDDNN). O aviso de versão
178
  # nova compara com a REVISAO do tars.py do GitHub (tars_atualizacao.py).
179
+ REVISAO = 2026111301
180
 
181
  # Endereço do servidor Ollama — respeita a variável de ambiente padrão
182
  # do próprio Ollama (OLLAMA_HOST), pra funcionar sem editar o código
 
225
 
226
 
227
  OLLAMA_HOST = _normalizar_ollama_host(os.environ.get("OLLAMA_HOST"))
228
+ if not os.environ.get("OLLAMA_HOST"): # 5.3: o motor llama.cpp é o padrão (se já instalado); senão, ou se a config de dev escolheu o Ollama, o Ollama
229
+ try:
230
+ import tars_motor_local
231
+ if tars_motor_local.escolhido() == "llamacpp":
232
+ OLLAMA_HOST = tars_motor_local.garantir() or OLLAMA_HOST
233
+ except Exception:
234
+ pass
235
 
236
 
237
  def _ollama_responde(url, espera=0.8):
238
  try:
239
+ return requests.get(motor_lib.url("versao", url), timeout=espera).status_code == 200
240
  except Exception:
241
  return False
242
 
 
252
  OLLAMA_HOST, OLLAMA_NO_WINDOWS = _candidato, True
253
  break
254
 
255
+ OLLAMA_URL = motor_lib.url("conversa", OLLAMA_HOST)
256
+ OLLAMA_TAGS_URL = motor_lib.url("modelos", OLLAMA_HOST)
257
+ OLLAMA_SHOW_URL = motor_lib.url("detalhes", OLLAMA_HOST)
258
+ OLLAMA_GENERATE_URL = motor_lib.url("gerar", OLLAMA_HOST)
259
+ OLLAMA_PS_URL = motor_lib.url("rodando", OLLAMA_HOST)
260
 
261
  # Quanto tempo cada modelo fica na memória (RAM/VRAM) depois do último uso: o
262
  # Ollama o descarrega sozinho. Curto = PC fraco liberado mais cedo; o modelo
 
281
  # Neo: o classificador por IA (qwen2.5:0.5b) saiu; quem decide o tipo do
282
  # pedido é a helper Murph. Os ajudantes das versões antigas, se ainda
283
  # estiverem instalados, ficam fora da escolha de conversa.
284
+ MODELOS_AJUDANTES = {"qwen2.5:0.5b", "gemma3:270m"}
 
 
 
 
 
 
 
 
285
 
286
  # ------------------------------------------------------------
287
  # Timeouts de rede (segundos) — nomeados por propósito, não por valor,
 
390
  PASTA_NUCLEO = Path(__file__).resolve().parent / "nucleo"
391
  PARTES_NUCLEO = (
392
  "registro",
393
+ "visual",
394
  "ollama",
395
  "hardware",
396
  "aplicacoes",
app/tars_acessibilidade.py CHANGED
@@ -314,15 +314,15 @@ def achar_janela(pid=None, titulo=None, termos=(), ativa=False, ignorar_pids=())
314
  # Elementos
315
  # ------------------------------------------------------------
316
 
317
- def _texto_do_no(no, papel):
318
  Atspi = _modulo()
319
  if papel in PAPEIS_CAMPO or papel in ("document-text", "paragraph"):
320
  try:
321
  if _nativo is not None:
322
- return _nativo.texto(no)[:LIMITE_CHARS_TEXTO] or _nome(no)
323
  n = Atspi.Text.get_character_count(no)
324
  if n:
325
- return Atspi.Text.get_text(no, 0, min(n, LIMITE_CHARS_TEXTO))
326
  except Exception:
327
  pass
328
  return _nome(no)
@@ -611,3 +611,51 @@ def preencher(janela, nome, texto):
611
  except Exception:
612
  pass
613
  return {"sucesso": False, "achou": True, "elemento": rotulo, "precisa_digitar": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  # Elementos
315
  # ------------------------------------------------------------
316
 
317
+ def _texto_do_no(no, papel, limite=LIMITE_CHARS_TEXTO):
318
  Atspi = _modulo()
319
  if papel in PAPEIS_CAMPO or papel in ("document-text", "paragraph"):
320
  try:
321
  if _nativo is not None:
322
+ return _nativo.texto(no)[:limite] or _nome(no)
323
  n = Atspi.Text.get_character_count(no)
324
  if n:
325
+ return Atspi.Text.get_text(no, 0, min(n, limite))
326
  except Exception:
327
  pass
328
  return _nome(no)
 
611
  except Exception:
612
  pass
613
  return {"sucesso": False, "achou": True, "elemento": rotulo, "precisa_digitar": True}
614
+
615
+
616
+ # ------------------------------------------------------------
617
+ # 5.3 (F4+): ler o que a janela mostra, como texto, para perguntar sobre ela SEM imagem (o texto entra no mesmo contrato das
618
+ # ações de texto). Nunca lê campo de senha e não guarda nada.
619
+ # ------------------------------------------------------------
620
+ LIMITE_TEXTO_JANELA = 6000 # caracteres, o mesmo corte das ações de texto
621
+ LIMITE_CHARS_BLOCO = 1500 # um parágrafo ou campo; o resto do bloco é cortado
622
+
623
+
624
+ def montar_texto_da_janela(blocos, botoes, limite=LIMITE_TEXTO_JANELA):
625
+ """Junta os blocos de texto da janela (na ordem de leitura, sem repetir) e, no fim, os nomes dos botões e links, para
626
+ perguntas como "onde clico para exportar?". Corta no limite."""
627
+ vistos, partes = set(), []
628
+ for bloco in blocos:
629
+ limpo = " ".join(str(bloco or "").split())
630
+ if limpo and limpo not in vistos:
631
+ vistos.add(limpo)
632
+ partes.append(limpo)
633
+ nomes = []
634
+ for nome in botoes:
635
+ limpo = " ".join(str(nome or "").split())[:60]
636
+ if limpo and limpo not in nomes:
637
+ nomes.append(limpo)
638
+ texto = "\n".join(partes)
639
+ if nomes:
640
+ texto += ("\n\n" if texto else "") + "Buttons and links: " + ", ".join(nomes[:60])
641
+ return texto[:limite]
642
+
643
+
644
+ def texto_da_janela(pid=None, titulo=None, limite=LIMITE_TEXTO_JANELA):
645
+ """{"titulo", "app", "texto"} da janela pedida (ou da ativa, sem contar o próprio openTARS), lido pela acessibilidade; None
646
+ se não há acessibilidade, janela ou texto."""
647
+ if indisponivel():
648
+ return None
649
+ janela = achar_janela(pid=pid, titulo=titulo, ativa=not (pid or titulo), ignorar_pids=(os.getpid(),))
650
+ if not janela:
651
+ return None
652
+ blocos, botoes = [], []
653
+ for no, papel, nome, _estados, tipo in _itens(janela, forcar=True):
654
+ if "password" in papel: # campo de senha: nem o nome vira texto
655
+ continue
656
+ if tipo in ("texto", "campo"):
657
+ blocos.append(_texto_do_no(no, papel, LIMITE_CHARS_BLOCO))
658
+ elif tipo == "clicavel" and nome:
659
+ botoes.append(nome)
660
+ texto = montar_texto_da_janela(blocos, botoes, limite)
661
+ return {"titulo": janela["titulo"], "app": janela["app"], "texto": texto} if texto.strip() else None
app/tars_acoes_texto.py CHANGED
@@ -18,6 +18,7 @@ import unicodedata
18
  import requests
19
 
20
  import tars_plataforma as plataforma
 
21
 
22
  SISTEMA_ACAO_TEXTO = (
23
  "You are Gargantua, the AI of openTARS. You transform the text the user selected and answer with ONLY the result: "
@@ -37,6 +38,11 @@ INSTRUCOES = {
37
  "para_pt": {"pt_BR": "Traduza o texto para o português.", "en": "Translate the text into Portuguese."},
38
  "perguntar": {"pt_BR": "Responda à pergunta usando só o texto. Se o texto não trouxer a resposta, diga isso.",
39
  "en": "Answer the question using only the text. If the text doesn't say, say so."},
 
 
 
 
 
40
  }
41
 
42
  LIMITE_TEXTO = 6000 # caracteres do trecho; mais que isso é cortado (e a janela avisa)
@@ -51,7 +57,8 @@ def pedido(acao, idioma, texto, pergunta=None):
51
  """A mensagem do usuário, no formato do treino."""
52
  instrucao = INSTRUCOES[acao][idioma if idioma in ("pt_BR", "en") else "en"]
53
  if pergunta:
54
- instrucao += f"\n{'Pergunta' if idioma == 'pt_BR' else 'Question'}: {pergunta}"
 
55
  return f"{instrucao}\n\n<texto>\n{texto}\n</texto>"
56
 
57
 
@@ -89,13 +96,15 @@ def rodar(acao, texto, modelo, host, pergunta=None, ao_pedaco=None, cancelar=Non
89
  manter=False):
90
  """Pede ao Ollama e devolve o texto inteiro; `ao_pedaco(trecho)` recebe a resposta enquanto chega. `manter`: o assistente
91
  está ligado, o modelo fica na memória só por 3 minutos depois do uso (keep_alive "3m") e sai sozinho."""
 
 
92
  sessao = sessao or requests
93
  corpo = {"model": modelo, "stream": True, "think": False, "messages": mensagens(acao, texto, pergunta, padrao),
94
  "options": {"temperature": 0.1, "num_predict": 1200}}
95
  if manter:
96
  corpo["keep_alive"] = "3m"
97
  partes = []
98
- with sessao.post(f"{host}/api/chat", json=corpo, stream=True, timeout=(5, 300)) as r:
99
  if r.status_code != 200: # o Ollama explica no corpo ("model 'x' not found"); o status sozinho não diz nada
100
  try:
101
  motivo = r.json().get("error")
 
18
  import requests
19
 
20
  import tars_plataforma as plataforma
21
+ import tars_motor as motor
22
 
23
  SISTEMA_ACAO_TEXTO = (
24
  "You are Gargantua, the AI of openTARS. You transform the text the user selected and answer with ONLY the result: "
 
38
  "para_pt": {"pt_BR": "Traduza o texto para o português.", "en": "Translate the text into Portuguese."},
39
  "perguntar": {"pt_BR": "Responda à pergunta usando só o texto. Se o texto não trouxer a resposta, diga isso.",
40
  "en": "Answer the question using only the text. If the text doesn't say, say so."},
41
+ "curto": {"pt_BR": "Reescreva o texto de forma mais curta, mantendo as informações importantes.",
42
+ "en": "Rewrite the text to be shorter, keeping the important information."},
43
+ "lista": {"pt_BR": "Transforme o texto em uma lista de tópicos curtos.", "en": "Turn the text into a list of short bullet points."},
44
+ "responder": {"pt_BR": "Escreva uma resposta curta e educada a esta mensagem, dizendo o que a linha Dizer pede.",
45
+ "en": "Write a short, polite reply to this message that says what the Say line asks."},
46
  }
47
 
48
  LIMITE_TEXTO = 6000 # caracteres do trecho; mais que isso é cortado (e a janela avisa)
 
57
  """A mensagem do usuário, no formato do treino."""
58
  instrucao = INSTRUCOES[acao][idioma if idioma in ("pt_BR", "en") else "en"]
59
  if pergunta:
60
+ rotulo = ("Dizer", "Say") if acao == "responder" else ("Pergunta", "Question") # 5.3: responder mensagem
61
+ instrucao += f"\n{rotulo[0] if idioma == 'pt_BR' else rotulo[1]}: {pergunta}"
62
  return f"{instrucao}\n\n<texto>\n{texto}\n</texto>"
63
 
64
 
 
96
  manter=False):
97
  """Pede ao Ollama e devolve o texto inteiro; `ao_pedaco(trecho)` recebe a resposta enquanto chega. `manter`: o assistente
98
  está ligado, o modelo fica na memória só por 3 minutos depois do uso (keep_alive "3m") e sai sozinho."""
99
+ if cancelar and cancelar.is_set(): # a pessoa já trocou de opção: nem pede ao modelo (antes o pedido velho ainda saía)
100
+ return ""
101
  sessao = sessao or requests
102
  corpo = {"model": modelo, "stream": True, "think": False, "messages": mensagens(acao, texto, pergunta, padrao),
103
  "options": {"temperature": 0.1, "num_predict": 1200}}
104
  if manter:
105
  corpo["keep_alive"] = "3m"
106
  partes = []
107
+ with sessao.post(motor.url("conversa", host), json=corpo, stream=True, timeout=(5, 300)) as r:
108
  if r.status_code != 200: # o Ollama explica no corpo ("model 'x' not found"); o status sozinho não diz nada
109
  try:
110
  motivo = r.json().get("error")
app/tars_anexos.py CHANGED
@@ -341,10 +341,6 @@ def montar_bloco(anexos, descricoes=None, limite_total=None):
341
  return "\n\n".join(partes)
342
 
343
 
344
- def imagens_b64(anexos):
345
- return [a.imagem_b64 for a in anexos if a.tipo == "imagem" and a.imagem_b64]
346
-
347
-
348
  # ------------------------------------------------------------
349
  # Terminal: achar caminhos numa linha ("@arquivo", arrastar e soltar)
350
  # ------------------------------------------------------------
@@ -352,45 +348,6 @@ def imagens_b64(anexos):
352
  _ARROBA = re.compile(r"(?<!\S)@(\"[^\"]+\"|'[^']+'|\S+)")
353
 
354
 
355
- def separar_arrobas(linha):
356
- """'resuma @notas.txt e @"meu doc.pdf"' -> ('resuma e', ['notas.txt', 'meu doc.pdf']),
357
- só dos '@caminho' que existem de fato (o resto continua no texto)."""
358
- achados = []
359
-
360
- def troca(m):
361
- bruto = m.group(1)
362
- if bruto[0] in "\"'" and bruto[-1] == bruto[0]:
363
- bruto = bruto[1:-1]
364
- if bruto and _existe(normalizar_caminho(bruto)):
365
- achados.append(bruto)
366
- return ""
367
- return m.group(0)
368
-
369
- texto = _ARROBA.sub(troca, linha)
370
- return re.sub(r"\s{2,}", " ", texto).strip(), achados
371
-
372
-
373
- def linha_e_so_caminho(linha):
374
- """Quem arrasta um arquivo pro terminal cola só o caminho: se a linha
375
- inteira (com ou sem aspas) é um arquivo/pasta que existe, devolve-o."""
376
- t = str(linha or "").strip()
377
- if not t or "\n" in t or len(t) > 1000:
378
- return None
379
- candidatos = [t]
380
- # vários arrastados de uma vez: 'a.png' 'b.txt'
381
- entre_aspas = re.findall(r"'([^']+)'|\"([^\"]+)\"", t)
382
- if len(entre_aspas) > 1 and not re.sub(r"'[^']*'|\"[^\"]*\"|\s+", "", t):
383
- candidatos = [a or b for a, b in entre_aspas]
384
- caminhos = []
385
- for c in candidatos:
386
- if not (c.startswith(("/", "~", "file://", "'", '"', ".")) or ambiente.parece_caminho_windows(c)):
387
- return None
388
- p = normalizar_caminho(c)
389
- if not _existe(p):
390
- return None
391
- caminhos.append(c)
392
- return caminhos
393
-
394
  # ------------------------------------------------------------
395
  # Imagens citadas no texto do pedido ("veja home/pictures/foto.png")
396
  # ------------------------------------------------------------
 
341
  return "\n\n".join(partes)
342
 
343
 
 
 
 
 
344
  # ------------------------------------------------------------
345
  # Terminal: achar caminhos numa linha ("@arquivo", arrastar e soltar)
346
  # ------------------------------------------------------------
 
348
  _ARROBA = re.compile(r"(?<!\S)@(\"[^\"]+\"|'[^']+'|\S+)")
349
 
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  # ------------------------------------------------------------
352
  # Imagens citadas no texto do pedido ("veja home/pictures/foto.png")
353
  # ------------------------------------------------------------
app/tars_assistente.py CHANGED
@@ -14,6 +14,7 @@ import requests
14
 
15
  import tars_i18n as i18n
16
  import tars_plataforma as plataforma
 
17
 
18
  NOME_AUTOSTART = "opentars-assistente"
19
 
@@ -27,7 +28,7 @@ KEEP_ALIVE_OCIOSO = "3m" # quanto o modelo do assistente fica na memória
27
 
28
  def manter_carregado(host, modelo, carregar, sessao=None):
29
  """Um pedido sem prompt carrega o modelo por KEEP_ALIVE_OCIOSO (e o Ollama descarrega sozinho) ou descarrega já (0)."""
30
- r = (sessao or requests).post(f"{host}/api/generate", json={"model": modelo, "keep_alive": KEEP_ALIVE_OCIOSO if carregar else 0},
31
  timeout=(5, 180))
32
  if r.status_code != 200:
33
  try:
 
14
 
15
  import tars_i18n as i18n
16
  import tars_plataforma as plataforma
17
+ import tars_motor as motor
18
 
19
  NOME_AUTOSTART = "opentars-assistente"
20
 
 
28
 
29
  def manter_carregado(host, modelo, carregar, sessao=None):
30
  """Um pedido sem prompt carrega o modelo por KEEP_ALIVE_OCIOSO (e o Ollama descarrega sozinho) ou descarrega já (0)."""
31
+ r = (sessao or requests).post(motor.url("gerar", host), json={"model": modelo, "keep_alive": KEEP_ALIVE_OCIOSO if carregar else 0},
32
  timeout=(5, 180))
33
  if r.status_code != 200:
34
  try:
app/tars_atalhos.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """O que o assistente resolve sem o modelo (F4 do PLANO_5.3.md, 07/11): vem das camadas do openTARS Pi, onde levaram a prova
2
+ de 19 para 30 de 40 tarefas inéditas, e modelos maiores não ganharam do pequeno depois disso.
3
+
4
+ - estado do computador com avaliação ("está tranquilo") e capacidade ("cabe um arquivo de 10 GB?");
5
+ - memória: "guarda que...", "esquece...", "o que você sabe de mim?";
6
+ - contas;
7
+ - operações de arquivo com os nomes ligados ao disco e a resposta em palavras (tars_operacoes, tars_nomes);
8
+ - pedidos de vários passos (tars_passos);
9
+ - perguntas sobre o mundo respondidas por um resultado do SearXNG, com o site (tars_resposta_web).
10
+
11
+ resolver() devolve o texto da resposta, ou None: aí o pedido segue para o modelo, como antes. Os comandos passam por
12
+ executar_ferramenta, então valem as mesmas regras de segurança e o mesmo cartão de aprovação do resto do app.
13
+ As operações de arquivo só valem no Linux (os comandos são de bash e GNU); no Windows e no macOS o modelo continua com elas.
14
+ """
15
+ import os
16
+ import re
17
+ import shlex
18
+ import time
19
+ import unicodedata
20
+
21
+ import tars_conta
22
+ import tars_email
23
+ import tars_integracoes
24
+ import tars_lembretes
25
+ import tars_midia
26
+ import tars_operacoes as operacoes
27
+ import tars_passos as passos
28
+ import tars_regras_terminal as regras_terminal
29
+ import tars_sistema_frases as sistema_frases
30
+ import tars_resposta_web as resposta_web
31
+ from tars_atalhos_textos import t
32
+
33
+ _MARCAS_PT = re.compile(r"[ãõçáéíóúâêô]|\b(o|os|as|um|uma|de|do|da|que|qual|quanto|quanta|quem|como|onde|por|para|pra|meu|minha|tem|tenho|e|é|cria|faz|lembra|esquece|"
34
+ r"em|no|na|foi|isso|voce|você|eu|quando|sim|não|nao|tá|ta|está|esta|mostra|apaga|copia|move|abre)\b", re.IGNORECASE)
35
+ _MARCAS_EN = re.compile(r"\b(the|an|of|what|which|who|how|where|is|are|my|me|do|does|can|please|create|make|remember|forget|search|and|in|to|when|did|was|"
36
+ r"were|has|have|it|this|that|you|your|i|show|delete|copy|move|open|file|folder)\b", re.IGNORECASE)
37
+
38
+
39
+ def idioma_do(texto):
40
+ pt, en = len(_MARCAS_PT.findall(texto)), len(_MARCAS_EN.findall(texto))
41
+ return "en" if en > pt else "pt"
42
+
43
+
44
+ def normalizar(texto):
45
+ return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)).lower().strip()
46
+
47
+
48
+ # ---------------- estado do computador ----------------
49
+ _DA_ASSISTENTE = re.compile(r"\b(sua memoria|your memory|na memoria|da memoria|in memory|from memory|to memory|de mim|about me)\b")
50
+ _MEMORIA = re.compile(r"\b(ram|memoria|memory)\b")
51
+ _QUANTO = re.compile(r"\b(quanta|quanto|livre|disponivel|sobrando|sobrou|usada|uso|usando|how much|free|usage|used|left|available)\b")
52
+ _AVALIA = re.compile(r"\b(acabando|acabou|cheia|cheio|lotad[oa]|pouca|pouco|suficiente|enough|running out|low|full|tranquil\w*|apertad[oa]|limite|limit|"
53
+ r"sobrecarregad[oa]|ocupad[oa]|busy|pesad[oa]|heavy|alta|alto|high|ok|bem|fine|aguenta|aguentando|handle|almost|quase)\b")
54
+ _DISCO = re.compile(r"\b(disco|disk|armazenamento|storage|ssd|hd|espaco livre|espaco no|espaco em disco|free space|drive)\b")
55
+ _TAMANHO = re.compile(r"(\d+(?:[.,]\d+)?)\s*(tb|gb|g|giga\w*|mb|m|mega\w*|kb)\b")
56
+ _CABE = re.compile(r"\b(da pra|da para|cabe|caberia|cabem|consigo|posso|tem espaco|tem lugar|sobra espaco|is there (?:enough )?(?:room|space|memory)|"
57
+ r"enough (?:space|room|memory|ram)|can i (?:download|fit|store|save|run|keep|open)|fits?|would fit|aguenta|can it (?:run|handle))\b")
58
+ _USA_RAM = re.compile(r"\b(ram|memoria|memory|rodar|roda|run|running|abrir|open|programa|program|processo|process|app|aplicativo|servico|service|jogo|game)\b")
59
+ _CPU = re.compile(r"\b(uso|carga|usage|load|busy|ocupad[oa])\b.{0,15}\b(cpu|processador|processor)\b|\b(cpu|processador|processor)\b.{0,20}"
60
+ r"\b(uso|carga|usage|load|ocupad[oa]|busy|sobrecarregad[oa]|pesad[oa]|aguenta\w*)\b|\b(quantos nucleos|how many (cpu )?cores)\b")
61
+ _LIGADO = re.compile(r"\buptime\b|\b(ha )?quanto tempo\b.{0,30}\b(ligad|rodando)|\bhow long\b.{0,30}\b(running|been on|been up|up)\b")
62
+ _DE_PROCESSO = re.compile(r"\b(processos?|process(es)?|programas?|apps?|aplicativos?|quem esta|what'?s|whats|which program|o que esta)\b.{0,30}"
63
+ r"\b(usando|comendo|gastando|consumindo|using|eating|hogging|taking)\b")
64
+
65
+
66
+ def _folga(livre, total, idioma):
67
+ parte = livre / total if total else 1
68
+ return t("folga_boa" if parte > 0.3 else "folga_media" if parte > 0.1 else "folga_pouca", idioma)
69
+
70
+
71
+ def _memoria_mb():
72
+ import psutil
73
+ m = psutil.virtual_memory()
74
+ return m.total // 2**20, m.available // 2**20 # disponível, não a coluna "free" (o cache conta como livre)
75
+
76
+
77
+ def _disco_gb():
78
+ import shutil
79
+ d = shutil.disk_usage(os.path.expanduser("~"))
80
+ return d.total / 1e9, d.free / 1e9
81
+
82
+
83
+ def estado(n, idioma):
84
+ if _DA_ASSISTENTE.search(n) or _DE_PROCESSO.search(n):
85
+ return None
86
+ tamanho = _TAMANHO.search(n)
87
+ if tamanho and _CABE.search(n):
88
+ valor = float(tamanho.group(1).replace(",", "."))
89
+ mb = valor * {"t": 1024 * 1024, "g": 1024, "m": 1, "k": 1 / 1024}[tamanho.group(2)[0]]
90
+ pedido = f"{tamanho.group(1)} {tamanho.group(2).upper()}"
91
+ if _USA_RAM.search(n) and not re.search(r"\b(baixar|download|salvar|save|guardar|store|disco|disk|arquivo|file)\b", n):
92
+ total, livre = _memoria_mb()
93
+ return t("cabe_ram" if mb < livre * 0.9 else "nao_cabe_ram", idioma, livre=livre, pedido=pedido)
94
+ total, livre = _disco_gb()
95
+ return t("cabe_disco" if mb < livre * 1000 * 0.95 else "nao_cabe_disco", idioma, livre=livre, pedido=pedido)
96
+ if _MEMORIA.search(n) and (_QUANTO.search(n) or _AVALIA.search(n)):
97
+ total, livre = _memoria_mb()
98
+ texto = t("mem", idioma, total=total, livre=livre, usada=total - livre)
99
+ return texto + " " + _folga(livre, total, idioma) if _AVALIA.search(n) else texto
100
+ if _DISCO.search(n) and (_QUANTO.search(n) or _AVALIA.search(n) or re.search(r"\b(espaco|space)\b", n)):
101
+ total, livre = _disco_gb()
102
+ texto = t("disco", idioma, total=total, livre=livre, usado=total - livre)
103
+ return texto + " " + _folga(livre, total, idioma) if _AVALIA.search(n) else texto
104
+ if _CPU.search(n):
105
+ import psutil
106
+ nucleos, carga = os.cpu_count() or 1, psutil.getloadavg()[0] # os.getloadavg não existe no Windows
107
+ return t("cpu", idioma, nucleos=nucleos, carga=carga) + " " + t("cpu_folga" if carga < 0.7 * nucleos else "cpu_media" if carga < nucleos else "cpu_cheia", idioma)
108
+ if _LIGADO.search(n):
109
+ import psutil
110
+ segundos = int(time.time() - psutil.boot_time())
111
+ dias, resto = divmod(segundos, 86400)
112
+ horas, minutos = resto // 3600, resto % 3600 // 60
113
+ return t("ligada", idioma, tempo=" ".join(p for p in (f"{dias} d" if dias else "", f"{horas} h" if horas else "", f"{minutos} min") if p))
114
+ return None
115
+
116
+
117
+ # ---------------- memória da pessoa ----------------
118
+ _POLIDEZ = r"^(por favor,? |please,? |pode |voce pode |can you |could you |from now on,? |a partir de agora,? )?"
119
+ # 13/11 (revisão): "salva/grava/guarda/save" também querem dizer salvar um arquivo ou gravar a tela ("salva meu arquivo na pasta X"
120
+ # virava um fato na memória); esses só valem com "que", "that", ":" ou "na memória". Os que só querem dizer lembrar aceitam "meu/my" direto.
121
+ _LEMBRAR = re.compile(_POLIDEZ + r"(?:(lembra|lembre|anota|anote|memoriza|memorize|nao esquece|nao esqueca|remember|keep in mind|don'?t forget|"
122
+ r"jot down|make a note)\b( ai| isso| disso| this| na memoria| to memory)?( que| that|:|(?= (my|meu|minha|eu|i)\b))|"
123
+ r"(grava|grave|guarda|guarde|salva|salve|save|note|write down)\b(( ai| isso| this)? (na memoria|to memory|in memory))?"
124
+ r"( ai| isso| this)?( que| that|:))\s*")
125
+ _LEMBRAR_NO_FIM = re.compile(r"^(.+?)[,.]?\s+(keep that|remember that|remember this|guarda isso|lembra disso|anota isso|anota ai)[.!]?$")
126
+ _ESQUECER_TUDO = re.compile(r"^(esquece|esqueca|forget)\s+(tudo|everything)( que voce sabe( sobre mim)?| about me| you know( about me)?)?[.!]*$|"
127
+ r"\b(apaga|apague|limpa|limpe|zera|clear|wipe|erase|reset)\s+(toda |a |sua |your |the |all )*(memoria|memory)\b")
128
+ _ESQUECER = re.compile(_POLIDEZ + r"(esquece|esqueca|esquecer|forget)\b\s*(isso|that)?\s*(d[aeo]s?|que|about|sobre)?\s*(?P<alvo>.*)$")
129
+ _VER_MEMORIA = re.compile(r"\bo que (voce )?(lembra|guardou|sabe) (de|sobre) mim\b|\bwhat do you (remember|know)( about me)?\b|"
130
+ r"\b(mostra|mostre|show)( a| your)? (sua )?(memoria|memory)\b")
131
+ _VAZIAS = {"o", "a", "os", "as", "de", "do", "da", "que", "eu", "meu", "minha", "the", "of", "my", "i", "that", "isso", "about", "sobre", "is", "e"}
132
+
133
+
134
+ def memoria(pedido, n, idioma, core):
135
+ mem = core.memoria
136
+ if _VER_MEMORIA.search(n):
137
+ fatos = mem.listar()
138
+ return t("memoria", idioma, fatos="\n".join("- " + f for f in fatos)) if fatos else t("memoria_vazia", idioma)
139
+ if _ESQUECER_TUDO.search(n):
140
+ mem.esquecer("*")
141
+ return t("esqueci_tudo", idioma)
142
+ achado = _LEMBRAR.search(n) or None
143
+ if achado:
144
+ fato = pedido.strip()[achado.end():].strip(" .") if len(pedido.strip()) == len(n) else n[achado.end():].strip(" .")
145
+ ok, _ = mem.lembrar(fato) if fato else (False, None)
146
+ return t("anotado", idioma, fato=fato) if ok else None
147
+ achado = _LEMBRAR_NO_FIM.search(n)
148
+ if achado:
149
+ fato = (pedido.strip() if len(pedido.strip()) == len(n) else n)[:achado.end(1)]
150
+ ok, _ = mem.lembrar(fato)
151
+ return t("anotado", idioma, fato=fato) if ok else None
152
+ achado = _ESQUECER.search(n)
153
+ if achado and achado.group("alvo"):
154
+ palavras = [p for p in re.findall(r"[\w-]+", achado.group("alvo")) if p not in _VAZIAS and len(p) > 2]
155
+ fatos = mem.listar()
156
+ notas = [(sum(normalizar(p) in normalizar(f) for p in palavras), f) for f in fatos]
157
+ melhor = max((x for x, _ in notas), default=0)
158
+ if not melhor:
159
+ return t("nada_guardado", idioma)
160
+ saiu = [f for x, f in notas if x == melhor]
161
+ for f in saiu:
162
+ mem.esquecer(f)
163
+ return t("esqueci", idioma, fato="; ".join(saiu))
164
+ return None
165
+
166
+
167
+ # ---------------- comandos (operações de arquivo) ----------------
168
+ def _rodar(core, comando):
169
+ """O resultado no formato que tars_operacoes.resumir lê (o do openTARS Pi)."""
170
+ r = core.executar_ferramenta("execute_terminal", {"command": comando})
171
+ r = r if isinstance(r, dict) else {}
172
+ return {"success": bool(r.get("sucesso")), "exit_code": r.get("codigo"), "stdout": r.get("stdout") or "",
173
+ "stderr": r.get("stderr") or "", "message": r.get("mensagem") or ""}
174
+
175
+
176
+ def _com_saida(texto, saida):
177
+ linhas = [l for l in (saida or "").rstrip().splitlines()][:40]
178
+ bloco = "```\n" + "\n".join(linhas) + "\n```" if linhas else ""
179
+ return "\n\n".join(p for p in (texto, bloco) if p)
180
+
181
+
182
+ def comando(plano, idioma, core, estado_passos):
183
+ """Roda o comando de uma operação e devolve (resposta, deu_certo)."""
184
+ r = _rodar(core, plano["comando"])
185
+ estado_passos["resultado"], estado_passos["comando"] = r, plano["comando"]
186
+ if "did not confirm" in r["message"] or "cancelled by the user" in r["message"].lower():
187
+ return t("negado", idioma), False # a pessoa disse não: sem o texto em inglês do controle
188
+ nada_achado = plano.get("resumo") in ("ocorrencias", "arquivos_com") and r["exit_code"] == 1 and not r["stderr"].strip()
189
+ if not r["success"] and not nada_achado:
190
+ erro = (r["stderr"] or r["message"] or "?").strip().splitlines()
191
+ return t("falhou", idioma, erro=erro[-1] if erro else "?"), False
192
+ if plano.get("lugar"):
193
+ estado_passos["lugar"] = plano["lugar"]
194
+ elif plano["comando"].startswith("mkdir -p ") and "&&" not in plano["comando"]:
195
+ estado_passos["lugar"] = shlex.split(plano["comando"])[-1] # a pasta criada vira o "ela" do passo seguinte
196
+ if plano.get("alvo"):
197
+ estado_passos["alvo"] = plano["alvo"]
198
+ resumo = operacoes.resumir(plano, r, idioma)
199
+ if resumo:
200
+ return " ".join(p for p in (plano.get("nota"), resumo) if p), True
201
+ if not r["stdout"].strip():
202
+ return " ".join(p for p in (plano.get("nota"), plano.get("feito") or t("feito", idioma)) if p), True
203
+ return _com_saida(plano.get("nota") or "", r["stdout"]), True
204
+
205
+
206
+ def _plano(pedido, n, idioma, linux):
207
+ if not linux:
208
+ return None
209
+ p = operacoes.plano(pedido, n, idioma) or regras_terminal.plano(pedido, n, idioma)
210
+ return {"comando": p} if isinstance(p, str) else p
211
+
212
+
213
+ def _uma_parte(pedido, idioma, core, estado_passos, linux, permitir_mundo=True):
214
+ """(resposta, deu_certo) de uma frase, ou None se é com o modelo."""
215
+ import tars_plataforma
216
+ n = normalizar(pedido)
217
+ fala = (tars_lembretes.resposta_da_rotina(pedido) or tars_lembretes.agendar(pedido, idioma) or tars_integracoes.responder(pedido, idioma)
218
+ or tars_midia.responder(pedido, idioma)
219
+ or tars_email.responder(pedido, idioma, core)
220
+ or memoria(pedido, n, idioma, core))
221
+ if fala:
222
+ return fala, True
223
+ if not linux: # 5.3: Windows e macOS: as mesmas frases, executadas direto (volume, brilho, tema, Wi-Fi, bloquear)
224
+ fala = sistema_frases.responder_outros(n, idioma, tars_plataforma.sistema(), core)
225
+ if fala:
226
+ return fala, True
227
+ plano = sistema_frases.plano(n, idioma) if linux else None
228
+ plano = plano or _plano(pedido, n, idioma, linux)
229
+ if plano:
230
+ if "fala" in plano:
231
+ return plano["fala"], True
232
+ return comando(plano, idioma, core, estado_passos)
233
+ fala = estado(n, idioma) or tars_conta.resolver(n, idioma)
234
+ if fala:
235
+ return fala, True
236
+ if permitir_mundo:
237
+ fala = mundo(pedido, n, idioma)
238
+ if fala:
239
+ return fala, True
240
+ return None
241
+
242
+
243
+ # ---------------- perguntas sobre o mundo ----------------
244
+ _PERGUNTA = re.compile(r"^(quem|qual|quais|quando|onde|quanto|quanta|quantos|quantas|em que ano|o que e|o que foi|what|who|which|when|where|how (tall|far|high|long|big|many|much|old)|"
245
+ r"in what year|what year)\b")
246
+ _PESSOAL = re.compile(r"\b(voce|seu|sua|you|your|eu|meu|minha|meus|minhas|me|mim|my|i|computador|computer|pc|arquivo|arquivos|file|files|pasta|folder|disco|disk|"
247
+ r"memoria|memory|ram|cpu|processador|processor|wifi|internet|placa|ip|senha|password|lembr|remind|timer|alarme|alarm)\b")
248
+
249
+
250
+ def mundo(pedido, n, idioma):
251
+ """Uma frase de um resultado do SearXNG que responde, com o site. None: sem SearXNG ligado, sem resultado bom, ou não é pergunta sobre o mundo."""
252
+ if not _PERGUNTA.search(n) or _PESSOAL.search(n):
253
+ return None
254
+ try:
255
+ import tars_web
256
+ if not tars_web.config()["ligada"]:
257
+ return None
258
+ achado = resposta_web.responder(pedido.strip(" ?"))
259
+ except Exception: # noqa: BLE001 - sem busca, o modelo responde como antes
260
+ return None
261
+ if not achado:
262
+ return None
263
+ import urllib.parse
264
+ site = urllib.parse.urlsplit(achado["url"]).hostname or achado["url"]
265
+ return t("de_onde", idioma, site=site, trecho=achado["trecho"])
266
+
267
+
268
+ # ---------------- entrada ----------------
269
+ def resolver(texto, core):
270
+ """A resposta sem o modelo, ou None. core: o módulo tars (memória, executar_ferramenta)."""
271
+ texto = (texto or "").strip()
272
+ if not texto or len(texto) > 400 or os.environ.get("TARS_SEM_ATALHOS"):
273
+ return None
274
+ import tars_plataforma
275
+ linux = tars_plataforma.sistema() == "linux"
276
+ idioma = idioma_do(texto)
277
+ estado_passos = {"lugar": None, "resultado": None, "comando": None, "alvo": None}
278
+ partes = passos.dividir(texto, normalizar(texto))
279
+ if partes:
280
+ falas = []
281
+ for i, parte in enumerate(partes):
282
+ n = normalizar(parte)
283
+ if i and passos.pede_contagem(n) and estado_passos["resultado"]:
284
+ saida = estado_passos["resultado"].get("stdout") or ""
285
+ quantos = len([l for l in saida.splitlines() if l.strip()])
286
+ falas.append(t("r_e_um", idioma) if quantos == 1 else t("r_sao", idioma, n=quantos))
287
+ continue
288
+ destino = passos.salvar_em(n) if i else None
289
+ if destino:
290
+ anterior = estado_passos["comando"] if (estado_passos["resultado"] or {}).get("success") else None
291
+ if not anterior or not linux:
292
+ if i == 1 and not falas:
293
+ return None
294
+ falas.append(t("nada_para_salvar", idioma))
295
+ break
296
+ comeco = n.rfind(destino)
297
+ destino = parte[comeco:comeco + len(destino)] if comeco >= 0 else destino
298
+ resposta, ok = comando({"comando": f"{anterior} > {operacoes._q(destino)}"}, idioma, core, estado_passos)
299
+ else:
300
+ parte = passos.com_lugar(parte, estado_passos["lugar"]) if i else parte
301
+ feito = _uma_parte(parte, idioma, core, estado_passos, linux, permitir_mundo=False)
302
+ if feito is None and i and estado_passos["alvo"]:
303
+ feito = _uma_parte(f"{parte} {'de' if idioma == 'pt' else 'of'} {estado_passos['alvo']}", idioma, core, estado_passos, linux, False)
304
+ if feito is None:
305
+ if i == 0:
306
+ return None # o primeiro passo não é de regra: o pedido inteiro vai para o modelo
307
+ falas.append(t("falhou", idioma, erro=parte))
308
+ break
309
+ resposta, ok = feito
310
+ if resposta and (not falas or resposta != falas[-1]):
311
+ falas.append(resposta)
312
+ if not ok:
313
+ break
314
+ return "\n".join(falas) if falas else None
315
+ feito = _uma_parte(texto, idioma, core, estado_passos, linux)
316
+ return feito[0] if feito else None
app/tars_atalhos_textos.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """As frases das respostas que saem sem o modelo (tars_atalhos, tars_operacoes), em pt e en: a resposta segue o idioma do pedido.
2
+ Vieram do openTARS Pi (opentars_pi/textos.py) em 07/11; "mem" fala da memória disponível, não da coluna "free" do `free`."""
3
+
4
+ TEXTOS = {'pt': {'r_e_um': 'É só 1.','htop_falta': 'O htop não está instalado (sudo apt install htop). Mostrando o top:','achei_em': '(Achei em {real}.)',
5
+ 'nome_corrigido': '(Não existe {pedido}; usei {real}.)',
6
+ 'nao_achei_arquivo': 'Não achei {nome} aqui.',
7
+ 'nao_achei_arquivo_sugestao': 'Não achei {nome}. Você quis dizer: {sugestoes}?',
8
+ 'apagar_parecido': 'Não existe {nome}. Você quis dizer {real}? Se for, diga: apaga {real}',
9
+ 'nenhum_tipo': 'Não há arquivos desse tipo em {pasta}.',
10
+ 'nao_instalado': 'O {programa} não está instalado. Para instalar: sudo apt install {pacote}',
11
+ 'r_linhas': '{alvo} tem {n} linhas.',
12
+ 'r_ocorrencias': '"{termo}" aparece {n} vezes em {alvo}.',
13
+ 'r_arquivos': '{alvo} tem {n} arquivos.',
14
+ 'r_nenhum_arquivo': 'Nenhum arquivo aqui fala de "{termo}".',
15
+ 'r_esta_em': 'Está em: {lista}',
16
+ 'r_maior': 'O maior arquivo de {alvo} é {nome} ({tamanho}).',
17
+ 'r_menor': 'O menor arquivo de {alvo} é {nome} ({tamanho}).',
18
+ 'r_tamanho': '{alvo} ocupa {tamanho}.',
19
+ 'r_achados': 'Achei {n} em {alvo}.',
20
+ 'r_nada_achado': 'Nada assim em {alvo}.',
21
+ 'r_sao': 'São {n}.',
22
+ 'r_loc': '{alvo} tem {n} linhas de código.',
23
+ 'mem': '{livre} MB de memória disponíveis, de {total} MB ({usada} MB em uso).',
24
+ 'disco': '{usado:.1f} GB usados no disco, {livre:.1f} GB livres, de {total:.1f} GB.',
25
+ 'temp': 'A placa está a {temp:.0f} °C.',
26
+ 'sem_temp': 'Esta placa não tem um sensor de temperatura que eu consiga ler.',
27
+ 'ligada': 'Ligada há {tempo}.',
28
+ 'cpu': '{nucleos} núcleos, carga {carga:.2f}.',
29
+ 'processador': 'O processador é um {nome}, com {nucleos} núcleos.',
30
+ 'folga_boa': 'Está tranquilo.',
31
+ 'folga_media': 'Ainda dá, mas está apertando.',
32
+ 'folga_pouca': 'Está apertado: vale liberar espaço.',
33
+ 'cabe_disco': 'Sim: o disco tem {livre:.1f} GB livres, então {pedido} cabem.',
34
+ 'nao_cabe_disco': 'Não: o disco tem só {livre:.1f} GB livres, não cabem {pedido}.',
35
+ 'cabe_ram': 'Sim: há {livre} MB de memória livres, então {pedido} cabem.',
36
+ 'nao_cabe_ram': 'Não: só há {livre} MB de memória livres, não cabem {pedido}.',
37
+ 'temp_ok': 'Está normal.',
38
+ 'temp_morna': 'Está morna, mas dentro do limite.',
39
+ 'temp_quente': 'Está quente: vale olhar a ventilação.',
40
+ 'cpu_folga': 'Está folgado.',
41
+ 'cpu_media': 'Está ocupado, mas dando conta.',
42
+ 'cpu_cheia': 'Está sobrecarregado agora.',
43
+ 'anotado': 'Anotado: {fato}.',
44
+ 'esqueci': 'Esquecido: {fato}.',
45
+ 'esqueci_tudo': 'Pronto, minha memória está vazia.',
46
+ 'nada_guardado': 'Não tenho nada guardado sobre isso.',
47
+ 'memoria': 'Isto é o que eu lembro:\n{fatos}',
48
+ 'memoria_vazia': 'Minha memória está vazia.',
49
+ 'lembro': 'Você me disse: {fato}.',
50
+ 'feito': 'Pronto.',
51
+ 'falhou': 'Não deu certo: {erro}',
52
+ 'negado': 'Certo, não rodei.',
53
+ 'de_onde': 'Segundo {site}: {trecho}',
54
+ 'tambem': 'Também: {site} ({titulo})',
55
+ 'nao_achei': 'Não achei uma boa resposta. O mais perto que encontrei: {titulo} ({site}).',
56
+ 'nada_para_salvar': 'Não tenho o que salvar do passo anterior.'},
57
+ 'en': {'r_e_um': 'Just 1.','htop_falta': 'htop is not installed (sudo apt install htop). Here is top instead:','achei_em': '(Found it at {real}.)',
58
+ 'nome_corrigido': '(There is no {pedido}; I used {real}.)',
59
+ 'nao_achei_arquivo': 'I could not find {nome} here.',
60
+ 'nao_achei_arquivo_sugestao': 'I could not find {nome}. Did you mean: {sugestoes}?',
61
+ 'apagar_parecido': 'There is no {nome}. Did you mean {real}? If so, say: delete {real}',
62
+ 'nenhum_tipo': 'There are no files of that kind in {pasta}.',
63
+ 'nao_instalado': '{programa} is not installed. Install it with: sudo apt install {pacote}',
64
+ 'r_linhas': '{alvo} has {n} lines.',
65
+ 'r_ocorrencias': '"{termo}" appears {n} times in {alvo}.',
66
+ 'r_arquivos': '{alvo} has {n} files.',
67
+ 'r_nenhum_arquivo': 'No file here mentions "{termo}".',
68
+ 'r_esta_em': 'It is in: {lista}',
69
+ 'r_maior': 'The biggest file in {alvo} is {nome} ({tamanho}).',
70
+ 'r_menor': 'The smallest file in {alvo} is {nome} ({tamanho}).',
71
+ 'r_tamanho': '{alvo} takes up {tamanho}.',
72
+ 'r_achados': 'Found {n} in {alvo}.',
73
+ 'r_nada_achado': 'Nothing like that in {alvo}.',
74
+ 'r_sao': 'That makes {n}.',
75
+ 'r_loc': '{alvo} has {n} lines of code.',
76
+ 'mem': '{livre} MB of memory available, out of {total} MB ({usada} MB in use).',
77
+ 'disco': '{usado:.1f} GB used on the disk, {livre:.1f} GB free, out of {total:.1f} GB.',
78
+ 'temp': 'The board is at {temp:.0f} °C.',
79
+ 'sem_temp': 'This board has no temperature sensor I can read.',
80
+ 'ligada': 'Running for {tempo}.',
81
+ 'cpu': '{nucleos} cores, load {carga:.2f}.',
82
+ 'processador': 'The processor is a {nome}, with {nucleos} cores.',
83
+ 'folga_boa': 'Plenty of room.',
84
+ 'folga_media': 'Still fine, but getting tighter.',
85
+ 'folga_pouca': 'That is tight: worth freeing some up.',
86
+ 'cabe_disco': 'Yes: the disk has {livre:.1f} GB free, so {pedido} fits.',
87
+ 'nao_cabe_disco': 'No: the disk has only {livre:.1f} GB free, not enough for {pedido}.',
88
+ 'cabe_ram': 'Yes: {livre} MB of memory are free, so {pedido} fits.',
89
+ 'nao_cabe_ram': 'No: only {livre} MB of memory are free, not enough for {pedido}.',
90
+ 'temp_ok': 'That is normal.',
91
+ 'temp_morna': 'Warm, but within limits.',
92
+ 'temp_quente': 'That is hot: check the cooling.',
93
+ 'cpu_folga': 'It is relaxed.',
94
+ 'cpu_media': 'Busy, but coping.',
95
+ 'cpu_cheia': 'It is overloaded right now.',
96
+ 'anotado': 'Noted: {fato}.',
97
+ 'esqueci': 'Forgotten: {fato}.',
98
+ 'esqueci_tudo': 'Done, my memory is empty now.',
99
+ 'nada_guardado': 'I have nothing saved about that.',
100
+ 'memoria': 'Here is what I remember:\n{fatos}',
101
+ 'memoria_vazia': 'My memory is empty.',
102
+ 'lembro': 'You told me: {fato}.',
103
+ 'feito': 'Done.',
104
+ 'falhou': 'That did not work: {erro}',
105
+ 'negado': 'Okay, I did not run it.',
106
+ 'de_onde': 'From {site}: {trecho}',
107
+ 'tambem': 'Also: {site} ({titulo})',
108
+ 'nao_achei': 'I could not find a good answer. The closest I found: {titulo} ({site}).',
109
+ 'nada_para_salvar': 'I have nothing to save from the step before.'}}
110
+
111
+
112
+ def t(chave, idioma, **campos):
113
+ texto = TEXTOS["pt" if str(idioma).startswith("pt") else "en"][chave]
114
+ return texto.format(**campos) if campos else texto
app/tars_atualizacao.py CHANGED
@@ -58,6 +58,16 @@ def pelo_script():
58
  return plataforma.sistema() != "linux" or MARCA_SCRIPT.exists()
59
 
60
 
 
 
 
 
 
 
 
 
 
 
61
  def versao_publicada(agora=None, buscar=None, cache=None):
62
  """A versão publicada (do cache de até 24 h, ou buscada agora), ou None."""
63
  if _desligado():
@@ -153,21 +163,47 @@ def _estado_arquivo():
153
  return Path(os.environ.get("TARS_ARQUIVO_ATUALIZACAO") or os.path.expanduser("~/.config/opentars/atualizacao.json"))
154
 
155
 
156
- def marcar_instalada(sha256):
157
- """Guarda o hash do arquivo que o usuário instalou por aqui (para não oferecer o mesmo de novo)."""
 
 
 
158
  try:
159
  arq = _estado_arquivo()
160
  arq.parent.mkdir(parents=True, exist_ok=True)
161
- arq.write_text(json.dumps({"sha256": str(sha256).lower()}), encoding="utf-8")
162
  except Exception:
163
  pass
164
 
165
 
 
 
 
 
 
 
 
 
 
166
  def _ultima_instalada():
 
167
  try:
168
- return str(json.loads(_estado_arquivo().read_text(encoding="utf-8")).get("sha256") or "")
169
  except Exception:
170
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
 
173
  def _epoch(iso):
@@ -238,8 +274,7 @@ def procurar_no_repositorio(obter_json=None, extras=(), compilado=None, ultima=N
238
  blob = str(arq.get("sha") or "").lower()
239
  if not url or not host_permitido(url, extras) or not re.fullmatch(r"[0-9a-f]{40}", blob):
240
  return None
241
- instalada = _ultima_instalada() if ultima is None else ultima
242
- if blob == instalada:
243
  return None
244
  try:
245
  commits = obter_json(URL_COMMITS + "&path=" + quote(str(arq.get("name"))))
@@ -297,7 +332,7 @@ def procurar_em_release(versao_local, obter_json=None, obter_texto=None, extras=
297
  except Exception:
298
  somas = {}
299
  meu_pacote = compilado_em() if compilado is None else compilado
300
- instalada = _ultima_instalada() if ultima is None else ultima
301
  arquivos, mais_recente = {}, 0.0
302
  for nome, a in ativos.items():
303
  tipo = "appimage" if str(nome).endswith(".AppImage") else "deb" if str(nome).endswith(".deb") else None
@@ -309,7 +344,7 @@ def procurar_em_release(versao_local, obter_json=None, obter_texto=None, extras=
309
  continue
310
  enviado = _epoch(a.get("created_at") or a.get("updated_at"))
311
  novo = (nova_versao > local) or (enviado > meu_pacote + 60)
312
- if novo and sha != instalada:
313
  arquivos[tipo] = {"nome": nome, "url": url, "sha256": sha, "id": sha}
314
  mais_recente = max(mais_recente, enviado)
315
  if not arquivos:
 
58
  return plataforma.sistema() != "linux" or MARCA_SCRIPT.exists()
59
 
60
 
61
+ def argumentos_um_clique(sistema=None):
62
+ """5.3 (F5): o "Atualizar" de quem instalou por script roda o próprio instalador. No Windows abre o PowerShell visível (o
63
+ instalador fecha o app aberto e o reabre no fim); no macOS e nos outros Linux o script roda escondido e o app reinicia."""
64
+ sistema = sistema or plataforma.sistema()
65
+ if sistema == "windows":
66
+ reabrir = '; & (Join-Path $env:LOCALAPPDATA "openTARS\\bin\\opentars-gui.cmd")'
67
+ return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", COMANDO_REINSTALAR["windows"] + reabrir]
68
+ return ["bash", "-c", "set -o pipefail; " + COMANDO_REINSTALAR[sistema]]
69
+
70
+
71
  def versao_publicada(agora=None, buscar=None, cache=None):
72
  """A versão publicada (do cache de até 24 h, ou buscada agora), ou None."""
73
  if _desligado():
 
163
  return Path(os.environ.get("TARS_ARQUIVO_ATUALIZACAO") or os.path.expanduser("~/.config/opentars/atualizacao.json"))
164
 
165
 
166
+ def marcar_instalada(*ids):
167
+ """Guarda os identificadores do arquivo que o usuário instalou por aqui (para não oferecer o mesmo de novo). O mesmo .deb
168
+ chega por dois caminhos, o upload no repositório (hash git) e a Release (SHA-256); guardando só um, o outro caminho achava
169
+ o arquivo "novo" e oferecia a atualização para sempre (10/11), então vão todos."""
170
+ ids = [str(i).lower() for i in ids if i]
171
  try:
172
  arq = _estado_arquivo()
173
  arq.parent.mkdir(parents=True, exist_ok=True)
174
+ arq.write_text(json.dumps({"sha256": ids[0] if ids else "", "ids": sorted(set(ids))}), encoding="utf-8")
175
  except Exception:
176
  pass
177
 
178
 
179
+ def ids_do_arquivo(caminho):
180
+ """[sha256, hash git] de um arquivo, os dois identificadores com que ele aparece no GitHub; [] se não der para ler."""
181
+ try:
182
+ dados = Path(caminho).read_bytes()
183
+ except Exception:
184
+ return []
185
+ return [hashlib.sha256(dados).hexdigest(), hashlib.sha1(b"blob %d\0" % len(dados) + dados).hexdigest()]
186
+
187
+
188
  def _ultima_instalada():
189
+ """Os identificadores já instalados (conjunto)."""
190
  try:
191
+ dados = json.loads(_estado_arquivo().read_text(encoding="utf-8"))
192
  except Exception:
193
+ return set()
194
+ ids = {str(i).lower() for i in dados.get("ids") or []}
195
+ if dados.get("sha256"):
196
+ ids.add(str(dados["sha256"]).lower())
197
+ return ids
198
+
199
+
200
+ def _ids_instalados(valor):
201
+ """`valor` None = o que está guardado; texto ou lista = o que o chamador (os testes) passou."""
202
+ if valor is None:
203
+ return _ultima_instalada()
204
+ if isinstance(valor, str):
205
+ return {valor.lower()} if valor else set()
206
+ return {str(i).lower() for i in valor if i}
207
 
208
 
209
  def _epoch(iso):
 
274
  blob = str(arq.get("sha") or "").lower()
275
  if not url or not host_permitido(url, extras) or not re.fullmatch(r"[0-9a-f]{40}", blob):
276
  return None
277
+ if blob in _ids_instalados(ultima):
 
278
  return None
279
  try:
280
  commits = obter_json(URL_COMMITS + "&path=" + quote(str(arq.get("name"))))
 
332
  except Exception:
333
  somas = {}
334
  meu_pacote = compilado_em() if compilado is None else compilado
335
+ instaladas = _ids_instalados(ultima)
336
  arquivos, mais_recente = {}, 0.0
337
  for nome, a in ativos.items():
338
  tipo = "appimage" if str(nome).endswith(".AppImage") else "deb" if str(nome).endswith(".deb") else None
 
344
  continue
345
  enviado = _epoch(a.get("created_at") or a.get("updated_at"))
346
  novo = (nova_versao > local) or (enviado > meu_pacote + 60)
347
+ if novo and sha not in instaladas:
348
  arquivos[tipo] = {"nome": nome, "url": url, "sha256": sha, "id": sha}
349
  mais_recente = max(mais_recente, enviado)
350
  if not arquivos:
app/tars_autoteste.py CHANGED
@@ -64,7 +64,7 @@ class _Relatorio:
64
 
65
  def _versao_ollama(core):
66
  try:
67
- r = core._SESSION.get(f"{core.OLLAMA_HOST}/api/version", timeout=core.TIMEOUT_HTTP_CURTO)
68
  return r.json().get("version") if r.status_code == 200 else None
69
  except Exception:
70
  return None
 
64
 
65
  def _versao_ollama(core):
66
  try:
67
+ r = core._SESSION.get(core.motor_lib.url("versao", core.OLLAMA_HOST), timeout=core.TIMEOUT_HTTP_CURTO)
68
  return r.json().get("version") if r.status_code == 200 else None
69
  except Exception:
70
  return None
app/tars_busca_arquivos.py CHANGED
@@ -6,12 +6,16 @@ Só leitura: não abre, não move, não apaga. Igual nos três sistemas; só bib
6
  import os
7
  import re
8
  import shutil
 
9
  import subprocess
10
  import time
11
  import unicodedata
12
  import zipfile
13
  from pathlib import Path
14
 
 
 
 
15
  VAZIAS = set("""o a os as um uma uns umas de da do das dos e em no na nos nas para pra por com que meu minha meus minhas
16
  onde esta está esse essa este aquele aquela ao aos qual quais tem sobre ache acha achar encontre encontrar procure procurar
17
  busque buscar busca arquivo arquivos documento documentos pasta the an of in on for my where is are file files find search
@@ -80,7 +84,144 @@ def _trecho(texto, palavras, largura=140):
80
  return ("…" if inicio else "") + plano[inicio:inicio + largura].strip() + "…"
81
 
82
 
83
- def buscar(frase, raiz=None, limite=5, ler=texto_do_arquivo):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  """[{caminho, nome, pasta, modificado, trecho, sem_texto, pontos}] do melhor para o pior. Um arquivo entra quando
85
  tem pelo menos metade das palavras da frase no nome, na pasta ou no texto."""
86
  palavras = termos(frase)
 
6
  import os
7
  import re
8
  import shutil
9
+ import sqlite3
10
  import subprocess
11
  import time
12
  import unicodedata
13
  import zipfile
14
  from pathlib import Path
15
 
16
+ import tars_i18n
17
+ import tars_plataforma as plataforma
18
+
19
  VAZIAS = set("""o a os as um uma uns umas de da do das dos e em no na nos nas para pra por com que meu minha meus minhas
20
  onde esta está esse essa este aquele aquela ao aos qual quais tem sobre ache acha achar encontre encontrar procure procurar
21
  busque buscar busca arquivo arquivos documento documentos pasta the an of in on for my where is are file files find search
 
84
  return ("…" if inicio else "") + plano[inicio:inicio + largura].strip() + "…"
85
 
86
 
87
+ # ---------------------------------------------------------------- índice local (5.3, F4+): opcional, desligado por padrão
88
+ # Um arquivo SQLite na pasta de dados do openTARS com o nome, a pasta e o começo do texto de cada arquivo, para a busca ser
89
+ # instantânea em pastas grandes. Fica só neste computador, nunca é enviado, e se apaga ao desligar. Sem embeddings: continua
90
+ # sendo nome e palavras, como a busca sem índice.
91
+ TEXTO_NO_INDICE = 8000 # caracteres do começo de cada arquivo
92
+ VALIDADE_INDICE_S = 3600 # a busca atualiza o índice (em segundo plano) se ele tem mais de uma hora
93
+
94
+
95
+ def indice_ligado():
96
+ return bool(tars_i18n.ler_config().get("indice_arquivos"))
97
+
98
+
99
+ def caminho_do_indice():
100
+ return plataforma.pastas()["dados"] / "indice-arquivos.db"
101
+
102
+
103
+ def ligar_indice(ligado):
104
+ """Liga ou desliga. Desligar apaga o arquivo do índice: nada fica guardado."""
105
+ tars_i18n.salvar_config(indice_arquivos=bool(ligado))
106
+ if not ligado:
107
+ try:
108
+ caminho_do_indice().unlink()
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def _abrir_indice(caminho):
114
+ caminho.parent.mkdir(parents=True, exist_ok=True)
115
+ banco = sqlite3.connect(str(caminho), timeout=10)
116
+ banco.execute("CREATE TABLE IF NOT EXISTS arquivos (caminho TEXT PRIMARY KEY, mtime REAL, nome TEXT, texto TEXT, busca TEXT)")
117
+ banco.execute("CREATE TABLE IF NOT EXISTS meta (chave TEXT PRIMARY KEY, valor TEXT)")
118
+ return banco
119
+
120
+
121
+ def atualizar_indice(raiz=None, tempo=25.0, ler=texto_do_arquivo, caminho=None):
122
+ """Põe no índice os arquivos novos ou mudados (por data de modificação) e tira os que sumiram. Devolve
123
+ {"arquivos": n, "lidos": n, "removidos": n}. Corta no tempo: o que não deu volta na próxima atualização."""
124
+ global LIMITE_TEMPO_S
125
+ raiz = Path(raiz or Path.home())
126
+ banco = _abrir_indice(caminho or caminho_do_indice())
127
+ inicio = time.monotonic()
128
+ guardados = {c: m for c, m in banco.execute("SELECT caminho, mtime FROM arquivos")}
129
+ vistos, lidos = set(), 0
130
+ antes, LIMITE_TEMPO_S = LIMITE_TEMPO_S, tempo # a varredura do índice pode demorar mais que a busca sem índice
131
+ try:
132
+ for arquivo in _arquivos(raiz, inicio):
133
+ chave = str(arquivo)
134
+ try:
135
+ st = arquivo.stat()
136
+ except OSError:
137
+ continue
138
+ vistos.add(chave)
139
+ if guardados.get(chave) == st.st_mtime:
140
+ continue
141
+ texto = ""
142
+ if arquivo.suffix.lower() in LEGIVEIS and st.st_size <= LIMITE_TAMANHO:
143
+ texto = (ler(arquivo) or "")[:TEXTO_NO_INDICE * 2]
144
+ texto = " ".join(texto.split())[:TEXTO_NO_INDICE]
145
+ pastas = " ".join(arquivo.relative_to(raiz).parts[:-1])
146
+ banco.execute("INSERT OR REPLACE INTO arquivos VALUES (?,?,?,?,?)",
147
+ (chave, st.st_mtime, arquivo.name, texto, normalizar(f"{arquivo.stem} {pastas} {texto}")))
148
+ lidos += 1
149
+ finally:
150
+ LIMITE_TEMPO_S = antes
151
+ completo = time.monotonic() - inicio < tempo
152
+ sumiram = [c for c in guardados if c not in vistos] if completo else [] # só apaga se a varredura foi até o fim
153
+ banco.executemany("DELETE FROM arquivos WHERE caminho=?", [(c,) for c in sumiram])
154
+ banco.execute("INSERT OR REPLACE INTO meta VALUES ('atualizado', ?)", (str(time.time()),))
155
+ banco.commit()
156
+ total = banco.execute("SELECT COUNT(*) FROM arquivos").fetchone()[0]
157
+ banco.close()
158
+ return {"arquivos": total, "lidos": lidos, "removidos": len(sumiram)}
159
+
160
+
161
+ def indice_idade(caminho=None):
162
+ """Segundos desde a última atualização, ou None se não há índice."""
163
+ caminho = caminho or caminho_do_indice()
164
+ if not caminho.exists():
165
+ return None
166
+ try:
167
+ banco = _abrir_indice(caminho)
168
+ linha = banco.execute("SELECT valor FROM meta WHERE chave='atualizado'").fetchone()
169
+ banco.close()
170
+ return time.time() - float(linha[0]) if linha else None
171
+ except (sqlite3.Error, ValueError):
172
+ return None
173
+
174
+
175
+ def buscar_no_indice(frase, raiz=None, limite=5, caminho=None):
176
+ """O mesmo resultado de buscar(), lido do índice (instantâneo). None se não há índice para usar."""
177
+ palavras = termos(frase)
178
+ if not palavras:
179
+ return []
180
+ caminho = caminho or caminho_do_indice()
181
+ if indice_idade(caminho) is None:
182
+ return None
183
+ raiz = str(Path(raiz or Path.home()))
184
+ banco = _abrir_indice(caminho)
185
+ try:
186
+ filtro = " OR ".join("busca LIKE ?" for _ in palavras)
187
+ linhas = banco.execute(f"SELECT caminho, mtime, nome, texto, busca FROM arquivos WHERE caminho LIKE ? AND ({filtro})",
188
+ [raiz.rstrip(os.sep) + os.sep + "%"] + [f"%{p}%" for p in palavras]).fetchall()
189
+ finally:
190
+ banco.close()
191
+ minimo = max(1, (len(palavras) + 1) // 2)
192
+ achados = []
193
+ for arquivo, modificado, nome, texto, busca_n in linhas:
194
+ stem = normalizar(Path(nome).stem)
195
+ pasta_n = normalizar(" ".join(Path(arquivo).relative_to(raiz).parts[:-1]))
196
+ no_nome = {p for p in palavras if p in stem}
197
+ na_pasta = {p for p in palavras if p in pasta_n} - no_nome
198
+ no_texto = {p for p in palavras if texto and p in normalizar(texto)} - no_nome - na_pasta
199
+ if len(no_nome | na_pasta | no_texto) < minimo:
200
+ continue
201
+ achados.append({"caminho": arquivo, "nome": nome, "pasta": str(Path(arquivo).parent), "modificado": modificado,
202
+ "trecho": _trecho(texto, palavras) if texto else "",
203
+ "sem_texto": nome.lower().endswith(".pdf") and not texto.strip(),
204
+ "pontos": 3 * len(no_nome) + 2 * len(na_pasta) + len(no_texto)})
205
+ achados.sort(key=lambda a: (-a["pontos"], -a["modificado"]))
206
+ return achados[:limite]
207
+
208
+
209
+ def buscar(frase, raiz=None, limite=5, ler=texto_do_arquivo, indice=None):
210
+ """Com o índice ligado e existente, lê dele (e, se ele tem mais de uma hora, o atualiza em segundo plano); senão anda pelas
211
+ pastas como na 5.2. `indice`: True/False força; None segue a configuração."""
212
+ usar = indice_ligado() if indice is None else indice
213
+ if usar:
214
+ achados = buscar_no_indice(frase, raiz, limite)
215
+ if achados is not None:
216
+ idade = indice_idade()
217
+ if idade is not None and idade > VALIDADE_INDICE_S:
218
+ import threading
219
+ threading.Thread(target=lambda: atualizar_indice(raiz), daemon=True).start()
220
+ return achados
221
+ return _buscar_andando(frase, raiz, limite, ler)
222
+
223
+
224
+ def _buscar_andando(frase, raiz=None, limite=5, ler=texto_do_arquivo):
225
  """[{caminho, nome, pasta, modificado, trecho, sem_texto, pontos}] do melhor para o pior. Um arquivo entra quando
226
  tem pelo menos metade das palavras da frase no nome, na pasta ou no texto."""
227
  palavras = termos(frase)
app/tars_confianca.py CHANGED
@@ -21,6 +21,9 @@ _NAO_E_FATO = re.compile( # pedido criativo ou de texto, em qualquer posição
21
  r"\b(escreva|escreve|poema|poesia|conto|historia|cancao|musica|letra|verso|rima|piada|crie|cria|invente|inventa|"
22
  r"faca|faz|gere|gera|sugira|traduza|traduz|resuma|resume|reescreva|corrija|"
23
  r"write|poem|poetry|story|joke|song|lyrics|translate|summari[sz]e|rewrite|fix|improve|draft|make up|tell me)\b", re.I)
 
 
 
24
  _PERGUNTA = re.compile( # a forma de pergunta de fato: termina com "?" ou começa com palavra interrogativa
25
  r"\?\s*$|^(quem|qual|quais|quando|quanto|quanta|quantos|quantas|onde|por que|porque|o que|que|como|existe|"
26
  r"who|what|when|where|which|how|is|are|does|did)\b")
@@ -55,7 +58,8 @@ def vale_conferir(tarefa, pedido, resposta):
55
  if len((resposta or "").strip()) < MIN_CARACTERES or "```" in (resposta or ""):
56
  return False
57
  pedido = _sem_acento(pedido)
58
- return len(pedido.split()) >= 3 and _PERGUNTA.search(pedido) is not None and not _NAO_E_FATO.search(pedido)
 
59
 
60
 
61
  def logprobs_do_pedaco(pedaco):
 
21
  r"\b(escreva|escreve|poema|poesia|conto|historia|cancao|musica|letra|verso|rima|piada|crie|cria|invente|inventa|"
22
  r"faca|faz|gere|gera|sugira|traduza|traduz|resuma|resume|reescreva|corrija|"
23
  r"write|poem|poetry|story|joke|song|lyrics|translate|summari[sz]e|rewrite|fix|improve|draft|make up|tell me)\b", re.I)
24
+ _SAUDACAO = re.compile( # "e aí, tudo certo?" tem forma de pergunta, mas o app mandava pesquisar: a prova de 100 cobrava isso do modelo
25
+ r"^(e ai|oi|ola|opa|fala|bom dia|boa tarde|boa noite|tudo bem|tudo certo|como vai|como voce esta|"
26
+ r"hey|hi|hello|good (morning|afternoon|evening)|how are you|how's it going)\b", re.I)
27
  _PERGUNTA = re.compile( # a forma de pergunta de fato: termina com "?" ou começa com palavra interrogativa
28
  r"\?\s*$|^(quem|qual|quais|quando|quanto|quanta|quantos|quantas|onde|por que|porque|o que|que|como|existe|"
29
  r"who|what|when|where|which|how|is|are|does|did)\b")
 
58
  if len((resposta or "").strip()) < MIN_CARACTERES or "```" in (resposta or ""):
59
  return False
60
  pedido = _sem_acento(pedido)
61
+ return len(pedido.split()) >= 3 and _PERGUNTA.search(pedido) is not None and not _NAO_E_FATO.search(pedido) \
62
+ and not _SAUDACAO.search(pedido)
63
 
64
 
65
  def logprobs_do_pedaco(pedaco):
app/tars_conta.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contas por código: o modelo de 270M erra aritmética e a busca devolve lixo ("square root of 128" trazia a Wikipédia)."""
2
+ import ast
3
+ import math
4
+ import operator
5
+ import re
6
+
7
+ _PEDIDO = re.compile(r"^(please |por favor |pode |can you |could you )?(what is|what's|whats|how much is|how much|calculate|calc|compute|solve|work out|"
8
+ r"quanto e|quanto da|quanto vale|qual e|qual o valor de|qual|calcula|calcule|calcular|resolve|resolva|conta)?\s*(a |o |the )?")
9
+ _PALAVRAS = [
10
+ (r"\braiz quadrada d[eao]\s*", "sqrt "), (r"\bsquare root of\s*", "sqrt "), (r"\braiz de\s*", "sqrt "), (r"\bsqrt( of)?\s*", "sqrt "),
11
+ (r"\bmetade d[eao]\s*", "0.5*"), (r"\bhalf of\s*", "0.5*"), (r"\bdobro d[eao]\s*", "2*"), (r"\bdouble of\s*", "2*"), (r"\bdouble\s+(?=\d)", "2*"), (r"\btriplo d[eao]\s*", "3*"),
12
+ (r"(\d)\s*(%|por cento|percent)\s*(de|do|da|of)\s*", r"\1*0.01*"),
13
+ (r"\b(elevado a|elevado ao?|to the power of|to the)\s*", "**"), (r"\bao quadrado\b|\bsquared\b", "**2"), (r"\bao cubo\b|\bcubed\b", "**3"), (r"\^", "**"),
14
+ (r"\b(vezes|times|multiplicado por|multiplied by)\b", "*"), (r"(?<=\d)\s*x\s*(?=\d)", "*"),
15
+ (r"\b(dividido por|divided by|over)\b", "/"), (r"\b(mais|plus)\b", "+"), (r"\b(menos|minus)\b", "-"),
16
+ ]
17
+ _OPERACOES = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Mod: operator.mod}
18
+
19
+
20
+ def _valor(no):
21
+ if isinstance(no, ast.Constant) and type(no.value) in (int, float):
22
+ return no.value
23
+ if isinstance(no, ast.UnaryOp) and isinstance(no.op, (ast.USub, ast.UAdd)):
24
+ return -_valor(no.operand) if isinstance(no.op, ast.USub) else _valor(no.operand)
25
+ if isinstance(no, ast.BinOp) and isinstance(no.op, ast.Pow):
26
+ base, expoente = _valor(no.left), _valor(no.right)
27
+ if abs(expoente) > 300 or abs(base) > 1e6: # 9**9**9 prenderia a placa
28
+ raise ValueError
29
+ return base ** expoente
30
+ if isinstance(no, ast.BinOp) and type(no.op) in _OPERACOES:
31
+ return _OPERACOES[type(no.op)](_valor(no.left), _valor(no.right))
32
+ if isinstance(no, ast.Call) and getattr(no.func, "id", "") == "sqrt" and len(no.args) == 1:
33
+ return math.sqrt(_valor(no.args[0]))
34
+ raise ValueError
35
+
36
+
37
+ def _numero(valor, idioma):
38
+ if isinstance(valor, float) and valor.is_integer() and abs(valor) < 1e15:
39
+ valor = int(valor)
40
+ texto = str(valor) if isinstance(valor, int) else f"{valor:.6g}" if abs(valor) >= 1e6 else f"{valor:.6f}".rstrip("0")
41
+ return texto.replace(".", ",") if idioma == "pt" else texto
42
+
43
+
44
+ def resolver(n, idioma):
45
+ """'2 + 2 = 4' para um pedido normalizado que é só uma conta; None para o resto."""
46
+ texto = _PEDIDO.sub("", n.strip(" ?.!=")).strip(" ?.!=")
47
+ for padrao, troca in _PALAVRAS:
48
+ texto = re.sub(padrao, troca, texto)
49
+ texto = re.sub(r"(?<=\d),(?=\d)", ".", texto)
50
+ texto = re.sub(r"sqrt\s*(\d+(?:\.\d+)?)", r"sqrt(\1)", texto).strip()
51
+ if not re.search(r"\d", texto) or not re.search(r"[-+*/%]|sqrt", texto) or re.fullmatch(r"[\d/.-]+", texto) and re.search(r"\d[/-]\d+[/-]\d", texto):
52
+ return None # sem operação, ou uma data
53
+ if re.search(r"[^\d\s+\-*/%().sqrt]", texto):
54
+ return None
55
+ try:
56
+ return _numero(_valor(ast.parse(texto, mode="eval").body), idioma)
57
+ except (ValueError, SyntaxError, ZeroDivisionError, OverflowError, TypeError):
58
+ return None
app/tars_email.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """5.3 (F4+++): e-mail no assistente, DESLIGADO por padrão: só vale depois de a pessoa pôr a conta em Configurações > Assistente.
2
+
3
+ - A senha vai para o cofre do sistema (Secret Service no Linux, Gerenciador de Credenciais no Windows, Chaveiro no macOS) pelo pacote
4
+ `keyring`, e NUNCA para o config.json nem para o log. Sem o `keyring`, o e-mail fica desligado (não guarda senha em texto).
5
+ - Ler é só leitura: a caixa é aberta em modo somente leitura e os cabeçalhos vêm com PEEK, então nada vira "lido". "Meus e-mails de hoje"
6
+ lista quem escreveu e o assunto; "ler o último e-mail de X" mostra o começo do texto. O texto do e-mail é dado: nunca vira ordem.
7
+ - Enviar SEMPRE pergunta antes, com o destinatário, o assunto e o texto na tela; um destinatário por vez; sem lista nem cópia.
8
+ - Gmail e Outlook pessoal exigem senha de app (ou já não aceitam senha); contas com login por navegador (OAuth) não funcionam aqui.
9
+ Nada disso foi usado com um servidor de verdade (os testes usam servidores falsos).
10
+ """
11
+ import datetime as dt
12
+ import email
13
+ import email.header
14
+ import email.message
15
+ import email.utils
16
+ import imaplib
17
+ import re
18
+ import smtplib
19
+ import ssl
20
+ import unicodedata
21
+
22
+ import tars_i18n
23
+
24
+ SERVICO = "openTARS-email"
25
+ LIMITE_LISTA = 12
26
+ LIMITE_TEXTO = 1200
27
+ LIMITE_BAIXAR = 200_000 # bytes do começo da mensagem (anexos grandes não vêm inteiros)
28
+ TEMPO_REDE = 15
29
+ MESES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] # o IMAP só entende estes
30
+ SERVIDORES = { # domínio -> (imap, smtp)
31
+ "gmail.com": ("imap.gmail.com", "smtp.gmail.com"), "googlemail.com": ("imap.gmail.com", "smtp.gmail.com"),
32
+ "outlook.com": ("outlook.office365.com", "smtp-mail.outlook.com"), "hotmail.com": ("outlook.office365.com", "smtp-mail.outlook.com"),
33
+ "live.com": ("outlook.office365.com", "smtp-mail.outlook.com"), "icloud.com": ("imap.mail.me.com", "smtp.mail.me.com"),
34
+ "me.com": ("imap.mail.me.com", "smtp.mail.me.com"), "yahoo.com": ("imap.mail.yahoo.com", "smtp.mail.yahoo.com"),
35
+ "yahoo.com.br": ("imap.mail.yahoo.com", "smtp.mail.yahoo.com"), "uol.com.br": ("imap.uol.com.br", "smtps.uol.com.br"),
36
+ "proton.me": ("127.0.0.1", "127.0.0.1"), # o Proton só fala IMAP pelo programa Bridge, que a pessoa instala e ajusta
37
+ }
38
+
39
+ TEXTOS = {
40
+ "pt": {"desligado": "O e-mail está desligado. Ligue em Configurações > Assistente.", "sem_senha": "Não achei a senha da conta no cofre do sistema. Digite de novo em Configurações > Assistente.",
41
+ "erro": "Não consegui falar com o servidor de e-mail ({erro}).", "nada_hoje": "Nenhum e-mail novo hoje.", "hoje": "Hoje chegaram {n} e-mails:",
42
+ "hoje_um": "Hoje chegou 1 e-mail:", "mais": "… e mais {n}.", "nao_achei": "Não achei e-mail de {quem}.", "de": "De {de}, {data}\nAssunto: {assunto}\n\n{texto}",
43
+ "sem_texto": "(sem texto)", "pedir": "Mandar este e-mail?\nPara: {para}\nAssunto: {assunto}\n\n{texto}", "negado": "Tudo bem, não enviei.",
44
+ "enviado": "E-mail enviado para {para}.", "sem_corpo": "Diga o que escrever: envia um e-mail para ana@exemplo.com dizendo que chego às 9h.",
45
+ "so_um": "Mando um e-mail por vez, para um destinatário.", "sem_assunto": "(sem assunto)"},
46
+ "en": {"desligado": "Email is off. Turn it on in Settings > Assistant.", "sem_senha": "I couldn't find the account password in the system keychain. Type it again in Settings > Assistant.",
47
+ "erro": "I couldn't reach the mail server ({erro}).", "nada_hoje": "No new email today.", "hoje": "{n} emails came in today:",
48
+ "hoje_um": "1 email came in today:", "mais": "… and {n} more.", "nao_achei": "I found no email from {quem}.", "de": "From {de}, {data}\nSubject: {assunto}\n\n{texto}",
49
+ "sem_texto": "(no text)", "pedir": "Send this email?\nTo: {para}\nSubject: {assunto}\n\n{texto}", "negado": "Okay, I didn't send it.",
50
+ "enviado": "Email sent to {para}.", "sem_corpo": "Tell me what to write: send an email to ana@example.com saying I arrive at 9.",
51
+ "so_um": "I send one email at a time, to one recipient.", "sem_assunto": "(no subject)"},
52
+ }
53
+
54
+
55
+ def _t(chave, idioma, **kw):
56
+ s = TEXTOS["pt" if idioma == "pt" else "en"][chave]
57
+ return s.format(**kw) if kw else s
58
+
59
+
60
+ def normalizar(texto):
61
+ return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower()
62
+
63
+
64
+ # ---------------------------------------------------------------- conta e senha
65
+ class SemCofre(RuntimeError):
66
+ """O pacote keyring não está instalado ou o sistema não tem cofre de senhas."""
67
+
68
+
69
+ def _cofre():
70
+ try:
71
+ import keyring
72
+ from keyring.backends import fail
73
+ except ImportError as e:
74
+ raise SemCofre("keyring não instalado") from e
75
+ if isinstance(keyring.get_keyring(), fail.Keyring):
76
+ raise SemCofre("este sistema não tem cofre de senhas")
77
+ return keyring
78
+
79
+
80
+ def guardar_senha(usuario, senha):
81
+ _cofre().set_password(SERVICO, usuario, senha)
82
+
83
+
84
+ def ler_senha(usuario):
85
+ try:
86
+ return _cofre().get_password(SERVICO, usuario)
87
+ except SemCofre:
88
+ return None
89
+
90
+
91
+ def apagar_senha(usuario):
92
+ try:
93
+ _cofre().delete_password(SERVICO, usuario)
94
+ except Exception: # noqa: BLE001 (já não existia)
95
+ pass
96
+
97
+
98
+ def conta():
99
+ """{usuario, imap, imap_porta, smtp, smtp_porta} escolhida pela pessoa, ou None (desligado, o padrão)."""
100
+ c = tars_i18n.ler_config().get("email")
101
+ return c if isinstance(c, dict) and c.get("usuario") and c.get("imap") and c.get("smtp") else None
102
+
103
+
104
+ def servidores_do_dominio(endereco):
105
+ """(imap, smtp) provável para o endereço, ou ('', '') se não conheço o provedor."""
106
+ return SERVIDORES.get(endereco.rsplit("@", 1)[-1].lower(), ("", ""))
107
+
108
+
109
+ def salvar_conta(usuario, senha, imap="", smtp="", imap_porta=993, smtp_porta=465):
110
+ """Guarda a conta no config e a senha no cofre. Levanta SemCofre se não há cofre (aí nada é guardado). Vazio desliga."""
111
+ if not usuario:
112
+ antiga = conta()
113
+ tars_i18n.salvar_config(email={})
114
+ if antiga:
115
+ apagar_senha(antiga["usuario"])
116
+ return
117
+ padrao_imap, padrao_smtp = servidores_do_dominio(usuario)
118
+ if senha:
119
+ guardar_senha(usuario, senha) # primeiro o cofre: se falhar, a conta não fica pela metade
120
+ tars_i18n.salvar_config(email={"usuario": usuario, "imap": imap or padrao_imap, "smtp": smtp or padrao_smtp,
121
+ "imap_porta": int(imap_porta), "smtp_porta": int(smtp_porta)})
122
+
123
+
124
+ # ---------------------------------------------------------------- IMAP: ler
125
+ def _decodificar(valor):
126
+ try:
127
+ return str(email.header.make_header(email.header.decode_header(valor or ""))).strip()
128
+ except Exception: # noqa: BLE001 (cabeçalho torto: o texto cru serve)
129
+ return str(valor or "").strip()
130
+
131
+
132
+ def _abrir_imap(c, senha):
133
+ if not senha:
134
+ raise SemCofre("sem senha")
135
+ # sem ssl_context o imaplib usa ssl._create_stdlib_context(), que não confere o certificado (revisão de 13/11)
136
+ caixa = imaplib.IMAP4_SSL(c["imap"], int(c.get("imap_porta") or 993), timeout=TEMPO_REDE, ssl_context=ssl.create_default_context())
137
+ caixa.login(c["usuario"], senha)
138
+ caixa.select("INBOX", readonly=True) # só leitura: nada vira "lido", nada é apagado
139
+ return caixa
140
+
141
+
142
+ def _cabecalhos(caixa, numeros):
143
+ saida = []
144
+ for n in numeros:
145
+ tipo, dados = caixa.fetch(n, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
146
+ if tipo != "OK" or not dados or not isinstance(dados[0], tuple):
147
+ continue
148
+ msg = email.message_from_bytes(dados[0][1])
149
+ nome, endereco = email.utils.parseaddr(_decodificar(msg.get("From")))
150
+ saida.append({"de": nome or endereco, "endereco": endereco, "assunto": _decodificar(msg.get("Subject")), "data": msg.get("Date", "")})
151
+ return saida
152
+
153
+
154
+ def de_hoje(agora=None, c=None, senha=None):
155
+ """Os e-mails que chegaram hoje (do mais novo para o mais velho), só cabeçalhos."""
156
+ c, agora = c or conta(), agora or dt.datetime.now()
157
+ caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
158
+ try:
159
+ desde = f"{agora.day:02d}-{MESES[agora.month - 1]}-{agora.year}"
160
+ tipo, dados = caixa.search(None, "SINCE", desde)
161
+ numeros = (dados[0].split() if tipo == "OK" and dados and dados[0] else [])[::-1]
162
+ return {"total": len(numeros), "itens": _cabecalhos(caixa, numeros[:LIMITE_LISTA])}
163
+ finally:
164
+ try:
165
+ caixa.logout()
166
+ except Exception: # noqa: BLE001
167
+ pass
168
+
169
+
170
+ def _texto_da_mensagem(msg):
171
+ for parte in msg.walk() if msg.is_multipart() else [msg]:
172
+ if parte.get_content_type() == "text/plain" and not parte.get_filename():
173
+ carga = parte.get_payload(decode=True) or b""
174
+ return carga.decode(parte.get_content_charset() or "utf-8", "replace").strip()
175
+ for parte in msg.walk() if msg.is_multipart() else [msg]:
176
+ if parte.get_content_type() == "text/html":
177
+ carga = (parte.get_payload(decode=True) or b"").decode(parte.get_content_charset() or "utf-8", "replace")
178
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", re.sub(r"(?is)<(script|style).*?</\1>", " ", carga))).strip()
179
+ return ""
180
+
181
+
182
+ def ultimo_de(quem, c=None, senha=None):
183
+ """O e-mail mais recente de alguém (nome ou endereço): {de, data, assunto, texto} ou None."""
184
+ c = c or conta()
185
+ quem = re.sub(r'["\\\r\n]', "", quem).strip()
186
+ if not quem:
187
+ return None
188
+ caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
189
+ try:
190
+ tipo, dados = caixa.search(None, "FROM", f'"{quem}"')
191
+ numeros = dados[0].split() if tipo == "OK" and dados and dados[0] else []
192
+ if not numeros:
193
+ return None
194
+ tipo, dados = caixa.fetch(numeros[-1], f"(BODY.PEEK[]<0.{LIMITE_BAIXAR}>)")
195
+ if tipo != "OK" or not dados or not isinstance(dados[0], tuple):
196
+ return None
197
+ msg = email.message_from_bytes(dados[0][1])
198
+ nome, endereco = email.utils.parseaddr(_decodificar(msg.get("From")))
199
+ return {"de": nome or endereco, "data": msg.get("Date", ""), "assunto": _decodificar(msg.get("Subject")),
200
+ "texto": _texto_da_mensagem(msg)[:LIMITE_TEXTO]}
201
+ finally:
202
+ try:
203
+ caixa.logout()
204
+ except Exception: # noqa: BLE001
205
+ pass
206
+
207
+
208
+ # ---------------------------------------------------------------- SMTP: enviar
209
+ EMAIL_VALIDO = re.compile(r"^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$")
210
+
211
+
212
+ def enviar(para, assunto, texto, c=None, senha=None):
213
+ """Manda UM e-mail. Quem chama já pediu a aprovação da pessoa."""
214
+ c = c or conta()
215
+ if not EMAIL_VALIDO.match(para):
216
+ raise ValueError("destinatário inválido")
217
+ if not (senha or ler_senha(c["usuario"])):
218
+ raise SemCofre("sem senha")
219
+ msg = email.message.EmailMessage()
220
+ msg["From"], msg["To"], msg["Subject"] = c["usuario"], para, assunto
221
+ msg["Date"] = email.utils.formatdate(localtime=True)
222
+ msg.set_content(texto)
223
+ porta, contexto = int(c.get("smtp_porta") or 465), ssl.create_default_context()
224
+ senha = senha or ler_senha(c["usuario"])
225
+ if porta == 465:
226
+ with smtplib.SMTP_SSL(c["smtp"], porta, timeout=TEMPO_REDE, context=contexto) as s:
227
+ s.login(c["usuario"], senha)
228
+ s.send_message(msg)
229
+ else:
230
+ with smtplib.SMTP(c["smtp"], porta, timeout=TEMPO_REDE) as s:
231
+ s.starttls(context=contexto)
232
+ s.login(c["usuario"], senha)
233
+ s.send_message(msg)
234
+
235
+
236
+ def testar(c=None, senha=None):
237
+ """Entra na caixa e sai (só leitura). Devolve None se deu certo, ou o motivo."""
238
+ c = c or conta()
239
+ try:
240
+ caixa = _abrir_imap(c, senha or ler_senha(c["usuario"]))
241
+ caixa.logout()
242
+ return None
243
+ except (imaplib.IMAP4.error, OSError, ssl.SSLError) as e:
244
+ return str(e)[:100] or type(e).__name__
245
+
246
+
247
+ # ---------------------------------------------------------------- frases
248
+ _NOVOS = re.compile(r"\b(meus|my|novos?|new|chegou|chegaram|tenho|do i have|check|ver|mostra|mostre|show|quais|que|what|resume|resumo|summar\w+)\b.*"
249
+ r"\be-?mails?\b.*\b(hoje|today)\b|\b(tenho|do i have|any)\b.*\b(novos?|new)\b.*\be-?mails?\b|\b(novos?|new) e-?mails?\b")
250
+ _LER = re.compile(r"\b(ler|le|leia|abre|abra|mostra|mostre|read|show|open)\b.*\b(ultimo|ultima|last|latest|most recent)\b.*\be-?mail\b.*"
251
+ r"\b(de|da|do|from)\s+(?P<quem>.+)$")
252
+ _ENVIAR = re.compile(r"^\s*(?:por favor,?\s+|please,?\s+)?(?:envia|envie|enviar|manda|mande|mandar|escreve|escreva|send|write|email)\b.*?\be-?mails?\b.*?"
253
+ r"\b(?:para|pra|to)\s+(?P<para>[^\s,;<>]+@[^\s,;<>]+?)[.,;]?(?P<resto>\s.*)?$", re.IGNORECASE | re.DOTALL)
254
+ _CORPO = re.compile(r"\b(?:dizendo|falando|avisando|com o texto|com a mensagem|saying|telling (?:him|her|them)|with the message|that says)\b[:\s]*(?P<c>.+)$", re.IGNORECASE | re.DOTALL)
255
+ _ASSUNTO = re.compile(r"\b(?:assunto|subject)\b[:\s]+(?P<a>.+?)(?=\s+(?:dizendo|falando|avisando|com o texto|com a mensagem|saying|with the message|that says)\b|$)", re.IGNORECASE | re.DOTALL)
256
+
257
+
258
+ def _data_curta(texto):
259
+ try:
260
+ return email.utils.parsedate_to_datetime(texto).astimezone().strftime("%d/%m %H:%M")
261
+ except (TypeError, ValueError):
262
+ return texto[:16]
263
+
264
+
265
+ def responder(pedido, idioma, core=None):
266
+ """Lista os e-mails de hoje, lê o último de alguém ou manda um e-mail (com aprovação). None se a frase não é de e-mail.
267
+ Desligado (sem conta) também devolve None: o pedido segue para o modelo, como antes."""
268
+ n = normalizar(pedido).replace("e-mail", "email")
269
+ c = conta()
270
+ if c is None:
271
+ return None
272
+ m_env = _ENVIAR.match(pedido)
273
+ if m_env:
274
+ return _enviar_por_frase(m_env, idioma, core, c)
275
+ try:
276
+ m = _LER.search(n)
277
+ if m:
278
+ quem = pedido[len(pedido) - len(m.group("quem")):].strip(" ?.!") # com as maiúsculas e acentos de quem escreveu
279
+ achado = ultimo_de(quem, c)
280
+ if not achado:
281
+ return _t("nao_achei", idioma, quem=quem)
282
+ return _t("de", idioma, de=achado["de"], data=_data_curta(achado["data"]), assunto=achado["assunto"] or _t("sem_assunto", idioma),
283
+ texto=achado["texto"] or _t("sem_texto", idioma))
284
+ if _NOVOS.search(n):
285
+ hoje = de_hoje(c=c)
286
+ if not hoje["itens"]:
287
+ return _t("nada_hoje", idioma)
288
+ linhas = [_t("hoje_um" if hoje["total"] == 1 else "hoje", idioma, n=hoje["total"])]
289
+ linhas += [f"• {i['de']}: {i['assunto'] or _t('sem_assunto', idioma)}" for i in hoje["itens"]]
290
+ if hoje["total"] > len(hoje["itens"]):
291
+ linhas.append(_t("mais", idioma, n=hoje["total"] - len(hoje["itens"])))
292
+ return "\n".join(linhas)
293
+ except SemCofre:
294
+ return _t("sem_senha", idioma)
295
+ except (imaplib.IMAP4.error, OSError, ssl.SSLError, smtplib.SMTPException) as e:
296
+ return _t("erro", idioma, erro=str(e)[:80] or type(e).__name__)
297
+ return None
298
+
299
+
300
+ def _enviar_por_frase(m, idioma, core, c):
301
+ resto = m.group("resto") or ""
302
+ corpo = _CORPO.search(resto)
303
+ if "@" in (resto[:corpo.start()] if corpo else ""): # "para a@x.com e b@y.com dizendo...": um destinatário por vez
304
+ return _t("so_um", idioma)
305
+ texto = re.sub(r"^(?:que|that)\s+", "", (corpo.group("c") if corpo else resto.strip(" :,-")).strip(), flags=re.IGNORECASE)
306
+ if not texto:
307
+ return _t("sem_corpo", idioma)
308
+ texto = texto[:1].upper() + texto[1:]
309
+ assunto = _ASSUNTO.search(resto)
310
+ assunto = (assunto.group("a").strip(" .,:") if assunto else "") or _t("sem_assunto", idioma)
311
+ para = m.group("para").strip("<>")
312
+ if not EMAIL_VALIDO.match(para):
313
+ return _t("sem_corpo", idioma)
314
+ # nunca sai sem a pessoa ver e dizer sim: sem janela para perguntar, ou com as perguntas desligadas (TARS_SEM_CONFIRMACAO_PERIGOSOS), não envia
315
+ if core is None or not getattr(core, "_CONFIRMAR_COMANDOS_PERIGOSOS", False) \
316
+ or not core._confirmar_comando_perigoso(_t("pedir", idioma, para=para, assunto=assunto, texto=texto[:500])):
317
+ return _t("negado", idioma)
318
+ try:
319
+ enviar(para, assunto, texto, c)
320
+ except SemCofre:
321
+ return _t("sem_senha", idioma)
322
+ except (imaplib.IMAP4.error, OSError, ssl.SSLError, smtplib.SMTPException, ValueError) as e:
323
+ return _t("erro", idioma, erro=str(e)[:80] or type(e).__name__)
324
+ return _t("enviado", idioma, para=para)
app/tars_gerenciar_modelos.py CHANGED
@@ -11,6 +11,7 @@ import urllib.error
11
  import urllib.request
12
 
13
  import tars_primeiro_uso as pu
 
14
 
15
 
16
  def _tamanho_gb(m):
@@ -20,7 +21,7 @@ def _tamanho_gb(m):
20
  def listar(host, abrir=None):
21
  """Os modelos instalados, do maior para o menor: [{nome, gb, parametros, quantizacao, familia, data, nuvem, oficial}].
22
  Levanta OSError se o Ollama não responder."""
23
- pedido = urllib.request.Request(f"{host}/api/tags")
24
  with (abrir or urllib.request.urlopen)(pedido, timeout=6) as r:
25
  dados = json.loads(r.read())
26
  saida = []
@@ -50,7 +51,7 @@ def apagar(nome, host, abrir=None):
50
  """Apaga `nome` do Ollama (DELETE /api/delete). Devolve (ok, erro). Os modelos que outros modelos compartilham
51
  continuam: o Ollama só remove as camadas que mais ninguém usa."""
52
  corpo = json.dumps({"model": nome, "name": nome}).encode() # "name" para os Ollama mais antigos
53
- pedido = urllib.request.Request(f"{host}/api/delete", data=corpo, method="DELETE",
54
  headers={"Content-Type": "application/json"})
55
  try:
56
  with (abrir or urllib.request.urlopen)(pedido, timeout=30):
@@ -72,7 +73,7 @@ REGISTRO = "https://registry.ollama.ai/v2/{repositorio}/manifests/{tag}"
72
 
73
  def camada_local(nome, host, abrir=None):
74
  """O digest da camada de pesos do modelo instalado, ou "" se não deu para saber."""
75
- pedido = urllib.request.Request(f"{host}/api/show", data=json.dumps({"model": nome}).encode(),
76
  headers={"Content-Type": "application/json"})
77
  with (abrir or urllib.request.urlopen)(pedido, timeout=10) as r:
78
  achados = re.findall(r"sha256[-:]([0-9a-f]{64})", json.loads(r.read()).get("modelfile") or "")
 
11
  import urllib.request
12
 
13
  import tars_primeiro_uso as pu
14
+ import tars_motor as motor
15
 
16
 
17
  def _tamanho_gb(m):
 
21
  def listar(host, abrir=None):
22
  """Os modelos instalados, do maior para o menor: [{nome, gb, parametros, quantizacao, familia, data, nuvem, oficial}].
23
  Levanta OSError se o Ollama não responder."""
24
+ pedido = urllib.request.Request(motor.url("modelos", host))
25
  with (abrir or urllib.request.urlopen)(pedido, timeout=6) as r:
26
  dados = json.loads(r.read())
27
  saida = []
 
51
  """Apaga `nome` do Ollama (DELETE /api/delete). Devolve (ok, erro). Os modelos que outros modelos compartilham
52
  continuam: o Ollama só remove as camadas que mais ninguém usa."""
53
  corpo = json.dumps({"model": nome, "name": nome}).encode() # "name" para os Ollama mais antigos
54
+ pedido = urllib.request.Request(motor.url("apagar", host), data=corpo, method="DELETE",
55
  headers={"Content-Type": "application/json"})
56
  try:
57
  with (abrir or urllib.request.urlopen)(pedido, timeout=30):
 
73
 
74
  def camada_local(nome, host, abrir=None):
75
  """O digest da camada de pesos do modelo instalado, ou "" se não deu para saber."""
76
+ pedido = urllib.request.Request(motor.url("detalhes", host), data=json.dumps({"model": nome}).encode(),
77
  headers={"Content-Type": "application/json"})
78
  with (abrir or urllib.request.urlopen)(pedido, timeout=10) as r:
79
  achados = re.findall(r"sha256[-:]([0-9a-f]{64})", json.loads(r.read()).get("modelfile") or "")
app/tars_gui.py CHANGED
@@ -1299,30 +1299,10 @@ class JanelaTars(tk.Tk):
1299
  self._trabalho = threading.Thread(target=alvo, daemon=True)
1300
  self._trabalho.start()
1301
 
1302
- def _na_ui(self, funcao):
1303
- """Chamado de outra thread: roda na thread da janela."""
1304
- self._fila_saida.put(_NaInterface(funcao))
1305
-
1306
  # ------------------------------------------------------------
1307
  # Escolha da IA (um seletor só: Automático ou um modelo)
1308
  # ------------------------------------------------------------
1309
 
1310
- def _recontar_modelos(self):
1311
- """Conta de novo os modelos da barra de status."""
1312
- def alvo():
1313
- try:
1314
- catalogo = core.catalogar_modelos(forcar=True)
1315
- n = sum(1 for m in catalogo.values()
1316
- if m["categoria"] != "embedding" and m["tag"] not in core.MODELOS_AJUDANTES)
1317
- except Exception:
1318
- return
1319
-
1320
- def aplicar():
1321
- self._info["n_modelos"] = n
1322
- self._atualizar_status(self._info)
1323
- self._fila_saida.put(_NaInterface(aplicar))
1324
- threading.Thread(target=alvo, daemon=True).start()
1325
-
1326
  def _modelos_escolhiveis(self):
1327
  # Esta função roda na thread da janela: com a lista já guardada, usa ela (e atualiza em segundo
1328
  # plano), em vez de esperar até 5 s pelo Ollama a cada resposta e travar a tela.
 
1299
  self._trabalho = threading.Thread(target=alvo, daemon=True)
1300
  self._trabalho.start()
1301
 
 
 
 
 
1302
  # ------------------------------------------------------------
1303
  # Escolha da IA (um seletor só: Automático ou um modelo)
1304
  # ------------------------------------------------------------
1305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1306
  def _modelos_escolhiveis(self):
1307
  # Esta função roda na thread da janela: com a lista já guardada, usa ela (e atualiza em segundo
1308
  # plano), em vez de esperar até 5 s pelo Ollama a cada resposta e travar a tela.
app/tars_imagem.py CHANGED
@@ -23,6 +23,7 @@ import time
23
  from pathlib import Path
24
 
25
  import tars_plataforma as plataforma
 
26
 
27
  REPOSITORIO = "https://github.com/PrismML-Eng/Bonsai-Image-Demo.git"
28
  COMMIT = "de1e5c0323e7a0474c68c6cdd075bad4e782fb0e"
@@ -123,10 +124,10 @@ def descarregar_ollama(host, abrir=None):
123
  import urllib.request
124
  abrir = abrir or urllib.request.urlopen
125
  try:
126
- with abrir(f"{host}/api/ps", timeout=5) as r:
127
  nomes = [m.get("name") for m in json.loads(r.read()).get("models", [])]
128
  for nome in nomes:
129
- pedido = urllib.request.Request(f"{host}/api/generate", data=json.dumps({"model": nome, "keep_alive": 0}).encode(),
130
  headers={"Content-Type": "application/json"})
131
  with abrir(pedido, timeout=30):
132
  pass
 
23
  from pathlib import Path
24
 
25
  import tars_plataforma as plataforma
26
+ import tars_motor as motor
27
 
28
  REPOSITORIO = "https://github.com/PrismML-Eng/Bonsai-Image-Demo.git"
29
  COMMIT = "de1e5c0323e7a0474c68c6cdd075bad4e782fb0e"
 
124
  import urllib.request
125
  abrir = abrir or urllib.request.urlopen
126
  try:
127
+ with abrir(motor.url("rodando", host), timeout=5) as r:
128
  nomes = [m.get("name") for m in json.loads(r.read()).get("models", [])]
129
  for nome in nomes:
130
+ pedido = urllib.request.Request(motor.url("gerar", host), data=json.dumps({"model": nome, "keep_alive": 0}).encode(),
131
  headers={"Content-Type": "application/json"})
132
  with abrir(pedido, timeout=30):
133
  pass
app/tars_integracoes.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """5.3 (F4+++): integrações do assistente com a agenda e com as notas da pessoa, resolvidas por código (sem o modelo) e
2
+ DESLIGADAS por padrão: só valem depois de a pessoa dizer, nas Configurações > Assistente, qual arquivo .ics (ou link) e qual
3
+ pasta de notas usar. Só biblioteca padrão (mais o `requests` do app para o link do calendário). Nada sai do computador: o
4
+ calendário é só lido, e as notas são arquivos Markdown comuns numa pasta que a pessoa escolheu.
5
+
6
+ Agenda: lê eventos de arquivos .ics (Google, Outlook, Apple e Thunderbird exportam) com repetição diária, semanal, mensal e anual
7
+ (INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, exceções e instâncias alteradas). Responde "o que tenho amanhã?", "what's on my
8
+ calendar this week?", "qual meu próximo compromisso?".
9
+ Limites: sem enviar convite nem criar evento (só leitura); fuso com nome do Windows ("W. Europe Standard Time") que o sistema
10
+ não conhece é tratado como horário local; BYSETPOS e regras raras ficam de fora e o evento só vale na primeira data.
11
+ Notas: "cria uma nota: ..." grava um .md novo (nunca por cima de outro) e "minhas notas sobre X" / "show the note X" procura e lê.
12
+ """
13
+ import datetime as dt
14
+ import re
15
+ import time
16
+ import unicodedata
17
+ from pathlib import Path
18
+
19
+ import tars_i18n
20
+
21
+ CACHE_LINK_S = 900
22
+ LIMITE_ITERACOES = 6000
23
+ LIMITE_LISTA = 15
24
+ _cache_link = {}
25
+
26
+ TEXTOS = {
27
+ "pt": {"nada": "Não há nada na agenda para {quando}.", "um": "{Quando} você tem 1 compromisso:", "varios": "{Quando} você tem {n} compromissos:",
28
+ "dia_inteiro": "dia inteiro", "proximo": "Seu próximo compromisso: {quando}, {titulo}{local}.", "sem_proximo": "Não achei compromisso nos próximos 60 dias.",
29
+ "mais": "… e mais {n}.", "erro": "Não consegui ler a agenda ({erro}).",
30
+ "nota_criada": "Nota criada: {nome}, em {pasta}.", "nota_vazia": "Diga o que escrever: cria uma nota: comprar pão.",
31
+ "notas_nada": "Não achei nada nas notas sobre {termo}.", "notas_achei": "Achei {n} em suas notas:", "nota_texto": "{nome}:\n{texto}",
32
+ "nota_nao_achei": "Não achei a nota {termo}.",
33
+ "hoje": "hoje", "amanha": "amanhã", "depois": "depois de amanhã", "semana": "esta semana", "semana_que_vem": "a semana que vem",
34
+ "dias": ["segunda", "terça", "quarta", "quinta", "sexta", "sábado", "domingo"]},
35
+ "en": {"nada": "There is nothing on your calendar for {quando}.", "um": "{Quando} you have 1 event:", "varios": "{Quando} you have {n} events:",
36
+ "dia_inteiro": "all day", "proximo": "Your next event: {quando}, {titulo}{local}.", "sem_proximo": "I found no events in the next 60 days.",
37
+ "mais": "… and {n} more.", "erro": "I couldn't read the calendar ({erro}).",
38
+ "nota_criada": "Note created: {nome}, in {pasta}.", "nota_vazia": "Tell me what to write: create a note: buy bread.",
39
+ "notas_nada": "I found nothing about {termo} in your notes.", "notas_achei": "Found {n} in your notes:", "nota_texto": "{nome}:\n{texto}",
40
+ "nota_nao_achei": "I couldn't find the note {termo}.",
41
+ "hoje": "today", "amanha": "tomorrow", "depois": "the day after tomorrow", "semana": "this week", "semana_que_vem": "next week",
42
+ "dias": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]},
43
+ }
44
+ _DIAS_ICS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6}
45
+
46
+
47
+ def _t(chave, idioma, **kw):
48
+ return TEXTOS["pt" if idioma == "pt" else "en"][chave].format(**kw) if kw else TEXTOS["pt" if idioma == "pt" else "en"][chave]
49
+
50
+
51
+ def normalizar(texto):
52
+ return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower()
53
+
54
+
55
+ # ---------------------------------------------------------------- configuração (desligado por padrão)
56
+ def agendas():
57
+ """Os arquivos .ics ou links escolhidos pela pessoa; lista vazia = agenda desligada."""
58
+ valor = tars_i18n.ler_config().get("agenda_ics") or []
59
+ return [str(v).strip() for v in ([valor] if isinstance(valor, str) else valor) if str(v).strip()]
60
+
61
+
62
+ def pasta_de_notas():
63
+ valor = str(tars_i18n.ler_config().get("notas_pasta") or "").strip()
64
+ return Path(valor).expanduser() if valor else None
65
+
66
+
67
+ # ---------------------------------------------------------------- ICS: ler
68
+ def _desdobrar(texto):
69
+ """Linhas do iCalendar: uma linha que começa com espaço ou tab continua a anterior."""
70
+ linhas = []
71
+ for bruta in texto.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
72
+ if bruta[:1] in (" ", "\t") and linhas:
73
+ linhas[-1] += bruta[1:]
74
+ elif bruta:
75
+ linhas.append(bruta)
76
+ return linhas
77
+
78
+
79
+ def _propriedade(linha):
80
+ cabeca, _, valor = linha.partition(":")
81
+ nome, *params = cabeca.split(";")
82
+ p = {}
83
+ for par in params:
84
+ k, _, v = par.partition("=")
85
+ p[k.upper()] = v.strip('"')
86
+ return nome.upper(), p, valor
87
+
88
+
89
+ def _desescapar(valor):
90
+ return valor.replace("\\n", " ").replace("\\N", " ").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
91
+
92
+
93
+ def _fuso(nome):
94
+ if not nome:
95
+ return None
96
+ try:
97
+ from zoneinfo import ZoneInfo
98
+ return ZoneInfo(nome)
99
+ except Exception: # noqa: BLE001 (nome do Windows ou sem tzdata: horário local)
100
+ return None
101
+
102
+
103
+ def _quando(valor, params):
104
+ """(data e hora SEM fuso no horário do próprio evento, fuso, dia_inteiro)."""
105
+ valor = valor.strip()
106
+ if params.get("VALUE") == "DATE" or (len(valor) == 8 and valor.isdigit()):
107
+ return dt.datetime.strptime(valor[:8], "%Y%m%d"), None, True
108
+ utc = valor.endswith("Z")
109
+ naive = dt.datetime.strptime(valor.rstrip("Z")[:15], "%Y%m%dT%H%M%S")
110
+ if utc:
111
+ return naive, dt.timezone.utc, False
112
+ return naive, _fuso(params.get("TZID")), False
113
+
114
+
115
+ def _local(naive, fuso):
116
+ """O horário da parede do evento, no horário local do computador, sem fuso."""
117
+ if fuso is None:
118
+ return naive
119
+ return naive.replace(tzinfo=fuso).astimezone().replace(tzinfo=None)
120
+
121
+
122
+ def _duracao(valor):
123
+ m = re.fullmatch(r"([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?", valor.strip())
124
+ if not m:
125
+ return dt.timedelta(0)
126
+ sinal = -1 if m.group(1) == "-" else 1
127
+ sem, dia, h, mi, s = (int(x or 0) for x in m.groups()[1:])
128
+ return sinal * dt.timedelta(weeks=sem, days=dia, hours=h, minutes=mi, seconds=s)
129
+
130
+
131
+ def ler_ics(texto):
132
+ """Os eventos do texto de um .ics: [{uid, inicio, fuso, dia_inteiro, duracao, titulo, local, regra, excecoes, id_instancia}]."""
133
+ eventos, atual = [], None
134
+ for linha in _desdobrar(texto):
135
+ if linha.upper() == "BEGIN:VEVENT":
136
+ atual = {"props": []}
137
+ elif linha.upper() == "END:VEVENT" and atual is not None:
138
+ e = _montar(atual["props"])
139
+ if e:
140
+ eventos.append(e)
141
+ atual = None
142
+ elif atual is not None:
143
+ atual["props"].append(_propriedade(linha))
144
+ return eventos
145
+
146
+
147
+ def _montar(props):
148
+ d = {}
149
+ excecoes = []
150
+ for nome, p, valor in props:
151
+ if nome == "EXDATE":
152
+ excecoes += [(v, p) for v in valor.split(",") if v.strip()]
153
+ else:
154
+ d.setdefault(nome, (p, valor))
155
+ if "DTSTART" not in d or d.get("STATUS", ({}, ""))[1].upper() == "CANCELLED":
156
+ return None
157
+ try:
158
+ inicio, fuso, inteiro = _quando(d["DTSTART"][1], d["DTSTART"][0])
159
+ if "DTEND" in d:
160
+ fim, fuso_fim, _ = _quando(d["DTEND"][1], d["DTEND"][0])
161
+ duracao = (fim - inicio) if fuso_fim == fuso else _local(fim, fuso_fim) - _local(inicio, fuso)
162
+ elif "DURATION" in d:
163
+ duracao = _duracao(d["DURATION"][1])
164
+ else:
165
+ duracao = dt.timedelta(days=1) if inteiro else dt.timedelta(0)
166
+ regra = {}
167
+ for parte in (d.get("RRULE", ({}, ""))[1]).split(";"):
168
+ k, _, v = parte.partition("=")
169
+ if k:
170
+ regra[k.upper()] = v
171
+ sem = {_quando(v, p)[0] for v, p in excecoes}
172
+ instancia = _quando(d["RECURRENCE-ID"][1], d["RECURRENCE-ID"][0])[0] if "RECURRENCE-ID" in d else None
173
+ except (ValueError, KeyError):
174
+ return None
175
+ return {"uid": d.get("UID", ({}, ""))[1], "inicio": inicio, "fuso": fuso, "dia_inteiro": inteiro, "duracao": duracao,
176
+ "titulo": _desescapar(d.get("SUMMARY", ({}, ""))[1]) or "(sem título)", "local": _desescapar(d.get("LOCATION", ({}, ""))[1]),
177
+ "regra": regra, "excecoes": sem, "id_instancia": instancia}
178
+
179
+
180
+ # ---------------------------------------------------------------- ICS: repetir
181
+ def _passos_da_regra(e):
182
+ """As datas (horário da parede do evento) de cada ocorrência, em ordem, a partir do início. Para quando a regra acaba."""
183
+ r, inicio = e["regra"], e["inicio"]
184
+ if not r.get("FREQ"):
185
+ yield inicio
186
+ return
187
+ freq, intervalo = r["FREQ"].upper(), max(1, int(r.get("INTERVAL") or 1))
188
+ contagem = int(r["COUNT"]) if r.get("COUNT", "").isdigit() else None
189
+ ate = None
190
+ if r.get("UNTIL"):
191
+ try:
192
+ u, fuso_u, _ = _quando(r["UNTIL"], {})
193
+ ate = u if fuso_u is None else _local(u, fuso_u)
194
+ ate = ate + dt.timedelta(days=1) if len(r["UNTIL"]) == 8 else ate
195
+ except ValueError:
196
+ ate = None
197
+ emitidos = 0
198
+ hora = inicio.time()
199
+ por_dia = [(int(m.group(1) or 0), _DIAS_ICS[m.group(2)]) for m in re.finditer(r"([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)", r.get("BYDAY", ""))]
200
+ dias_mes = [int(x) for x in r.get("BYMONTHDAY", "").split(",") if x.strip("-").isdigit()]
201
+
202
+ def candidatos():
203
+ if freq == "DAILY":
204
+ n = 0
205
+ while True:
206
+ yield inicio + dt.timedelta(days=n * intervalo)
207
+ n += 1
208
+ elif freq == "WEEKLY":
209
+ segunda = inicio - dt.timedelta(days=inicio.weekday())
210
+ dias = sorted({d for _, d in por_dia}) or [inicio.weekday()]
211
+ n = 0
212
+ while True:
213
+ base = segunda + dt.timedelta(weeks=n * intervalo)
214
+ for d in dias:
215
+ yield dt.datetime.combine((base + dt.timedelta(days=d)).date(), hora)
216
+ n += 1
217
+ elif freq in ("MONTHLY", "YEARLY"):
218
+ n = 0
219
+ while True:
220
+ total = (inicio.year * 12 + inicio.month - 1) + (n * intervalo * (12 if freq == "YEARLY" else 1))
221
+ ano, mes = divmod(total, 12)
222
+ mes += 1
223
+ if freq == "YEARLY" and por_dia == [] and not dias_mes:
224
+ dias = [inicio.day]
225
+ else:
226
+ dias = list(dias_mes) or []
227
+ if por_dia and freq == "MONTHLY":
228
+ dias += _dias_da_semana_do_mes(ano, mes, por_dia)
229
+ if not dias:
230
+ dias = [inicio.day]
231
+ ultimo = (dt.date(ano + (mes == 12), mes % 12 + 1, 1) - dt.timedelta(days=1)).day
232
+ for d in sorted(set(dias)):
233
+ d = ultimo + 1 + d if d < 0 else d
234
+ if 1 <= d <= ultimo:
235
+ yield dt.datetime.combine(dt.date(ano, mes, d), hora)
236
+ n += 1
237
+ else:
238
+ yield inicio
239
+
240
+ for c in candidatos():
241
+ if c < inicio:
242
+ continue
243
+ if ate is not None and c >= ate:
244
+ return
245
+ emitidos += 1
246
+ if contagem is not None and emitidos > contagem:
247
+ return
248
+ yield c
249
+ if emitidos >= LIMITE_ITERACOES:
250
+ return
251
+
252
+
253
+ def _dias_da_semana_do_mes(ano, mes, por_dia):
254
+ """BYDAY com ordinal (2TU, -1FR) ou sem (todas as terças) dentro do mês."""
255
+ dias = []
256
+ ultimo = (dt.date(ano + (mes == 12), mes % 12 + 1, 1) - dt.timedelta(days=1)).day
257
+ for ordinal, dia in por_dia:
258
+ do_dia = [d for d in range(1, ultimo + 1) if dt.date(ano, mes, d).weekday() == dia]
259
+ if not ordinal:
260
+ dias += do_dia
261
+ elif 0 < ordinal <= len(do_dia):
262
+ dias.append(do_dia[ordinal - 1])
263
+ elif ordinal < 0 and -ordinal <= len(do_dia):
264
+ dias.append(do_dia[ordinal])
265
+ return dias
266
+
267
+
268
+ def ocorrencias(eventos, de, ate):
269
+ """[(inicio, fim, evento)] que tocam [de, ate), no horário local, em ordem."""
270
+ alteradas = {(e["uid"], e["id_instancia"]) for e in eventos if e["id_instancia"] is not None}
271
+ saida = []
272
+ for e in eventos:
273
+ for naive in _passos_da_regra(e):
274
+ local = naive if e["dia_inteiro"] else _local(naive, e["fuso"])
275
+ if local >= ate:
276
+ break # as ocorrências vêm em ordem: o resto é depois do período
277
+ if naive in e["excecoes"] or (e["uid"], naive) in alteradas:
278
+ continue
279
+ fim = local + e["duracao"]
280
+ if fim > de or (not e["duracao"] and local >= de):
281
+ saida.append((local, fim, e))
282
+ saida.sort(key=lambda x: (x[0], x[2]["titulo"]))
283
+ return saida
284
+
285
+
286
+ def eventos_das_fontes(fontes=None, agora=None):
287
+ """Lê os .ics escolhidos (arquivos ou links). Devolve (eventos, erros). O link tem cache de 15 minutos."""
288
+ eventos, erros = [], []
289
+ for fonte in (fontes if fontes is not None else agendas()):
290
+ try:
291
+ if re.match(r"https?://", fonte, re.I) or fonte.lower().startswith("webcal://"):
292
+ texto = _baixar(fonte.replace("webcal://", "https://", 1))
293
+ else:
294
+ texto = Path(fonte).expanduser().read_text(encoding="utf-8", errors="replace")
295
+ eventos += ler_ics(texto)
296
+ except Exception as e: # noqa: BLE001 (arquivo sumiu, sem rede: a resposta diz, o resto continua)
297
+ erros.append(f"{type(e).__name__}")
298
+ return eventos, erros
299
+
300
+
301
+ def _baixar(url):
302
+ guardado = _cache_link.get(url)
303
+ if guardado and time.monotonic() - guardado[0] < CACHE_LINK_S:
304
+ return guardado[1]
305
+ import requests
306
+ r = requests.get(url, timeout=10, headers={"User-Agent": "openTARS"})
307
+ r.raise_for_status()
308
+ _cache_link[url] = (time.monotonic(), r.text)
309
+ return r.text
310
+
311
+
312
+ # ---------------------------------------------------------------- agenda: entender a frase
313
+ _AGENDA = re.compile(r"\b(agenda|compromiss\w*|calendar\w*|calendario|meetings?|events?|eventos?|reuni\w+|schedule)\b")
314
+ _TEMPO = re.compile(r"\b(hoje|today|amanha|tomorrow|depois de amanha|day after tomorrow|esta semana|essa semana|nesta semana|this week|"
315
+ r"semana que vem|proxima semana|next week|segunda|terca|quarta|quinta|sexta|sabado|domingo|monday|tuesday|wednesday|"
316
+ r"thursday|friday|saturday|sunday)\b")
317
+ _PROXIMO = re.compile(r"\b(proximo|proxima|next|upcoming)\b.*\b(compromisso|evento|reuniao|meeting|event|appointment)\b|"
318
+ r"\b(compromisso|evento|reuniao|meeting|event|appointment)\b.*\b(proximo|proxima|next)\b")
319
+ _NOMES_DIA = {"segunda": 0, "terca": 1, "quarta": 2, "quinta": 3, "sexta": 4, "sabado": 5, "domingo": 6,
320
+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
321
+
322
+
323
+ def _intervalo(n, hoje):
324
+ """(início, fim, nome do período em pt/en) para a frase normalizada, ou None."""
325
+ dia = dt.datetime.combine(hoje, dt.time())
326
+ if re.search(r"\b(depois de amanha|day after tomorrow)\b", n):
327
+ return dia + dt.timedelta(days=2), dia + dt.timedelta(days=3), "depois"
328
+ if re.search(r"\b(amanha|tomorrow)\b", n):
329
+ return dia + dt.timedelta(days=1), dia + dt.timedelta(days=2), "amanha"
330
+ if re.search(r"\b(semana que vem|proxima semana|next week)\b", n):
331
+ segunda = dia + dt.timedelta(days=7 - hoje.weekday())
332
+ return segunda, segunda + dt.timedelta(days=7), "semana_que_vem"
333
+ if re.search(r"\b(esta semana|essa semana|nesta semana|this week)\b", n):
334
+ return dia, dia + dt.timedelta(days=7 - hoje.weekday()), "semana"
335
+ if re.search(r"\b(hoje|today)\b", n):
336
+ return dia, dia + dt.timedelta(days=1), "hoje"
337
+ for nome, numero in _NOMES_DIA.items():
338
+ if re.search(rf"\b{nome}\b", n):
339
+ alvo = dia + dt.timedelta(days=(numero - hoje.weekday()) % 7)
340
+ return alvo, alvo + dt.timedelta(days=1), ("dia", numero)
341
+ return None
342
+
343
+
344
+ def _periodo_texto(periodo, idioma, inicio):
345
+ if isinstance(periodo, tuple):
346
+ nome = TEXTOS["pt" if idioma == "pt" else "en"]["dias"][periodo[1]]
347
+ return (f"{'na' if periodo[1] < 5 else 'no'} {nome}" if idioma == "pt" else f"on {nome}") + f" ({inicio:%d/%m})"
348
+ return _t(periodo, idioma)
349
+
350
+
351
+ def _hora(local, fim, inteiro, idioma):
352
+ if inteiro:
353
+ return _t("dia_inteiro", idioma)
354
+ return f"{local:%H:%M}" + (f"–{fim:%H:%M}" if fim != local and fim.date() == local.date() else "")
355
+
356
+
357
+ def responder_agenda(pedido, idioma, agora=None, fontes=None):
358
+ """O texto da resposta sobre a agenda, ou None quando a frase não é sobre a agenda (ou a agenda está desligada)."""
359
+ fontes = agendas() if fontes is None else fontes
360
+ n = normalizar(pedido)
361
+ if not fontes:
362
+ return None
363
+ proximo = bool(_PROXIMO.search(n))
364
+ intervalo = None if proximo else _intervalo(n, (agora or dt.datetime.now()).date())
365
+ pede = proximo or (intervalo is not None and bool(_AGENDA.search(n) or re.search(r"\b(tenho|do i have|have i|i have|what do i)\b", n)))
366
+ if not pede:
367
+ return None
368
+ eventos, erros = eventos_das_fontes(fontes)
369
+ if erros and not eventos:
370
+ return _t("erro", idioma, erro=erros[0])
371
+ agora = agora or dt.datetime.now()
372
+ if proximo:
373
+ for inicio, fim, e in ocorrencias(eventos, agora, agora + dt.timedelta(days=60)):
374
+ quando = f"{inicio:%d/%m} {_hora(inicio, fim, e['dia_inteiro'], idioma)}"
375
+ return _t("proximo", idioma, quando=quando, titulo=e["titulo"], local=f" ({e['local']})" if e["local"] else "")
376
+ return _t("sem_proximo", idioma)
377
+ de, ate, periodo = intervalo
378
+ achados = ocorrencias(eventos, de, ate)
379
+ nome = _periodo_texto(periodo, idioma, de)
380
+ if not achados:
381
+ return _t("nada", idioma, quando=nome)
382
+ cabeca = _t("um" if len(achados) == 1 else "varios", idioma, Quando=_cap(nome), n=len(achados))
383
+ linhas = [cabeca]
384
+ multi_dia = (ate - de).days > 1
385
+ for inicio, fim, e in achados[:LIMITE_LISTA]:
386
+ dia = f"{TEXTOS['pt' if idioma == 'pt' else 'en']['dias'][inicio.weekday()][:3]} {inicio:%d/%m} " if multi_dia else ""
387
+ linhas.append(f"• {dia}{_hora(inicio, fim, e['dia_inteiro'], idioma)} {e['titulo']}" + (f" ({e['local']})" if e["local"] else ""))
388
+ if len(achados) > LIMITE_LISTA:
389
+ linhas.append(_t("mais", idioma, n=len(achados) - LIMITE_LISTA))
390
+ return "\n".join(linhas)
391
+
392
+
393
+ def _cap(s):
394
+ return s[:1].upper() + s[1:]
395
+
396
+
397
+ # ---------------------------------------------------------------- notas em Markdown
398
+ _CRIAR_NOTA = re.compile(r"^\s*(?:por favor,?\s+|please,?\s+)?(?:cri[ae]r?|fa[zç]a?|salv[ae]|escrev[ae]r?|anot[ae]r?|nova|create|make|take|new|add|save|write)\s+"
399
+ r"(?:(?:uma|a|an)\s+)?(?:(?:nova|new)\s+)?n[oó]ta\b\s*(?:sobre|de|com|dizendo|que|about|saying|that|:|-)?\s*:?\s*(?P<corpo>.*)$",
400
+ re.IGNORECASE | re.DOTALL)
401
+ _CRIAR_NOTA_EN = re.compile(r"^\s*(?:please,?\s+)?(?:create|make|take|new|add|save|write)\s+(?:(?:a|an)\s+)?(?:(?:new)\s+)?note\b\s*(?:about|saying|that|:|-)?\s*:?\s*(?P<corpo>.*)$",
402
+ re.IGNORECASE | re.DOTALL)
403
+ _ACHAR_NOTA = re.compile(r"\b(?:minhas\s+)?notas?\s+(?:sobre|de|com|about|on|for)\s+(?P<termo>.+)$|"
404
+ r"\b(?:procura|busca|ache|acha|procure|busque|search|find|look)\w*\s+(?:nas?\s+)?(?:minhas\s+)?(?:notas|my notes|in my notes|notes)\s+(?P<termo2>.+)$", re.IGNORECASE)
405
+ _LER_NOTA = re.compile(r"^\s*(?:abre|abra|mostra|mostre|le|ler|leia|show|open|read)\s+(?:a\s+|the\s+|minha\s+|my\s+)*(?:nota|note)\s+(?P<termo>.+?)\s*$", re.IGNORECASE)
406
+
407
+
408
+ def _nome_de_arquivo(texto):
409
+ base = normalizar(texto.strip().splitlines()[0] if texto.strip() else "nota")
410
+ slug = "-".join(re.findall(r"[a-z0-9]+", base)[:7]) or "nota"
411
+ return slug[:60]
412
+
413
+
414
+ def criar_nota(corpo, pasta=None, agora=None):
415
+ """Grava uma nota nova e devolve o caminho. Nunca escreve por cima: se o nome existe, ganha -2, -3..."""
416
+ pasta = Path(pasta or pasta_de_notas())
417
+ pasta.mkdir(parents=True, exist_ok=True)
418
+ corpo = corpo.strip()
419
+ titulo = corpo.splitlines()[0][:80]
420
+ base = f"{(agora or dt.datetime.now()):%Y-%m-%d}-{_nome_de_arquivo(corpo)}"
421
+ destino, k = pasta / f"{base}.md", 2
422
+ while destino.exists():
423
+ destino = pasta / f"{base}-{k}.md"
424
+ k += 1
425
+ resto = "\n".join(corpo.splitlines()[1:]).strip()
426
+ destino.write_text(f"# {titulo}\n\n" + (resto + "\n" if resto else ""), encoding="utf-8")
427
+ return destino
428
+
429
+
430
+ def achar_notas(termo, pasta=None, limite=5):
431
+ import tars_busca_arquivos as busca
432
+ pasta = Path(pasta or pasta_de_notas())
433
+ if not pasta.is_dir():
434
+ return []
435
+ achados = busca.buscar(termo, raiz=pasta, limite=limite * 2, indice=False)
436
+ return [a for a in achados if a["nome"].lower().endswith((".md", ".txt"))][:limite]
437
+
438
+
439
+ def responder_notas(pedido, idioma, pasta=None):
440
+ """Cria, procura ou lê notas. None quando a frase não é de nota, ou as notas estão desligadas (sem pasta)."""
441
+ pasta = pasta or pasta_de_notas()
442
+ if pasta is None:
443
+ return None
444
+ m = _CRIAR_NOTA.match(pedido) or _CRIAR_NOTA_EN.match(pedido)
445
+ if m:
446
+ corpo = (m.group("corpo") or "").strip()
447
+ if not corpo:
448
+ return _t("nota_vazia", idioma)
449
+ destino = criar_nota(corpo, pasta)
450
+ return _t("nota_criada", idioma, nome=destino.name, pasta=str(destino.parent).replace(str(Path.home()), "~", 1))
451
+ m = _LER_NOTA.match(pedido)
452
+ if m:
453
+ termo = m.group("termo").strip(" ?.!")
454
+ achados = achar_notas(termo, pasta, 1)
455
+ if not achados:
456
+ return _t("nota_nao_achei", idioma, termo=termo)
457
+ texto = Path(achados[0]["caminho"]).read_text(encoding="utf-8", errors="replace").strip()
458
+ return _t("nota_texto", idioma, nome=achados[0]["nome"], texto=texto[:900] + ("…" if len(texto) > 900 else ""))
459
+ m = _ACHAR_NOTA.search(pedido)
460
+ if m:
461
+ termo = (m.group("termo") or m.group("termo2") or "").strip(" ?.!")
462
+ if not termo:
463
+ return None
464
+ achados = achar_notas(termo, pasta)
465
+ if not achados:
466
+ return _t("notas_nada", idioma, termo=termo)
467
+ linhas = [_t("notas_achei", idioma, n=len(achados))]
468
+ for a in achados:
469
+ linhas.append(f"• {a['nome']}" + (f": {a['trecho']}" if a["trecho"] else ""))
470
+ return "\n".join(linhas)
471
+ return None
472
+
473
+
474
+ def responder(pedido, idioma):
475
+ """Agenda e notas por código; None se a frase não é com elas (ou as duas estão desligadas, o padrão)."""
476
+ return responder_notas(pedido, idioma) or responder_agenda(pedido, idioma)
app/tars_lembretes.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lembretes e timers por frase (F4++ do PLANO_5.3.md, 07/11), sem o modelo.
2
+
3
+ Medido antes: "me lembra de tomar água daqui a 30 minutos" virava uma memória ("Vou lembrar"), e "põe um timer de 5 minutos" tentava
4
+ programas que não existem e dizia que o timer estava rodando. Nenhum aviso era agendado.
5
+ Aqui a frase vira uma rotina de uma vez na agenda que o app já tem (tars_rotinas); na hora, o pedido "Lembrete: ..." sai por regra
6
+ (resposta_da_rotina) e a janela avisa como avisa qualquer rotina. Só dispara com o openTARS aberto (ou na bandeja).
7
+ """
8
+ import datetime as dt
9
+ import re
10
+ import unicodedata
11
+
12
+ import tars_rotinas
13
+
14
+ _NUMEROS = {"um": 1, "uma": 1, "dois": 2, "duas": 2, "tres": 3, "quatro": 4, "cinco": 5, "seis": 6, "sete": 7, "oito": 8, "nove": 9, "dez": 10,
15
+ "quinze": 15, "vinte": 20, "trinta": 30, "quarenta": 40, "meia": 30, "a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "five": 5,
16
+ "ten": 10, "fifteen": 15, "twenty": 20, "thirty": 30}
17
+ _N = r"(\d+|um|uma|dois|duas|tres|quatro|cinco|seis|sete|oito|nove|dez|quinze|vinte|trinta|quarenta|a|an|one|two|three|five|ten|fifteen|twenty|thirty)"
18
+ _PEDE = re.compile(r"^(?:por favor |please |pode |can you )?(?:me lembra|me lembre|lembra-me|me avisa|me avise|avisa-me|remind me|ping me)\b")
19
+ _PEDE_SEM_DUVIDA = re.compile(r"^(?:por favor |please |pode |can you )?(?:me lembra|me lembre|lembra-me|remind me)\b")
20
+ _TIMER = re.compile(r"\b(?:timer|temporizador|cronometro|alarme|alarm)\b")
21
+ _DAQUI = re.compile(r"\b(?:daqui a|daqui|em|dentro de|in|after)\s+" + _N + r"\s*(minutos?|minutes?|mins?|horas?|h|hours?|segundos?|seconds?|s)\b")
22
+ _DURACAO = re.compile(_N + r"[\s-]*(minutos?|minutes?|mins?|horas?|hours?|h|segundos?|seconds?|s)\b")
23
+ _HORA = re.compile(r"\b(?:as|a|at|pras|para as|ao meio)\s*(\d{1,2})(?:[:h](\d{2}))?\s*(?:h|horas)?\s*(am|pm|da manha|da tarde|da noite)?\b|\b(\d{1,2})\s*(am|pm)\b|\b(meio[- ]dia|noon|midnight|meia[- ]noite)\b")
24
+ _AMANHA = re.compile(r"\b(amanha|tomorrow)\b")
25
+
26
+
27
+ def _sem_acento(texto):
28
+ return "".join(c for c in unicodedata.normalize("NFKD", texto) if not unicodedata.combining(c)).lower().strip()
29
+
30
+
31
+ def _numero(texto):
32
+ return int(texto) if texto.isdigit() else _NUMEROS.get(texto, 1)
33
+
34
+
35
+ def _segundos(numero, unidade):
36
+ u = unidade[0]
37
+ return _numero(numero) * (3600 if u == "h" else 1 if u == "s" else 60)
38
+
39
+
40
+ def _quando(n, agora):
41
+ """O datetime pedido, ou None se a frase não diz quando."""
42
+ a = _DAQUI.search(n)
43
+ if a:
44
+ return agora + dt.timedelta(seconds=_segundos(a.group(1), a.group(2)))
45
+ h = _HORA.search(n)
46
+ if not h:
47
+ return None
48
+ if h.group(6):
49
+ hora, minuto = (12, 0) if h.group(6).startswith(("meio", "noon")) else (0, 0)
50
+ else:
51
+ hora = int(h.group(1) or h.group(4))
52
+ minuto = int(h.group(2) or 0)
53
+ periodo = h.group(3) or h.group(5) or ""
54
+ if periodo in ("pm", "da tarde", "da noite") and hora < 12:
55
+ hora += 12
56
+ if periodo in ("am", "da manha") and hora == 12:
57
+ hora = 0
58
+ if hora > 23 or minuto > 59:
59
+ return None
60
+ alvo = agora.replace(hour=hora, minute=minuto, second=0, microsecond=0)
61
+ if _AMANHA.search(n):
62
+ alvo += dt.timedelta(days=1)
63
+ elif alvo <= agora:
64
+ alvo += dt.timedelta(days=1)
65
+ return alvo
66
+
67
+
68
+ def _o_que(original, n):
69
+ """O texto do lembrete, com os acentos e as maiúsculas de quem escreveu: o pedido sem o "me lembra" e sem o quando."""
70
+ base = original.strip() if len(original.strip()) == len(n) else n
71
+ apagar = [False] * len(n)
72
+ for padrao in (_PEDE, _DAQUI, _HORA, _AMANHA):
73
+ for m in padrao.finditer(n):
74
+ for k in range(m.start(), m.end()):
75
+ apagar[k] = True
76
+ texto = " ".join("".join(" " if apagar[k] else c for k, c in enumerate(base)).split())
77
+ texto = re.sub(r"^(?:de|que|para|pra|to|that|about)\s+", "", texto, flags=re.IGNORECASE)
78
+ return re.sub(r"\s+(?:de|para|pra|to|e|and)$", "", texto, flags=re.IGNORECASE).strip(" ,.!?")
79
+
80
+
81
+ def entender(texto, agora=None):
82
+ """{'tipo': 'lembrete'|'timer', 'quando': datetime, 'texto': ...}, {'tipo': 'falta_hora'} ou None."""
83
+ n = _sem_acento(texto)
84
+ agora = agora or dt.datetime.now().replace(microsecond=0)
85
+ if _TIMER.search(n) and re.search(r"\b(poe|ponha|coloca|coloque|liga|ligue|cria|crie|marca|marque|faz|set|start|put|make|timer de|timer for)\b|^timer\b", n):
86
+ d = _DURACAO.search(n)
87
+ if not d:
88
+ return {"tipo": "falta_hora"}
89
+ segundos = _segundos(d.group(1), d.group(2))
90
+ rotulo = f"{_numero(d.group(1))} {d.group(2)}"
91
+ return {"tipo": "timer", "quando": agora + dt.timedelta(seconds=segundos), "texto": rotulo}
92
+ if not _PEDE.search(n):
93
+ return None
94
+ quando = _quando(n, agora)
95
+ if quando is None: # "me avisa se..." sem hora não é lembrete; "me lembra de..." sem hora pergunta quando
96
+ return {"tipo": "falta_hora"} if _PEDE_SEM_DUVIDA.search(n) else None
97
+ return {"tipo": "lembrete", "quando": quando, "texto": _o_que(texto, n) or ("isso" if re.search(r"[ãçáé]|\bde\b", n) else "this")}
98
+
99
+
100
+ def agendar(pedido, idioma, agenda=None):
101
+ """Cria a rotina e devolve a frase de confirmação; None se a frase não é de lembrete nem de timer."""
102
+ e = entender(pedido)
103
+ if e is None:
104
+ return None
105
+ pt = idioma == "pt"
106
+ if e["tipo"] == "falta_hora":
107
+ return "Quando te lembro? Por exemplo: daqui a 30 minutos, ou amanhã às 9h." if pt else "When should I remind you? For example: in 30 minutes, or tomorrow at 9 am."
108
+ agenda = agenda or tars_rotinas.Agenda()
109
+ if e["tipo"] == "timer":
110
+ nome, aviso = (f"Timer de {e['texto']}", f"Timer: {e['texto']} terminaram.") if pt else (f"{e['texto']} timer", f"Timer: {e['texto']} are up.")
111
+ else:
112
+ nome, aviso = (("Lembrete: " if pt else "Reminder: ") + e["texto"])[:tars_rotinas.LIMITE_NOME], ("Lembrete: " if pt else "Reminder: ") + e["texto"]
113
+ rotina, erro = agenda.criar(nome, aviso, {"tipo": "uma_vez", "data": e["quando"].isoformat()}, ativa=True, aprovada=True, origem="pessoa")
114
+ if erro:
115
+ return ("Não consegui agendar (" + erro + ")." if pt else "I couldn't schedule it (" + erro + ").")
116
+ dia = "" if e["quando"].date() == dt.date.today() else (" de amanhã" if pt else " tomorrow") if e["quando"].date() == dt.date.today() + dt.timedelta(days=1) \
117
+ else f" de {e['quando']:%d/%m}" if pt else f" on {e['quando']:%m/%d}"
118
+ hora = f"{e['quando']:%H:%M}"
119
+ if e["tipo"] == "timer":
120
+ return f"Timer de {e['texto']} ligado: toca às {hora}{dia}." if pt else f"{e['texto']} timer set: it goes off at {hora}{dia}."
121
+ return f"Combinado: às {hora}{dia} eu te lembro de {e['texto']}." if pt else f"Got it: at {hora}{dia} I'll remind you to {e['texto']}."
122
+
123
+
124
+ _AVISO = re.compile(r"^(lembrete|reminder|timer):\s*(.+)$", re.IGNORECASE)
125
+
126
+
127
+ def resposta_da_rotina(pedido):
128
+ """Quando a rotina dispara, o pedido "Lembrete: X" volta como o próprio aviso, sem o modelo."""
129
+ a = _AVISO.match((pedido or "").strip())
130
+ return f"⏰ {pedido.strip()}" if a else None
app/tars_mais_modelos.py CHANGED
@@ -10,9 +10,10 @@ Se um tamanho mudar lá, é aqui que se corrige.
10
  GRUPOS = (
11
  # 25/10 (decisão do Enzo): os modelos do openTARS no topo, o Lite primeiro e destacado (é o que fica em segundo plano)
12
  ("opentars", (
13
- ("ProjectOpenTARS/Gargantua-2:lite", "Gargantua 2 Lite", 0.617, ("vision", "tools")),
14
- ("ProjectOpenTARS/Gargantua-2:latest", "Gargantua 2", 1.6, ("vision", "tools")),
15
- ("ProjectOpenTARS/Gargantua:code", "Gargantua Code", 1.1, ("tools",)),
 
16
  )),
17
  ("bonsai", (
18
  ("bonsai-2:2b", "Bonsai 2 · 2B", 0.911, ("vision", "tools", "thinking")),
@@ -90,7 +91,7 @@ GRUPOS = (
90
  )
91
 
92
  CAPACIDADES = ("vision", "tools", "thinking", "image", "text")
93
- DESTAQUE = "ProjectOpenTARS/Gargantua-2:lite"
94
 
95
  # Etiquetas que o Ollama mostra com outro nome quando o modelo foi baixado sem a tag (o mesmo arquivo).
96
  _APELIDOS = {
@@ -117,10 +118,6 @@ _APELIDOS = {
117
  }
118
 
119
 
120
- def todas_as_tags():
121
- return [item[0] for _, itens in GRUPOS for item in itens]
122
-
123
-
124
  def _norma(tag):
125
  tag = tag.strip().lower()
126
  return tag if ":" in tag else tag + ":latest"
@@ -133,6 +130,8 @@ def instalado(tag, instalados):
133
 
134
 
135
  def tamanho_texto(gb):
 
 
136
  return f"{round(gb * 1000)} MB" if gb < 1 else f"{gb:.1f} GB"
137
 
138
 
@@ -186,3 +185,55 @@ def recomendados(hw):
186
  if cabem:
187
  achados.append(max(cabem, key=lambda i: ("tools" in i[3], i[2], "thinking" not in i[3]))[0])
188
  return achados
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  GRUPOS = (
11
  # 25/10 (decisão do Enzo): os modelos do openTARS no topo, o Lite primeiro e destacado (é o que fica em segundo plano)
12
  ("opentars", (
13
+ ("ProjectOpenTARS/Gargantua-2.5:lite", "Gargantua Lite Gen 3", 4.4, ("vision", "tools")),
14
+ ("ProjectOpenTARS/Gargantua-2.5:latest", "Gargantua 2.5", 6.3, ("vision", "tools")),
15
+ ("ProjectOpenTARS/Gargantua-Pro:pro", "Gargantua Pro Gen 1", 7.6, ("vision", "tools")),
16
+ ("ProjectOpenTARS/Gargantua-Code-Gen2:code", "Gargantua Code Gen 2", 4.7, ("tools",)),
17
  )),
18
  ("bonsai", (
19
  ("bonsai-2:2b", "Bonsai 2 · 2B", 0.911, ("vision", "tools", "thinking")),
 
91
  )
92
 
93
  CAPACIDADES = ("vision", "tools", "thinking", "image", "text")
94
+ DESTAQUE = "ProjectOpenTARS/Gargantua-2.5:lite"
95
 
96
  # Etiquetas que o Ollama mostra com outro nome quando o modelo foi baixado sem a tag (o mesmo arquivo).
97
  _APELIDOS = {
 
118
  }
119
 
120
 
 
 
 
 
121
  def _norma(tag):
122
  tag = tag.strip().lower()
123
  return tag if ":" in tag else tag + ":latest"
 
130
 
131
 
132
  def tamanho_texto(gb):
133
+ if not gb:
134
+ return "?" # resultado da pesquisa na biblioteca: o tamanho só se sabe ao clicar
135
  return f"{round(gb * 1000)} MB" if gb < 1 else f"{gb:.1f} GB"
136
 
137
 
 
185
  if cabem:
186
  achados.append(max(cabem, key=lambda i: ("tools" in i[3], i[2], "thinking" not in i[3]))[0])
187
  return achados
188
+
189
+
190
+ # ---------------------------------------------------------------- 11/11: pesquisa na loja
191
+
192
+ def filtrar(itens, consulta):
193
+ """Os itens (tag, nome, gb, capacidades) em que todas as palavras da consulta aparecem no nome, na tag ou nas capacidades."""
194
+ palavras = str(consulta or "").lower().split()
195
+ return [i for i in itens if all(p in " ".join((i[0].split("/")[-1], i[1], *i[3])).lower() for p in palavras)] # sem o dono: "pro" não casa com ProjectOpenTARS
196
+
197
+
198
+ def _http(url, tempo=10):
199
+ import urllib.request
200
+ pedido = urllib.request.Request(url, headers={"User-Agent": "openTARS"})
201
+ with urllib.request.urlopen(pedido, timeout=tempo) as r:
202
+ return r.read(2_000_000).decode("utf-8", "replace")
203
+
204
+
205
+ _CAPACIDADES_CONHECIDAS = ("vision", "tools", "thinking")
206
+
207
+
208
+ def buscar_biblioteca(consulta, http=_http):
209
+ """Procura na biblioteca do Ollama (ollama.com/search). Devolve [{nome, descricao, capacidades, tamanhos}], só o que roda
210
+ local: modelos "cloud" e de usuário ficam de fora. Falha de rede levanta a exceção do `http`."""
211
+ import re
212
+ from urllib.parse import quote
213
+ pagina = http("https://ollama.com/search?q=" + quote(str(consulta).strip()))
214
+ achados = []
215
+ for bloco in pagina.split('<li class="border-t')[1:]:
216
+ m = re.search(r'href="/library/([a-z0-9][\w.\-]*)"', bloco)
217
+ if not m:
218
+ continue
219
+ marcas = re.findall(r'>([^<>]{1,24})</span>', bloco)
220
+ descricao = re.search(r'<p class="[^"]*">([^<]+)</p>', bloco)
221
+ achados.append({
222
+ "nome": m.group(1),
223
+ "descricao": (descricao.group(1).strip() if descricao else ""),
224
+ "capacidades": tuple(c for c in _CAPACIDADES_CONHECIDAS if c in marcas),
225
+ "tamanhos": tuple(x for x in marcas if re.fullmatch(r"(e?\d+(\.\d+)?[bm]|latest)", x)),
226
+ })
227
+ return achados
228
+
229
+
230
+ def tamanho_da_tag(nome_tag, http=_http):
231
+ """Tamanho do download (GB) de `nome:tag` segundo a página de tags da biblioteca, ou None se não achar."""
232
+ import re
233
+ nome, _, tag = nome_tag.partition(":")
234
+ pagina = http(f"https://ollama.com/library/{nome}/tags")
235
+ m = re.search(r'href="/library/' + re.escape(nome_tag) + r'"(.{0,900}?)([\d.]+)\s?(GB|MB)', pagina, re.S)
236
+ if not m:
237
+ return None
238
+ valor = float(m.group(2))
239
+ return valor if m.group(3) == "GB" else valor / 1000
app/tars_midia.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """5.3 (F4+++): controle de música e vídeo por frase, sem o modelo: "pausa a música", "próxima faixa", "que música está tocando?".
2
+
3
+ Linux: qualquer player que fale MPRIS (Spotify, VLC, Rhythmbox, Firefox, Chromium, o YouTube no navegador...) pelo `gdbus`, que vem
4
+ com o GNOME e o glib. Windows: as teclas de mídia do sistema (tocar/pausar, próxima, anterior); o Windows não deixa um programa
5
+ simples ler o que está tocando, então "que música é?" diz que não sabe. macOS: Spotify e Music pelo `osascript`.
6
+ Só age quando a frase pede (nunca sozinho), e nunca abre nem fecha programas. Não foi usado com um player de verdade fora do Linux.
7
+ """
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import unicodedata
12
+
13
+ import tars_plataforma as plataforma
14
+
15
+ TEXTOS = {
16
+ "pt": {"play_pause": "Pronto.", "pause": "Pausei.", "play": "Toquei.", "next": "Próxima.", "previous": "Voltei.", "stop": "Parei.",
17
+ "nenhum": "Não achei nenhum player de música ou vídeo aberto.", "tocando": "Tocando agora: {titulo}{artista} ({app}).",
18
+ "pausado": "Está pausado: {titulo}{artista} ({app}).", "nada": "Não tem nada tocando agora.",
19
+ "nao_sei": "No Windows eu consigo pausar, tocar e pular, mas não sei dizer o que está tocando.", "falhou": "Não consegui controlar o player ({erro})."},
20
+ "en": {"play_pause": "Done.", "pause": "Paused.", "play": "Playing.", "next": "Next.", "previous": "Back.", "stop": "Stopped.",
21
+ "nenhum": "I didn't find any music or video player open.", "tocando": "Now playing: {titulo}{artista} ({app}).",
22
+ "pausado": "Paused: {titulo}{artista} ({app}).", "nada": "Nothing is playing right now.",
23
+ "nao_sei": "On Windows I can pause, play and skip, but I can't tell what is playing.", "falhou": "I couldn't control the player ({erro})."},
24
+ }
25
+ METODOS = {"play_pause": "PlayPause", "pause": "Pause", "play": "Play", "next": "Next", "previous": "Previous", "stop": "Stop"}
26
+ TECLAS_WINDOWS = {"play_pause": 0xB3, "pause": 0xB3, "play": 0xB3, "next": 0xB0, "previous": 0xB1, "stop": 0xB2}
27
+ COMANDOS_MAC = {"play_pause": "playpause", "pause": "pause", "play": "play", "next": "next track", "previous": "previous track", "stop": "pause"}
28
+ PLAYERS_MAC = ("Spotify", "Music")
29
+
30
+
31
+ def _t(chave, idioma, **kw):
32
+ s = TEXTOS["pt" if idioma == "pt" else "en"][chave]
33
+ return s.format(**kw) if kw else s
34
+
35
+
36
+ def normalizar(texto):
37
+ return unicodedata.normalize("NFKD", str(texto)).encode("ascii", "ignore").decode().lower().strip(" .!?,")
38
+
39
+
40
+ def _rodar(comando, tempo=4):
41
+ """O texto de saída, ou levanta OSError / SubprocessError / RuntimeError. (Os testes trocam esta função por uma falsa.)"""
42
+ r = subprocess.run(comando, capture_output=True, text=True, timeout=tempo)
43
+ if r.returncode != 0:
44
+ raise RuntimeError((r.stderr or r.stdout or f"código {r.returncode}").strip()[:120])
45
+ return r.stdout
46
+
47
+
48
+ # ---------------------------------------------------------------- Linux: MPRIS pelo gdbus
49
+ def _players_mpris():
50
+ saida = _rodar(["gdbus", "call", "--session", "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus",
51
+ "--method", "org.freedesktop.DBus.ListNames"])
52
+ return [n for n in re.findall(r"'(org\.mpris\.MediaPlayer2\.[^']+)'", saida)]
53
+
54
+
55
+ def _propriedade(player, nome):
56
+ return _rodar(["gdbus", "call", "--session", "--dest", player, "--object-path", "/org/mpris/MediaPlayer2", "--method",
57
+ "org.freedesktop.DBus.Properties.Get", "org.mpris.MediaPlayer2.Player", nome])
58
+
59
+
60
+ def _estado(player):
61
+ """'Playing', 'Paused' ou 'Stopped'; '' se não respondeu."""
62
+ try:
63
+ m = re.search(r"'(Playing|Paused|Stopped)'", _propriedade(player, "PlaybackStatus"))
64
+ except (OSError, RuntimeError, subprocess.SubprocessError):
65
+ return ""
66
+ return m.group(1) if m else ""
67
+
68
+
69
+ def _escolher_player(players):
70
+ """O que está tocando; senão o que está pausado; senão o primeiro."""
71
+ estados = {p: _estado(p) for p in players}
72
+ for desejado in ("Playing", "Paused"):
73
+ for p, e in estados.items():
74
+ if e == desejado:
75
+ return p
76
+ return players[0] if players else None
77
+
78
+
79
+ def _nome_do_player(player):
80
+ nome = player.split("org.mpris.MediaPlayer2.", 1)[-1]
81
+ return re.sub(r"\.instance\d+$", "", nome).capitalize()
82
+
83
+
84
+ def _metadados(player):
85
+ texto = _propriedade(player, "Metadata")
86
+ titulo = re.search(r"'xesam:title': <'((?:[^'\\]|\\.)*)'>", texto)
87
+ artista = re.search(r"'xesam:artist': <\['((?:[^'\\]|\\.)*)'", texto)
88
+ desescapar = lambda s: s.replace("\\'", "'").replace("\\\\", "\\") # noqa: E731
89
+ return (desescapar(titulo.group(1)) if titulo else "", desescapar(artista.group(1)) if artista else "")
90
+
91
+
92
+ def _linux(acao, idioma):
93
+ players = _players_mpris()
94
+ if not players:
95
+ return _t("nenhum", idioma)
96
+ player = _escolher_player(players)
97
+ if acao == "agora":
98
+ titulo, artista = _metadados(player)
99
+ estado = _estado(player)
100
+ if not titulo or estado == "Stopped":
101
+ return _t("nada", idioma)
102
+ return _t("tocando" if estado == "Playing" else "pausado", idioma, titulo=titulo,
103
+ artista=f", {artista}" if artista else "", app=_nome_do_player(player))
104
+ _rodar(["gdbus", "call", "--session", "--dest", player, "--object-path", "/org/mpris/MediaPlayer2", "--method",
105
+ f"org.mpris.MediaPlayer2.Player.{METODOS[acao]}"])
106
+ return _t(acao, idioma)
107
+
108
+
109
+ # ---------------------------------------------------------------- Windows e macOS
110
+ def _windows(acao, idioma):
111
+ if acao == "agora":
112
+ return _t("nao_sei", idioma)
113
+ import ctypes
114
+ tecla = TECLAS_WINDOWS[acao]
115
+ ctypes.windll.user32.keybd_event(tecla, 0, 0, 0) # aperta e solta a tecla de mídia
116
+ ctypes.windll.user32.keybd_event(tecla, 0, 2, 0)
117
+ return _t(acao, idioma)
118
+
119
+
120
+ def _mac(acao, idioma):
121
+ aberto = [p for p in PLAYERS_MAC if _rodar(["osascript", "-e", f'application "{p}" is running']).strip() == "true"]
122
+ if not aberto:
123
+ return _t("nenhum", idioma)
124
+ player = aberto[0]
125
+ if acao == "agora":
126
+ estado = _rodar(["osascript", "-e", f'tell application "{player}" to player state as string']).strip()
127
+ if estado not in ("playing", "paused"):
128
+ return _t("nada", idioma)
129
+ titulo = _rodar(["osascript", "-e", f'tell application "{player}" to name of current track']).strip()
130
+ artista = _rodar(["osascript", "-e", f'tell application "{player}" to artist of current track']).strip()
131
+ return _t("tocando" if estado == "playing" else "pausado", idioma, titulo=titulo, artista=f", {artista}" if artista else "", app=player)
132
+ _rodar(["osascript", "-e", f'tell application "{player}" to {COMANDOS_MAC[acao]}'])
133
+ return _t(acao, idioma)
134
+
135
+
136
+ def agir(acao, idioma="pt"):
137
+ """Faz a ação ("play_pause", "pause", "play", "next", "previous", "stop" ou "agora") e devolve a frase da resposta."""
138
+ try:
139
+ sistema = plataforma.sistema()
140
+ return {"windows": _windows, "macos": _mac}.get(sistema, _linux)(acao, idioma)
141
+ except (OSError, RuntimeError, subprocess.SubprocessError, AttributeError) as e:
142
+ return _t("falhou", idioma, erro=str(e)[:80] or type(e).__name__)
143
+
144
+
145
+ # ---------------------------------------------------------------- entender a frase
146
+ _MIDIA = r"(musica|musicas|faixa|song|songs|track|music|player|spotify|video|videos|podcast)"
147
+ _ACOES = (
148
+ ("agora", re.compile(rf"^(que|qual|what)\b.*\b{_MIDIA}\b.*\b(tocando|esta tocando|playing|is this)\b|^(what'?s|what is) (this song|playing( now)?)$|"
149
+ r"^(que|qual) (musica|faixa) (e|eh|esta tocando)( essa| esta| agora)?$|^o que esta tocando( agora)?$")),
150
+ ("next", re.compile(rf"^(pula|pule|passa|passe|skip|next)\b( a| o| the)? {_MIDIA}$|^(proxima|proximo|next) (musica|faixa|song|track|video)$|"
151
+ r"^(vai|va|ir) (para|pra|a|to) (a )?(proxima|next)( (musica|faixa|song|track))?$")),
152
+ ("previous", re.compile(rf"^(volta|volte|previous|go back)\b( a| o| the)? {_MIDIA}$|^(musica|faixa) anterior$|^(previous|last) (song|track|music)$")),
153
+ ("pause", re.compile(rf"^(pausa|pause|pare|para|stop)\b( a| o| the) {_MIDIA}$|^(pausa|pause)$|^(da|de|dar) pause$|^(pausa|pause) {_MIDIA}$")),
154
+ ("play", re.compile(rf"^(toca|toque|continua|continue|retoma|retome|resume|play|unpause)\b( a| o| the)? {_MIDIA}$|^(play)$|^(da|de|dar) play$|"
155
+ r"^(volta|volte) a tocar$")),
156
+ )
157
+
158
+
159
+ def acao_da_frase(pedido):
160
+ """A ação de mídia que a frase pede, ou None. Só frases completas e sem objeto: "toca Legião Urbana" NÃO entra (é uma busca)."""
161
+ n = normalizar(pedido)
162
+ if len(n) > 80:
163
+ return None
164
+ for acao, regra in _ACOES:
165
+ if regra.search(n):
166
+ return acao
167
+ return None
168
+
169
+
170
+ def responder(pedido, idioma):
171
+ acao = acao_da_frase(pedido)
172
+ return agir(acao, idioma) if acao else None
app/tars_modos.py CHANGED
@@ -1,50 +1,61 @@
1
- """openTARS 5.1: os três modos da interface (Lite, Base e Pro) e os ajustes avançados do Pro.
2
 
 
 
3
  Só biblioteca padrão: a janela pergunta aqui o que mostrar, e o núcleo pergunta os ajustes avançados.
4
- A escolha fica em ~/.config/opentars/config.json ("modo" e "avancado"), o mesmo arquivo do idioma.
5
-
6
- - Lite: conversa, microfone, anexos e o básico das configurações. Tudo no automático.
7
- - Base: Lite + histórico, escolha de modelo, memória, rotinas, Probes e nuvem.
8
- - Pro: Base + ajustes avançados e diagnóstico.
9
  """
10
 
11
  import tars_i18n
12
  import tars_primeiro_uso as pu
13
 
14
- MODOS = ("lite", "base", "pro")
15
- PADRAO_INSTALACAO_NOVA = "base"
16
- PADRAO_ATUALIZACAO = "pro" # quem já usava a 5.0 não perde nada do que via
17
 
18
- _LITE = {"conversa", "microfone", "anexos", "cfg.geral", "cfg.assistente", "cfg.voz", "cfg.atualizacoes", "cfg.sobre"}
19
- _BASE = _LITE | {"historico", "seletor_modelo", "memoria", "cfg.memoria", "cfg.rotinas", "cfg.probes", "cfg.nuvem", "cfg.modelos", "cfg.gerenciar"}
20
- _PRO = _BASE | {"cfg.avancado", "cfg.diagnostico"}
21
- RECURSOS = {"lite": _LITE, "base": _BASE, "pro": _PRO}
 
22
 
23
  CONTEXTOS = (4096, 8192, 16384, 32768)
24
  TEMPERATURA_MAXIMA = 1.5
25
  LIMITE_INSTRUCOES = 1500
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def modo_atual():
29
- m = tars_i18n.ler_config().get("modo")
30
- if m in MODOS:
31
- return m
32
- # sem escolha gravada: quem atualizou da 5.0 (primeiro uso já feito) fica no Pro; instalação nova, Base
33
- return PADRAO_ATUALIZACAO if pu.primeiro_uso_concluido() else PADRAO_INSTALACAO_NOVA
34
 
35
 
36
  def definir_modo(modo):
37
- if modo not in MODOS:
 
38
  raise ValueError(f"modo desconhecido: {modo}")
39
- tars_i18n.salvar_config(modo=modo)
40
 
41
 
42
  def mostra(recurso, modo=None):
43
- return recurso in RECURSOS[modo or modo_atual()]
44
 
45
 
46
  # ------------------------------------------------------------
47
- # Ajustes avançados (só valem no Pro; no Lite e no Base tudo fica no automático)
48
  # ------------------------------------------------------------
49
 
50
  def _limpar(bruto):
@@ -65,13 +76,13 @@ def _limpar(bruto):
65
 
66
 
67
  def avancado_gravado():
68
- """O que está gravado, valha ou não agora (a tela do Pro mostra isso)."""
69
  return _limpar(tars_i18n.ler_config().get("avancado"))
70
 
71
 
72
  def avancado():
73
- """Os ajustes que valem agora: os gravados no Pro; os padrões nos outros modos."""
74
- return avancado_gravado() if modo_atual() == "pro" else _limpar({})
75
 
76
 
77
  def salvar_avancado(**mudancas):
 
1
+ """openTARS 5.3: um modo só (Base) e a chave "dev", que acrescenta os ajustes avançados e o diagnóstico.
2
 
3
+ Até a 5.2 havia Lite, Base e Pro; em 07/11 o Enzo decidiu: Base para todo mundo e "dev" nas Configurações.
4
+ Quem usava o Pro atualiza com o dev ligado; quem usava o Lite ou o Base fica no Base.
5
  Só biblioteca padrão: a janela pergunta aqui o que mostrar, e o núcleo pergunta os ajustes avançados.
6
+ A escolha fica em ~/.config/opentars/config.json ("dev", e o "modo" antigo só para migrar).
 
 
 
 
7
  """
8
 
9
  import tars_i18n
10
  import tars_primeiro_uso as pu
11
 
12
+ MODOS = ("base", "dev")
 
 
13
 
14
+ _BASE = {"conversa", "microfone", "anexos", "cfg.geral", "cfg.assistente", "cfg.voz", "cfg.atualizacoes", "cfg.sobre",
15
+ "historico", "seletor_modelo", "memoria", "cfg.memoria", "cfg.rotinas", "cfg.probes", "cfg.nuvem", "cfg.modelos", "cfg.gerenciar"}
16
+ _DEV = _BASE | {"cfg.avancado", "cfg.diagnostico"}
17
+ RECURSOS = {"base": _BASE, "dev": _DEV}
18
+ _ANTIGOS = {"lite": "base", "pro": "dev"}
19
 
20
  CONTEXTOS = (4096, 8192, 16384, 32768)
21
  TEMPERATURA_MAXIMA = 1.5
22
  LIMITE_INSTRUCOES = 1500
23
 
24
 
25
+ def dev_ligado():
26
+ config = tars_i18n.ler_config()
27
+ if isinstance(config.get("dev"), bool):
28
+ return config["dev"]
29
+ antigo = config.get("modo")
30
+ if antigo in ("lite", "base"):
31
+ return False
32
+ if antigo == "pro":
33
+ return True
34
+ # sem escolha gravada: quem atualizou da 5.0 via tudo (o antigo Pro); instalação nova começa no Base
35
+ return pu.primeiro_uso_concluido()
36
+
37
+
38
+ def definir_dev(ligado):
39
+ tars_i18n.salvar_config(dev=bool(ligado))
40
+
41
+
42
  def modo_atual():
43
+ return "dev" if dev_ligado() else "base"
 
 
 
 
44
 
45
 
46
  def definir_modo(modo):
47
+ """Compatibilidade: "pro"/"dev" ligam o dev; "lite"/"base" desligam."""
48
+ if modo not in MODOS and modo not in _ANTIGOS:
49
  raise ValueError(f"modo desconhecido: {modo}")
50
+ definir_dev(_ANTIGOS.get(modo, modo) == "dev")
51
 
52
 
53
  def mostra(recurso, modo=None):
54
+ return recurso in RECURSOS[_ANTIGOS.get(modo, modo) or modo_atual()]
55
 
56
 
57
  # ------------------------------------------------------------
58
+ # Ajustes avançados (só valem com o dev ligado; no Base tudo fica no automático)
59
  # ------------------------------------------------------------
60
 
61
  def _limpar(bruto):
 
76
 
77
 
78
  def avancado_gravado():
79
+ """O que está gravado, valha ou não agora (a tela dos ajustes avançados mostra isso)."""
80
  return _limpar(tars_i18n.ler_config().get("avancado"))
81
 
82
 
83
  def avancado():
84
+ """Os ajustes que valem agora: os gravados, com o dev ligado; os padrões, no Base."""
85
+ return avancado_gravado() if dev_ligado() else _limpar({})
86
 
87
 
88
  def salvar_avancado(**mudancas):
app/tars_motor.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A porta do motor de modelos, como o tars_plataforma é a porta do sistema. Hoje o motor é o Ollama; a 5.3 troca pelo llama.cpp
2
+ (PLANO_5.3.md, F9). Cada rota abaixo é uma operação que o motor novo precisa responder, e nenhum endereço da API mora fora daqui:
3
+ tests/test_motor.py confere."""
4
+
5
+ ROTAS = {
6
+ "versao": "/api/version", # está de pé?
7
+ "modelos": "/api/tags", # o que está instalado
8
+ "detalhes": "/api/show", # capacidades, parâmetros e quantização de um modelo
9
+ "rodando": "/api/ps", # o que está na memória
10
+ "conversa": "/api/chat", # conversa com ferramentas, em streaming ou não
11
+ "gerar": "/api/generate", # carregar e descarregar da memória (keep_alive), e pergunta simples
12
+ "baixar": "/api/pull",
13
+ "apagar": "/api/delete",
14
+ "conta": "/api/me", # conta do ollama.com (nuvem): não existe no llama.cpp
15
+ }
16
+
17
+
18
+ def url(rota, host):
19
+ return f"{str(host).rstrip('/')}{ROTAS[rota]}"
app/tars_motor_local.py ADDED
@@ -0,0 +1,797 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """O motor do openTARS 5.3 (F9 do PLANO_5.3.md): llama.cpp por dentro, a API do Ollama por fora.
2
+
3
+ Decidido pelo Enzo em 07/11: llama.cpp nos três sistemas, e a parte de visão (mmproj) só quando chega uma imagem. O app continua
4
+ falando "Ollama" (13 chamadas, tars_motor.ROTAS): este processo responde nessas rotas e roda um `llama-server` por modelo em uso.
5
+
6
+ - Modelos Gargantua baixados do Hugging Face (OpenTARS/...) com SHA-256 conferido, na pasta de dados do app.
7
+ - `llama-server` da Release fixa do ggml-org (VERSAO_LLAMA), conferido pelo hash: Vulkan no Linux e no Windows (com reserva em CPU
8
+ se a máquina não tiver Vulkan), Metal no macOS.
9
+ - Uma instância por modelo; a de visão só sobe quando o pedido traz imagem. Cada uma sai depois do keep_alive parado (padrão 5 min,
10
+ -1 = fica, 0 = sai já). O processo do motor fica aberto (como o serviço do Ollama), só com uns MB de Python.
11
+
12
+ python3 tars_motor_local.py --servir (o app sobe sozinho quando precisa: garantir())
13
+ """
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import platform
18
+ import shutil
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import tarfile
23
+ import threading
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ import zipfile
28
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
+ from pathlib import Path
30
+
31
+ import tars_plataforma as plataforma
32
+
33
+ PORTA = int(os.environ.get("TARS_MOTOR_PORTA") or 11435)
34
+ URL = f"http://127.0.0.1:{PORTA}"
35
+ VERSAO_LLAMA = "b12032" # 01/11/2026: a mesma usada no Space; versão fixa, nada de "a mais nova do dia"
36
+ _GH = f"https://github.com/ggml-org/llama.cpp/releases/download/{VERSAO_LLAMA}/"
37
+ BINARIOS = { # (sistema, máquina, aceleração): (arquivo, sha256)
38
+ ("linux", "x86_64", "vulkan"): ("llama-b12032-bin-ubuntu-vulkan-x64.tar.gz", "f75e7107f9450034c5467492c41e9ed91816e2d815fc4b802c6bd58de7d5b126"),
39
+ ("linux", "x86_64", "cpu"): ("llama-b12032-bin-ubuntu-x64.tar.gz", "9f10432ca3f66ef003a7a540aaffb9da0acadaefd05404521b9ab7a2436af304"),
40
+ ("windows", "x86_64", "vulkan"): ("llama-b12032-bin-win-vulkan-x64.zip", "19a8bbf269fa683ae7bc04f886c9c2a93e0655c2606f37d2b60069bf9e8bcc32"),
41
+ ("windows", "x86_64", "cpu"): ("llama-b12032-bin-win-cpu-x64.zip", "5296f5d18bb7d422c7c26f4bb9bc796d9d4f41e1312a8c296cff475bdc60d0d4"),
42
+ ("macos", "arm64", "metal"): ("llama-b12032-bin-macos-arm64.tar.gz", "00468fe833eb000b78a748122f5921b6c7b07dc82331a8d0c68196c69cb26382"),
43
+ ("macos", "x86_64", "cpu"): ("llama-b12032-bin-macos-x64.tar.gz", "291d99f3b6f45f67f73afe5381540f29928840a1a14d9c19d6595bd649a035fc"),
44
+ }
45
+ _HF = "https://huggingface.co/OpenTARS/{repo}/resolve/main/{arquivo}"
46
+ _PARAMETROS_QWEN = "top_k 20\ntop_p 0.8\nrepeat_penalty 1.05\ntemperature 0.4"
47
+ _PARAMETROS_GEMMA = "top_k 64\ntop_p 0.95\ntemperature 0.4"
48
+ CATALOGO = { # a ficha que o Ollama dava para cada modelo (copiada em 07/11), mais os arquivos do Hugging Face
49
+ "ProjectOpenTARS/Gargantua-2.5:latest": { # 13/11: Gemma 4 E4B, treino completo (1.949 passos)
50
+ "repo": "Gargantua-2.5", "gguf": ("gargantua-2.5-gemma4-e4b-Q4_K_M.gguf", "6b9e28f8c5eb8361796d0ecbe0d6a7bc3338f942820afcad388c5beeead795e1", 5302274208),
51
+ "mmproj": ("mmproj-gargantua-2.5-gemma4-e4b-F16.gguf", "c4347b30c7d9d176bb264fc6a14b81e44dde7266b9dd035ea60a91170d39775f", 990372160),
52
+ "capabilities": ["completion", "vision", "audio", "tools", "thinking"], "family": "gemma4", "parameter_size": "7.5B", "quantization_level": "Q4_K_M",
53
+ "parameters": _PARAMETROS_GEMMA},
54
+ "ProjectOpenTARS/Gargantua-2.5:lite": { # 13/11: Gemma 4 E2B (Lite Gen 3), primeira época (a segunda inventou mais: 11 contra 4 de 48; decisão do Enzo)
55
+ "repo": "Gargantua-2.5-Lite", "gguf": ("gargantua-2.5-lite-gemma4-e2b-Q4_K_M.gguf", "08b61b2a5e860033565cc8a057c03331f9d8a1f1465e3bd45a0f55117bb26c29", 3416120096),
56
+ "mmproj": ("mmproj-gargantua-2.5-lite-gemma4-e2b-F16.gguf", "7024964e8736cfe91cee244670315bc6aa8582dcdfb7eb65e4b460df7681546a", 985653568),
57
+ "capabilities": ["completion", "vision", "audio", "tools", "thinking"], "family": "gemma4", "parameter_size": "4.6B", "quantization_level": "Q4_K_M",
58
+ "parameters": _PARAMETROS_GEMMA},
59
+ "ProjectOpenTARS/Gargantua-Pro:pro": { # 11/11: Gemma 4 12B (Pro Gen 1, passo 300 de 368); o projetor tem de ser F32 (em F16 a placa corrompe)
60
+ "repo": "Gargantua-Pro", "gguf": ("gargantua-pro-gemma4-12b-Q4_K_M.gguf", "add484a229114d2cf6d7716f8264f830dd85a732591fbbc20439c3bebc943e0b", 7381383104),
61
+ "mmproj": ("mmproj-gargantua-pro-gemma4-12b-F32.gguf", "f35db6bf889c96f70f75350b371d2d0bd99d1bd62dea88c89594d245719d0267", 209521600),
62
+ "capabilities": ["completion", "vision", "tools", "thinking"], "family": "gemma4", "parameter_size": "11.95B", "quantization_level": "Q4_K_M",
63
+ "parameters": _PARAMETROS_GEMMA},
64
+ "ProjectOpenTARS/Gargantua-2:latest": {
65
+ "repo": "Gargantua-2", "gguf": ("gargantua-1.2-qwen3.5-2b-Q4_0.gguf", "3f2d450ec96be581d4b05f34eab703d1bd78e9f3e88a69f51e5efb6c5c61a86c", 1202486848),
66
+ "mmproj": ("mmproj-gargantua-qwen3.5-2b-Q8_0.gguf", "3156ce8710040751b6affc151eb66fe225dbe369a4ae51cd6298b1ae74087790", 364663840),
67
+ "capabilities": ["completion", "vision", "tools", "thinking"], "family": "qwen35", "parameter_size": "1.9B", "quantization_level": "Q4_0",
68
+ "parameters": _PARAMETROS_QWEN},
69
+ "ProjectOpenTARS/Gargantua-2:lite": {
70
+ "repo": "Gargantua-2-Lite", "gguf": ("gargantua-1.2-lite-qwen3.5-0.8b-Q4_0.gguf", "8736bc95729f047dfcf265e8fdaa8ed611bf9cf30c8d06e79b8e4f0d1cbbf29a", 501452352),
71
+ "mmproj": ("mmproj-gargantua-qwen3.5-0.8b-Q8_0.gguf", "1251de28a9e9c147e81a589d64e67470a5e8aa66a99834bb6902283a4d210519", 115923136),
72
+ "capabilities": ["completion", "vision", "tools", "thinking"], "family": "qwen35", "parameter_size": "752.39M", "quantization_level": "Q4_0",
73
+ "parameters": _PARAMETROS_QWEN},
74
+ "ProjectOpenTARS/Gargantua-Code-Gen2:code": {
75
+ "repo": "Gargantua-Code-Gen2", "gguf": ("gargantua-1.2-code-qwen2.5-coder-7b-Q4_K_M.gguf", "4c17360dcb36a3c0932f600df0eb6d7a3859e3ee7406fd4bbfb0f7c526904456", 4683073536),
76
+ "mmproj": None, "capabilities": ["completion", "tools", "insert"], "family": "qwen2", "parameter_size": "7.6B", "quantization_level": "Q4_K_M",
77
+ "parameters": _PARAMETROS_QWEN},
78
+ }
79
+ KEEP_ALIVE_PADRAO = 300
80
+ OLLAMA = os.environ.get("TARS_OLLAMA_REAL") or "http://127.0.0.1:11434" # modelos que não são do catálogo ("Mais modelos") seguem para ele
81
+ CONTEXTO = 8192
82
+
83
+
84
+ # ---------------------------------------------------------------- arquivos
85
+
86
+ def pasta():
87
+ return plataforma.pastas()["dados"] / "motor"
88
+
89
+
90
+ def pasta_modelos():
91
+ return pasta() / "modelos"
92
+
93
+
94
+ def sistema_e_maquina():
95
+ maquina = platform.machine().lower()
96
+ maquina = {"amd64": "x86_64", "aarch64": "arm64"}.get(maquina, maquina)
97
+ return plataforma.sistema(), maquina
98
+
99
+
100
+ def tem_vulkan():
101
+ s, _ = sistema_e_maquina()
102
+ if s == "windows":
103
+ return Path(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "vulkan-1.dll").exists()
104
+ if s == "linux":
105
+ try:
106
+ return "libvulkan.so.1" in subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=5).stdout
107
+ except (OSError, subprocess.SubprocessError):
108
+ return any(Path(p, "libvulkan.so.1").exists() for p in ("/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"))
109
+ return False
110
+
111
+
112
+ def variantes():
113
+ """As acelerações a tentar, da melhor para a reserva."""
114
+ s, m = sistema_e_maquina()
115
+ if s == "macos":
116
+ return ["metal"] if m == "arm64" else ["cpu"]
117
+ return (["vulkan"] if tem_vulkan() else []) + ["cpu"]
118
+
119
+
120
+ def binario():
121
+ """O llama-server instalado (o da melhor variante que já foi baixada), ou None."""
122
+ s, m = sistema_e_maquina()
123
+ nome = "llama-server.exe" if s == "windows" else "llama-server"
124
+ for v in variantes():
125
+ raiz = pasta() / VERSAO_LLAMA / v
126
+ if raiz.exists():
127
+ achado = next(iter(sorted(raiz.rglob(nome))), None)
128
+ if achado:
129
+ return achado
130
+ return None
131
+
132
+
133
+ def _sha256(caminho, algoritmo="sha256"):
134
+ h = hashlib.new(algoritmo)
135
+ with open(caminho, "rb") as f:
136
+ for bloco in iter(lambda: f.read(1 << 20), b""):
137
+ h.update(bloco)
138
+ return h.hexdigest()
139
+
140
+
141
+ def baixar(url, destino, sha, ao_progresso=None, tentativas=3, algoritmo="sha256"):
142
+ """Baixa com retomada (.parcial), confere o hash (SHA-256; `algoritmo` troca, a imagem do Debian usa SHA-512) e só então põe
143
+ no lugar. Levanta ValueError se o hash não bater."""
144
+ for tentativa in range(tentativas): # a conexão que trava no meio retoma do .parcial (visto em 07/11 no GitHub)
145
+ try:
146
+ return _baixar(url, destino, sha, ao_progresso, algoritmo)
147
+ except (TimeoutError, ConnectionError, urllib.error.URLError):
148
+ if tentativa == tentativas - 1:
149
+ raise
150
+
151
+
152
+ def _baixar(url, destino, sha, ao_progresso, algoritmo="sha256"):
153
+ destino = Path(destino)
154
+ destino.parent.mkdir(parents=True, exist_ok=True)
155
+ parcial = destino.with_name(destino.name + ".parcial")
156
+ feito = parcial.stat().st_size if parcial.exists() else 0
157
+ pedido = urllib.request.Request(url, headers={"User-Agent": "openTARS", **({"Range": f"bytes={feito}-"} if feito else {})})
158
+ with urllib.request.urlopen(pedido, timeout=60) as r:
159
+ if feito and r.status != 206:
160
+ feito = 0 # o servidor não retomou: começa de novo
161
+ total = feito + int(r.headers.get("Content-Length") or 0)
162
+ with open(parcial, "ab" if feito else "wb") as f:
163
+ for bloco in iter(lambda: r.read(1 << 16), b""):
164
+ f.write(bloco)
165
+ feito += len(bloco)
166
+ if ao_progresso:
167
+ ao_progresso(feito, total)
168
+ if _sha256(parcial, algoritmo) != sha:
169
+ parcial.unlink(missing_ok=True)
170
+ raise ValueError(f"{destino.name}: {algoritmo.upper()} check failed")
171
+ os.replace(parcial, destino)
172
+ return destino
173
+
174
+
175
+ def instalar_binario(ao_progresso=None):
176
+ """Baixa e extrai o llama-server desta máquina (a melhor variante; a de CPU se a outra não existir aqui)."""
177
+ s, m = sistema_e_maquina()
178
+ for v in variantes():
179
+ chave = (s, m, v)
180
+ if chave not in BINARIOS:
181
+ continue
182
+ arquivo, sha = BINARIOS[chave]
183
+ raiz = pasta() / VERSAO_LLAMA / v
184
+ pacote = baixar(_GH + arquivo, pasta() / "baixados" / arquivo, sha, ao_progresso)
185
+ temporario = raiz.with_name(raiz.name + ".novo")
186
+ shutil.rmtree(temporario, ignore_errors=True)
187
+ temporario.mkdir(parents=True)
188
+ if arquivo.endswith(".zip"):
189
+ with zipfile.ZipFile(pacote) as z:
190
+ z.extractall(temporario)
191
+ else:
192
+ with tarfile.open(pacote) as t:
193
+ t.extractall(temporario, **({"filter": "data"} if hasattr(tarfile, "data_filter") else {}))
194
+ for executavel in temporario.rglob("llama-server*"):
195
+ executavel.chmod(0o755)
196
+ shutil.rmtree(raiz, ignore_errors=True)
197
+ os.replace(temporario, raiz)
198
+ pacote.unlink(missing_ok=True)
199
+ return binario()
200
+ raise ValueError(f"no llama.cpp build for {s} {m}")
201
+
202
+
203
+ def _blob_do_ollama(sha):
204
+ """O arquivo do Ollama com esse SHA-256 (o mesmo GGUF que publicamos no Hugging Face), se esta máquina tiver."""
205
+ pastas = [os.environ.get("OLLAMA_MODELS"), Path.home() / ".ollama" / "models", "/usr/share/ollama/.ollama/models", "/var/lib/ollama/models"]
206
+ for raiz in filter(None, pastas):
207
+ blob = Path(raiz) / "blobs" / f"sha256-{sha}"
208
+ if blob.is_file() and os.access(blob, os.R_OK):
209
+ return blob
210
+ return None
211
+
212
+
213
+ def sha_do_arquivo(destino):
214
+ """SHA-256 de um GGUF já na pasta: lido da marca ao lado (gravada ao pôr o arquivo), ou calculado uma vez. O nome do arquivo
215
+ não muda entre versões do modelo; sem a marca, o modelo velho passava por novo para sempre (revisão de 13/11)."""
216
+ marca = Path(destino).with_name(Path(destino).name + ".sha256")
217
+ try:
218
+ return marca.read_text().strip()
219
+ except OSError:
220
+ sha = _sha256(destino)
221
+ marca.write_text(sha)
222
+ return sha
223
+
224
+
225
+ def _atual(destino, sha):
226
+ return destino.exists() and sha_do_arquivo(destino) == sha
227
+
228
+
229
+ def obter_arquivo(repo, arquivo, sha, ao_progresso=None):
230
+ """Põe um GGUF do catálogo na pasta: copiado do Ollama se ele já tem o mesmo arquivo, senão baixado do Hugging Face.
231
+ Um arquivo de mesmo nome com outro SHA-256 (versão anterior do modelo) é trocado."""
232
+ destino = pasta_modelos() / arquivo
233
+ if _atual(destino, sha):
234
+ return destino
235
+ blob = _blob_do_ollama(sha)
236
+ if not blob:
237
+ baixar(_HF.format(repo=repo, arquivo=arquivo), destino, sha, ao_progresso)
238
+ else:
239
+ destino.parent.mkdir(parents=True, exist_ok=True)
240
+ temporario = destino.with_name(destino.name + ".parcial")
241
+ shutil.copyfile(blob, temporario)
242
+ if _sha256(temporario) != sha:
243
+ temporario.unlink(missing_ok=True)
244
+ raise ValueError(f"{arquivo}: SHA-256 check failed")
245
+ os.replace(temporario, destino)
246
+ destino.with_name(destino.name + ".sha256").write_text(sha)
247
+ return destino
248
+
249
+
250
+ def preparar(nomes, ao_progresso=None):
251
+ """Instala o llama-server e os modelos pedidos. Levanta OSError/ValueError."""
252
+ if binario() is None:
253
+ instalar_binario(ao_progresso)
254
+ for nome in nomes:
255
+ ficha = CATALOGO[_nome(nome)]
256
+ for arquivo, sha, _ in [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else []):
257
+ obter_arquivo(ficha["repo"], arquivo, sha, ao_progresso)
258
+
259
+
260
+ def _nome(nome):
261
+ """"ProjectOpenTARS/Gargantua-2" é o mesmo que ":latest", como no Ollama."""
262
+ return nome if not nome or ":" in nome.split("/")[-1] else nome + ":latest"
263
+
264
+
265
+ def instalado(nome):
266
+ nome = _nome(nome)
267
+ ficha = CATALOGO.get(nome)
268
+ return bool(ficha) and (pasta_modelos() / ficha["gguf"][0]).exists()
269
+
270
+
271
+ def instalados():
272
+ return [n for n in CATALOGO if instalado(n)]
273
+
274
+
275
+ def escolhido():
276
+ """"llamacpp" (padrão) ou "ollama". A variável TARS_MOTOR vale mais que a config (chave "motor", Configurações > Avançado, de dev)."""
277
+ v = (os.environ.get("TARS_MOTOR") or "").strip().lower()
278
+ if v not in ("ollama", "llamacpp"):
279
+ try:
280
+ import tars_i18n
281
+ v = str(tars_i18n.ler_config().get("motor", "")).strip().lower()
282
+ except Exception:
283
+ v = ""
284
+ return "ollama" if v == "ollama" else "llamacpp"
285
+
286
+
287
+ def pronto():
288
+ """O motor pode servir agora: tem o llama-server e ao menos um modelo."""
289
+ return binario() is not None and bool(instalados())
290
+
291
+
292
+ # ---------------------------------------------------------------- instâncias do llama-server
293
+
294
+ def _porta_livre():
295
+ with socket.socket() as s:
296
+ s.bind(("127.0.0.1", 0))
297
+ return s.getsockname()[1]
298
+
299
+
300
+ def _pedir(url, corpo=None, tempo=900):
301
+ dados = json.dumps(corpo).encode() if corpo is not None else None
302
+ return urllib.request.urlopen(urllib.request.Request(url, dados, {"Content-Type": "application/json"}), timeout=tempo)
303
+
304
+
305
+ class Instancia:
306
+ def __init__(self, nome, visao):
307
+ ficha = CATALOGO[nome]
308
+ self.nome, self.visao, self.porta = nome, visao, _porta_livre()
309
+ self.ultimo, self.keep_alive = time.time(), KEEP_ALIVE_PADRAO
310
+ comando = [str(binario()), "-m", str(pasta_modelos() / ficha["gguf"][0]), "--host", "127.0.0.1", "--port", str(self.porta),
311
+ "-c", str(CONTEXTO), "--jinja", "--parallel", "1", "--no-webui", "-ngl", "99"]
312
+ if visao and ficha.get("mmproj"):
313
+ comando += ["--mmproj", str(pasta_modelos() / ficha["mmproj"][0])]
314
+ registro = open(pasta() / "llama-server.log", "ab")
315
+ extra = {"creationflags": 0x08000000} if plataforma.sistema() == "windows" else {"start_new_session": True} # sem janela preta no Windows
316
+ self.processo = subprocess.Popen(comando, stdin=subprocess.DEVNULL, stdout=registro, stderr=registro, **extra)
317
+ limite = time.time() + 300
318
+ while time.time() < limite:
319
+ if self.processo.poll() is not None:
320
+ raise RuntimeError(f"llama-server stopped with code {self.processo.returncode} (see {pasta() / 'llama-server.log'})")
321
+ try:
322
+ with _pedir(f"http://127.0.0.1:{self.porta}/health", tempo=2) as r:
323
+ if json.loads(r.read()).get("status") == "ok":
324
+ return
325
+ except (OSError, ValueError):
326
+ time.sleep(0.3)
327
+ self.parar()
328
+ raise RuntimeError("llama-server took too long to start")
329
+
330
+ def parar(self):
331
+ if self.processo.poll() is None:
332
+ self.processo.terminate()
333
+ try:
334
+ self.processo.wait(10)
335
+ except subprocess.TimeoutExpired:
336
+ self.processo.kill()
337
+
338
+
339
+ class Motor:
340
+ def __init__(self):
341
+ self.instancias, self.trava, self.ultimo_pedido = {}, threading.Lock(), time.time()
342
+
343
+ def instancia(self, nome, visao=False):
344
+ if nome not in CATALOGO or not instalado(nome):
345
+ raise KeyError(nome)
346
+ visao = visao and bool(CATALOGO[nome].get("mmproj"))
347
+ with self.trava:
348
+ chave = (nome, visao)
349
+ atual = self.instancias.get(chave)
350
+ if atual is None or atual.processo.poll() is not None:
351
+ atual = self.instancias[chave] = Instancia(nome, visao)
352
+ atual.ultimo = time.time()
353
+ return atual
354
+
355
+ def descarregar(self, nome):
356
+ with self.trava:
357
+ for chave in [c for c in self.instancias if c[0] == nome]:
358
+ self.instancias.pop(chave).parar()
359
+
360
+ def faxina(self):
361
+ """Tira da memória o que ficou parado além do keep_alive."""
362
+ agora = time.time()
363
+ with self.trava:
364
+ for chave, inst in list(self.instancias.items()):
365
+ if inst.processo.poll() is not None or (inst.keep_alive >= 0 and agora - inst.ultimo > inst.keep_alive):
366
+ self.instancias.pop(chave).parar()
367
+
368
+ def parar_tudo(self):
369
+ with self.trava:
370
+ for inst in self.instancias.values():
371
+ inst.parar()
372
+ self.instancias.clear()
373
+
374
+
375
+ def _keep_alive(valor):
376
+ if valor in (None, ""):
377
+ return KEEP_ALIVE_PADRAO
378
+ if isinstance(valor, (int, float)):
379
+ return float(valor)
380
+ texto = str(valor).strip()
381
+ unidade = {"s": 1, "m": 60, "h": 3600}.get(texto[-1:], 1)
382
+ try:
383
+ return float(texto[:-1] if texto[-1:] in "smh" else texto) * unidade
384
+ except ValueError:
385
+ return KEEP_ALIVE_PADRAO
386
+
387
+
388
+ # ---------------------------------------------------------------- tradução Ollama <-> OpenAI (llama-server)
389
+
390
+ def _parametros(nome):
391
+ p = {}
392
+ for linha in CATALOGO[nome]["parameters"].splitlines():
393
+ partes = linha.split()
394
+ if len(partes) == 2:
395
+ try:
396
+ p[partes[0]] = float(partes[1])
397
+ except ValueError:
398
+ pass
399
+ return p
400
+
401
+
402
+ def para_openai(corpo, nome):
403
+ mensagens, pendentes, numero = [], [], 0
404
+ for m in corpo.get("messages", []):
405
+ saida = {"role": m["role"], "content": m.get("content") or ""}
406
+ if m.get("images") or m.get("audio"): # "audio": WAV em base64, campo nosso (o Ollama não tem); o Gemma 4 ouve pelo projetor
407
+ saida["content"] = [{"type": "text", "text": saida["content"]}] + [
408
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{i}"}} for i in m.get("images") or []] + [
409
+ {"type": "input_audio", "input_audio": {"data": a, "format": "wav"}} for a in m.get("audio") or []]
410
+ if m.get("tool_calls"):
411
+ saida["tool_calls"] = []
412
+ for chamada in m["tool_calls"]:
413
+ numero += 1
414
+ f = chamada.get("function", {})
415
+ argumentos = f.get("arguments")
416
+ saida["tool_calls"].append({"id": f"call_{numero}", "type": "function", "function": {
417
+ "name": f.get("name"), "arguments": argumentos if isinstance(argumentos, str) else json.dumps(argumentos or {}, ensure_ascii=False)}})
418
+ pendentes.append(f"call_{numero}")
419
+ if m["role"] == "tool":
420
+ saida["tool_call_id"] = pendentes.pop(0) if pendentes else "call_0"
421
+ if m.get("tool_name") or m.get("name"):
422
+ saida["name"] = m.get("tool_name") or m.get("name")
423
+ mensagens.append(saida)
424
+ opcoes = {**_parametros(nome), **(corpo.get("options") or {})}
425
+ saida = {"messages": mensagens, "stream": bool(corpo.get("stream", True)), "cache_prompt": True,
426
+ "temperature": opcoes.get("temperature", 0.4), "top_p": opcoes.get("top_p", 0.8), "top_k": opcoes.get("top_k", 20),
427
+ "repeat_penalty": opcoes.get("repeat_penalty", 1.05), "chat_template_kwargs": {"enable_thinking": bool(corpo.get("think", False))}}
428
+ if opcoes.get("num_predict"):
429
+ saida["max_tokens"] = int(opcoes["num_predict"])
430
+ if corpo.get("tools"):
431
+ saida["tools"] = corpo["tools"]
432
+ if corpo.get("logprobs") and not (corpo.get("tools") and saida["stream"]):
433
+ saida["logprobs"] = True # shortcut: o llama-server recusa logprobs com ferramentas em streaming; aí a conferência de confiança fica sem sinal
434
+ if corpo.get("format") == "json":
435
+ saida["response_format"] = {"type": "json_object"}
436
+ elif isinstance(corpo.get("format"), dict):
437
+ saida["response_format"] = {"type": "json_schema", "json_schema": {"schema": corpo["format"]}}
438
+ return saida
439
+
440
+
441
+ def _chamadas_ollama(chamadas):
442
+ saida = []
443
+ for chamada in chamadas:
444
+ f = chamada.get("function", {})
445
+ argumentos = f.get("arguments")
446
+ try:
447
+ argumentos = json.loads(argumentos) if isinstance(argumentos, str) else argumentos
448
+ except ValueError:
449
+ argumentos = {}
450
+ saida.append({"function": {"name": f.get("name"), "arguments": argumentos or {}}})
451
+ return saida
452
+
453
+
454
+ def _final(nome, inicio, mensagem, motivo, logprobs=None):
455
+ saida = {"model": nome, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "message": mensagem, "done": True,
456
+ "done_reason": "length" if motivo == "length" else "stop", "total_duration": int((time.time() - inicio) * 1e9)}
457
+ if logprobs:
458
+ saida["logprobs"] = logprobs
459
+ return saida
460
+
461
+
462
+ def _tem_imagem(corpo):
463
+ """Imagem ou áudio no pedido: o llama-server precisa do projetor (mmproj), que só carrega quando precisa."""
464
+ return any(m.get("images") or m.get("audio") for m in corpo.get("messages", []))
465
+
466
+
467
+ # ---------------------------------------------------------------- o servidor com cara de Ollama
468
+
469
+ def _do_ollama(caminho):
470
+ """Os modelos do Ollama da pessoa, menos os que o motor já tem (ele serve esses). Lista vazia se não há Ollama."""
471
+ try:
472
+ with urllib.request.urlopen(OLLAMA + caminho, timeout=1.5) as r:
473
+ return [m for m in json.loads(r.read()).get("models", []) if not instalado(m.get("name"))]
474
+ except (OSError, ValueError):
475
+ return []
476
+
477
+
478
+ def _ficha_tags(nome):
479
+ ficha = CATALOGO[nome]
480
+ caminho = pasta_modelos() / ficha["gguf"][0]
481
+ tamanho = ficha["gguf"][2] + (ficha["mmproj"][2] if ficha.get("mmproj") else 0)
482
+ detalhes = {"parent_model": ficha["gguf"][0], "format": "gguf", "family": ficha["family"], "families": [ficha["family"]],
483
+ "parameter_size": ficha["parameter_size"], "quantization_level": ficha["quantization_level"]}
484
+ modificado = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(caminho.stat().st_mtime)) if caminho.exists() else ""
485
+ return {"name": nome, "model": nome, "modified_at": modificado, "size": tamanho, "digest": ficha["gguf"][1], "details": detalhes,
486
+ "capabilities": ficha["capabilities"]}
487
+
488
+
489
+ def servir():
490
+ motor = Motor()
491
+ pasta().mkdir(parents=True, exist_ok=True)
492
+
493
+ class Pedido(BaseHTTPRequestHandler):
494
+ protocol_version = "HTTP/1.1"
495
+
496
+ def log_message(self, *a):
497
+ pass
498
+
499
+ def _json(self, dados, codigo=200):
500
+ corpo = json.dumps(dados, ensure_ascii=False).encode()
501
+ self.send_response(codigo)
502
+ self.send_header("Content-Type", "application/json")
503
+ self.send_header("Content-Length", str(len(corpo)))
504
+ self.end_headers()
505
+ self.wfile.write(corpo)
506
+
507
+ def _linhas(self):
508
+ self.send_response(200)
509
+ self.send_header("Content-Type", "application/x-ndjson")
510
+ self.send_header("Transfer-Encoding", "chunked")
511
+ self.end_headers()
512
+
513
+ def _linha(self, dados):
514
+ texto = (json.dumps(dados, ensure_ascii=False) + "\n").encode()
515
+ self.wfile.write(f"{len(texto):x}\r\n".encode() + texto + b"\r\n")
516
+ self.wfile.flush()
517
+
518
+ def _fim_linhas(self):
519
+ self.wfile.write(b"0\r\n\r\n")
520
+ self.wfile.flush()
521
+
522
+ def do_GET(self):
523
+ motor.ultimo_pedido = time.time()
524
+ if self.path == "/api/version":
525
+ return self._json({"version": "0.40.6"})
526
+ if self.path == "/api/tags":
527
+ return self._json({"models": [_ficha_tags(n) for n in instalados()] + _do_ollama("/api/tags")})
528
+ if self.path == "/api/ps":
529
+ with motor.trava:
530
+ vivos = {c[0] for c in motor.instancias}
531
+ return self._json({"models": [dict(_ficha_tags(n), size_vram=0, expires_at="") for n in vivos] + _do_ollama("/api/ps")})
532
+ if self.path in ("/", "/api"):
533
+ return self._json({"status": "openTARS motor (llama.cpp)"})
534
+ self._json({"error": "not found"}, 404)
535
+
536
+ def do_HEAD(self):
537
+ self.send_response(200)
538
+ self.send_header("Content-Length", "0")
539
+ self.end_headers()
540
+
541
+ def _repassar(self, corpo):
542
+ """Pedido de um modelo de fora do catálogo: vai igual para o Ollama da pessoa, e a resposta volta pedaço por pedaço."""
543
+ pedido = urllib.request.Request(OLLAMA + self.path, json.dumps(corpo).encode(), {"Content-Type": "application/json"}, method=self.command)
544
+ try:
545
+ r = urllib.request.urlopen(pedido, timeout=900)
546
+ except urllib.error.HTTPError as e:
547
+ r = e
548
+ except OSError:
549
+ return self._json({"error": f"model '{corpo.get('model') or corpo.get('name')}' not found"}, 404)
550
+ with r:
551
+ self.send_response(r.status)
552
+ self.send_header("Content-Type", r.headers.get("Content-Type") or "application/json")
553
+ self.send_header("Transfer-Encoding", "chunked")
554
+ self.end_headers()
555
+ for bloco in iter(lambda: r.read1(1 << 16), b""):
556
+ self.wfile.write(f"{len(bloco):x}\r\n".encode() + bloco + b"\r\n")
557
+ self.wfile.flush()
558
+ self._fim_linhas()
559
+
560
+ def do_DELETE(self):
561
+ corpo = self._corpo()
562
+ nome = _nome(corpo.get("model") or corpo.get("name"))
563
+ if nome not in CATALOGO:
564
+ return self._repassar(corpo)
565
+ motor.descarregar(nome)
566
+ ficha = CATALOGO[nome]
567
+ for arquivo in [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else []):
568
+ (pasta_modelos() / arquivo[0]).unlink(missing_ok=True)
569
+ self._json({})
570
+
571
+ def _corpo(self):
572
+ tamanho = int(self.headers.get("Content-Length") or 0)
573
+ try:
574
+ return json.loads(self.rfile.read(tamanho) or b"{}")
575
+ except ValueError:
576
+ return {}
577
+
578
+ def do_POST(self):
579
+ motor.ultimo_pedido = time.time()
580
+ corpo = self._corpo()
581
+ nome = _nome(corpo.get("model") or corpo.get("name"))
582
+ if nome not in CATALOGO or (self.path != "/api/pull" and not instalado(nome) and self.path != "/api/delete"):
583
+ return self._repassar(corpo)
584
+ if self.path == "/api/show":
585
+ if nome not in CATALOGO or not instalado(nome):
586
+ return self._json({"error": f"model '{nome}' not found"}, 404)
587
+ ficha = _ficha_tags(nome)
588
+ return self._json({"capabilities": ficha["capabilities"], "details": ficha["details"], "parameters": CATALOGO[nome]["parameters"],
589
+ # o hash do arquivo instalado: o aviso de modelo novo (tars_gerenciar_modelos.desatualizados) compara com o ollama.com
590
+ "modelfile": f"# {CATALOGO[nome]['gguf'][0]} sha256:{sha_do_arquivo(pasta_modelos() / CATALOGO[nome]['gguf'][0])}", "template": "", "model_info": {"general.architecture": CATALOGO[nome]["family"]}})
591
+ if self.path == "/api/pull":
592
+ return self._puxar(nome, corpo.get("stream", True))
593
+ if self.path == "/api/delete":
594
+ return self.do_DELETE()
595
+ if self.path == "/api/generate":
596
+ return self._gerar(nome, corpo)
597
+ if self.path == "/api/chat":
598
+ return self._conversar(nome, corpo)
599
+ self._json({"error": "not found"}, 404)
600
+
601
+ def _gerar(self, nome, corpo):
602
+ ka = _keep_alive(corpo.get("keep_alive"))
603
+ if nome not in CATALOGO or not instalado(nome):
604
+ return self._json({"error": f"model '{nome}' not found"}, 404)
605
+ if not corpo.get("prompt"):
606
+ if ka == 0:
607
+ motor.descarregar(nome)
608
+ return self._json({"model": nome, "response": "", "done": True, "done_reason": "unload"})
609
+ try:
610
+ motor.instancia(nome).keep_alive = ka # carregar na memória e deixar quente
611
+ except (RuntimeError, KeyError) as e:
612
+ return self._json({"error": str(e)}, 500)
613
+ return self._json({"model": nome, "response": "", "done": True, "done_reason": "load"})
614
+ chat = {"messages": [{"role": "user", "content": corpo["prompt"], **({"images": corpo["images"]} if corpo.get("images") else {})}],
615
+ "stream": False, "options": corpo.get("options"), "think": corpo.get("think", False), "keep_alive": corpo.get("keep_alive")}
616
+ if corpo.get("system"):
617
+ chat["messages"].insert(0, {"role": "system", "content": corpo["system"]})
618
+ resposta = self._responder(nome, chat)
619
+ if "error" in resposta:
620
+ return self._json(resposta, 500)
621
+ return self._json({"model": nome, "response": resposta["message"]["content"], "done": True, "done_reason": resposta["done_reason"]})
622
+
623
+ def _responder(self, nome, corpo):
624
+ """Pedido sem streaming: o último pedaço do /api/chat."""
625
+ inicio = time.time()
626
+ try:
627
+ inst = motor.instancia(nome, _tem_imagem(corpo))
628
+ inst.keep_alive = _keep_alive(corpo.get("keep_alive"))
629
+ pedido = dict(para_openai(corpo, nome), stream=False)
630
+ with _pedir(f"http://127.0.0.1:{inst.porta}/v1/chat/completions", pedido) as r:
631
+ dados = json.loads(r.read())
632
+ inst.ultimo = time.time()
633
+ except urllib.error.HTTPError as e:
634
+ return {"error": e.read().decode("utf-8", "replace")[:300]}
635
+ except (OSError, RuntimeError, KeyError, ValueError) as e:
636
+ return {"error": str(e)}
637
+ escolha = dados["choices"][0]
638
+ m = escolha["message"]
639
+ mensagem = {"role": "assistant", "content": m.get("content") or ""}
640
+ if m.get("reasoning_content"):
641
+ mensagem["thinking"] = m["reasoning_content"]
642
+ if m.get("tool_calls"):
643
+ mensagem["tool_calls"] = _chamadas_ollama(m["tool_calls"])
644
+ logprobs = [{"token": x.get("token"), "logprob": x.get("logprob")} for x in (escolha.get("logprobs") or {}).get("content") or []]
645
+ return _final(nome, inicio, mensagem, escolha.get("finish_reason"), logprobs or None)
646
+
647
+ def _conversar(self, nome, corpo):
648
+ if nome not in CATALOGO or not instalado(nome):
649
+ return self._json({"error": f"model '{nome}' not found"}, 404)
650
+ if not corpo.get("stream", True):
651
+ resposta = self._responder(nome, corpo)
652
+ return self._json(resposta, 500 if "error" in resposta else 200)
653
+ inicio = time.time()
654
+ try:
655
+ inst = motor.instancia(nome, _tem_imagem(corpo))
656
+ inst.keep_alive = _keep_alive(corpo.get("keep_alive"))
657
+ r = _pedir(f"http://127.0.0.1:{inst.porta}/v1/chat/completions", dict(para_openai(corpo, nome), stream=True))
658
+ except urllib.error.HTTPError as e:
659
+ return self._json({"error": e.read().decode("utf-8", "replace")[:300]}, 500)
660
+ except (OSError, RuntimeError, KeyError) as e:
661
+ return self._json({"error": str(e)}, 500)
662
+ self._linhas()
663
+ chamadas, motivo, logprobs = {}, "stop", []
664
+ with r:
665
+ for bruta in r:
666
+ linha = bruta.decode("utf-8", "replace").strip()
667
+ if not linha.startswith("data:"):
668
+ continue
669
+ dado = linha[5:].strip()
670
+ if dado == "[DONE]":
671
+ break
672
+ try:
673
+ pedaco = json.loads(dado)
674
+ except ValueError:
675
+ continue
676
+ for escolha in pedaco.get("choices") or []:
677
+ delta = escolha.get("delta") or {}
678
+ motivo = escolha.get("finish_reason") or motivo
679
+ for c in delta.get("tool_calls") or []: # as chamadas chegam em pedaços: junta pelo índice
680
+ atual = chamadas.setdefault(c.get("index", 0), {"function": {"name": "", "arguments": ""}})
681
+ f = c.get("function") or {}
682
+ atual["function"]["name"] += f.get("name") or ""
683
+ atual["function"]["arguments"] += f.get("arguments") or ""
684
+ logprobs += [{"token": x.get("token"), "logprob": x.get("logprob")} for x in (escolha.get("logprobs") or {}).get("content") or []]
685
+ texto = delta.get("content") or ""
686
+ pensado = delta.get("reasoning_content") or ""
687
+ if texto or pensado:
688
+ mensagem = {"role": "assistant", "content": texto}
689
+ if pensado:
690
+ mensagem["thinking"] = pensado
691
+ self._linha({"model": nome, "message": mensagem, "done": False})
692
+ inst.ultimo = time.time()
693
+ final = {"role": "assistant", "content": ""}
694
+ if chamadas:
695
+ final["tool_calls"] = _chamadas_ollama([chamadas[i] for i in sorted(chamadas)])
696
+ self._linha(_final(nome, inicio, final, motivo, logprobs or None))
697
+ self._fim_linhas()
698
+
699
+ def _puxar(self, nome, stream):
700
+ if nome not in CATALOGO:
701
+ return self._json({"error": f"pull model manifest: file does not exist ({nome})"}, 404)
702
+ ficha = CATALOGO[nome]
703
+ arquivos = [ficha["gguf"]] + ([ficha["mmproj"]] if ficha.get("mmproj") else [])
704
+ if stream:
705
+ self._linhas()
706
+ avisar = (lambda d: self._linha(d)) if stream else (lambda d: None)
707
+ try:
708
+ avisar({"status": "pulling manifest"})
709
+ if binario() is None:
710
+ avisar({"status": "downloading llama.cpp"})
711
+ instalar_binario(lambda f, t: None)
712
+ for arquivo, sha, tamanho in arquivos:
713
+ destino = pasta_modelos() / arquivo
714
+ if _atual(destino, sha):
715
+ avisar({"status": f"pulling {sha[:12]}", "digest": f"sha256:{sha}", "total": tamanho, "completed": tamanho})
716
+ continue
717
+ ultimo = [0.0]
718
+
719
+ def progresso(feito, total, sha=sha, tamanho=tamanho):
720
+ if time.time() - ultimo[0] > 0.5 or feito >= (total or tamanho):
721
+ ultimo[0] = time.time()
722
+ avisar({"status": f"pulling {sha[:12]}", "digest": f"sha256:{sha}", "total": total or tamanho, "completed": feito})
723
+ obter_arquivo(ficha["repo"], arquivo, sha, progresso)
724
+ avisar({"status": "verifying sha256 digest"})
725
+ avisar({"status": "success"})
726
+ except (OSError, ValueError) as e:
727
+ if stream:
728
+ avisar({"error": str(e)})
729
+ else:
730
+ return self._json({"error": str(e)}, 500)
731
+ if stream:
732
+ self._fim_linhas()
733
+ else:
734
+ self._json({"status": "success"})
735
+
736
+ class Servidor(ThreadingHTTPServer):
737
+ def server_bind(self): # o original chama socket.getfqdn(), que no macOS travou mais de 20 s (CI de 07/11)
738
+ import socketserver
739
+ socketserver.TCPServer.server_bind(self)
740
+ self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
741
+
742
+ servidor = Servidor(("127.0.0.1", PORTA), Pedido)
743
+ servidor.daemon_threads = True
744
+ print(f"motor escutando em {URL}", file=sys.stderr, flush=True)
745
+
746
+ def vigiar():
747
+ while True:
748
+ time.sleep(15)
749
+ motor.faxina()
750
+ threading.Thread(target=vigiar, daemon=True).start()
751
+ import signal
752
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) # sem isso o finally não roda e os llama-server ficam órfãos (visto em 13/11)
753
+ try:
754
+ servidor.serve_forever()
755
+ finally:
756
+ motor.parar_tudo()
757
+
758
+
759
+ # ---------------------------------------------------------------- o lado do app
760
+
761
+ def respondendo(tempo=1.0):
762
+ try:
763
+ with urllib.request.urlopen(f"{URL}/api/version", timeout=tempo) as r:
764
+ return r.status == 200
765
+ except (OSError, ValueError):
766
+ return False
767
+
768
+
769
+ def garantir(espera=15):
770
+ """Deixa o motor de pé (sobe em segundo plano se não estiver) e devolve o endereço, ou None se não deu."""
771
+ if respondendo():
772
+ return URL
773
+ if binario() is None: # sem modelo também sobe: o primeiro uso baixa os modelos pelo /api/pull dele
774
+ return None
775
+ extra = {"creationflags": 0x08000000 | 0x00000200} if plataforma.sistema() == "windows" else {"start_new_session": True}
776
+ pasta().mkdir(parents=True, exist_ok=True)
777
+ with open(pasta() / "motor.log", "ab") as registro:
778
+ subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "--servir"], stdin=subprocess.DEVNULL, stdout=registro, stderr=registro,
779
+ cwd=str(Path(__file__).resolve().parent), **extra)
780
+ limite = time.time() + espera
781
+ while time.time() < limite:
782
+ if respondendo(0.5):
783
+ return URL
784
+ time.sleep(0.2)
785
+ return None
786
+
787
+
788
+ if __name__ == "__main__":
789
+ if "--servir" in sys.argv:
790
+ if os.environ.get("TARS_MOTOR_DEPURAR"): # o CI do macOS: se travar, a pilha vai para o motor.log
791
+ import faulthandler
792
+ faulthandler.dump_traceback_later(20, exit=False)
793
+ servir()
794
+ elif "--instalar" in sys.argv: # baixa o llama-server e os modelos pedidos (ou o 2B e o Lite)
795
+ nomes = [a for a in sys.argv[2:] if not a.startswith("-")] or ["ProjectOpenTARS/Gargantua-2.5:lite", "ProjectOpenTARS/Gargantua-2.5:latest"]
796
+ preparar(nomes)
797
+ print("pronto:", instalados())