Erinaldordso commited on
Commit
69f9cd1
·
verified ·
1 Parent(s): eee14a2

Route Naldo brain through OpenCode free models

Browse files
README.md CHANGED
@@ -10,328 +10,56 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- # Programa — servidor Minecraft + Voyager
14
-
15
- ## Objetivo Ender Dragon e modelos separados
16
-
17
- Os dois runners usam o endpoint público do Space `Erinaldordso/Mine`.
18
- `GEMINI_BOT_BACKEND=mine` mantém o nome Gemini_Bot e seu checkpoint, mas
19
- substitui o cliente Gemini CLI pelo mesmo backend CPU do Naldo_Bot. O nome
20
- do jogador não identifica o provedor da IA. Nenhum modelo é carregado aqui.
21
-
22
- `MINECRAFT_GAME_GOAL` acrescenta um objetivo permanente aos prompts de Action
23
- e Curriculum. A IA continua escolhendo cada tarefa e gerando suas ações.
24
- O aprendizado e os checkpoints existentes são retomados. Não se chama
25
- `inference(task=...)`, pois o upstream limpa as listas do currículo nesse método.
26
-
27
- O mundo Creative atual é preservado. Avançar nele até o dragão é uma execução
28
- assistida por Creative, sem tempo de speedrun Survival comprovado.
29
-
30
- Cada runner inicia numa sessão de processos própria. Quando o watchdog sai,
31
- o supervisor encerra também seus filhos antes de reconectar; um Mineflayer
32
- órfão não pode bloquear as portas 3000/3001. O motor possui seu próprio helper
33
- de deadline, verificado executando a classe realmente produzida pelo patcher.
34
-
35
- O Mineflayer espaça mensagens e comandos em pelo menos 1,1 segundo por conexão.
36
- A recuperação em Creative mantém os itens existentes e seus dados NBT, repõe
37
- apenas itens ausentes pelo protocolo de inventário e não usa `/clear` ou `/kill`.
38
- Uma expulsão encerra imediatamente a requisição pendente; `/start` tem limite de
39
- 120 segundos e `/health` só indica pronto após completar a conexão e o preparo.
40
-
41
- Pesquisa de alternativas: AltoClef/MiranCZ tem tarefa de conclusão, porém é um
42
- mod de cliente e sua versão publicada 1.19.4 difere do servidor 1.19 atual.
43
- Mindcraft aceita endpoints OpenAI compatíveis e bots sem tela, mas sua
44
- documentação não comprova uma conclusão rápida com estes modelos locais.
45
- O projeto rmalde/minecraft-agent relata 8min43s em Java 1.16.5, usando modelos
46
- pagos e uma seed conhecida com portal ativo; esse resultado não é um benchmark
47
- deste Space. Não há substituição silenciosa por um desses motores.
48
-
49
- Este Space hospeda o **jogo**: servidor Minecraft Java 1.19, ViaProxy, o relay
50
- para Xbox Friends, o processo Mineflayer e os bots autônomos (Naldo_Bot e
51
- Gemini_Bot). Ele **não** roda mais inferência de linguagem.
52
-
53
- ## Onde fica o cérebro
54
-
55
- A inferência saiu deste Space. Rodar o modelo aqui disputava CPU e RAM com o
56
- servidor Java, o ViaProxy, o relay Go e dois bots — e o próprio modelo era o
57
- maior consumidor dos três (18,56 GB de pesos mais o KV cache). Agora quem serve
58
- é o Space separado [`Erinaldordso/Mine`](https://huggingface.co/spaces/Erinaldordso/Mine):
59
-
60
- - **Cérebro principal** — `qwen3-coder-30b-a3b-instruct` (Qwen3-Coder-30B-A3B,
61
- contexto 16384, quantização `UD-Q3_K_XL`), servindo Action, Curriculum e Critic.
62
- - **Cérebro auxiliar** — `granite-4.0-h-tiny` (contexto 8192), servindo o QA do
63
- Curriculum e o Skill Manager. Os dois papéis são curtos e frequentes; um
64
- segundo exemplar do modelo grande só somaria latência.
65
- - **Embeddings** — `Qwen/Qwen3-Embedding-0.6B-GGUF`, alias `text-embedding-ada-002`.
66
-
67
- Como os dois Spaces não compartilham rede, a URL do cérebro é a **pública**
68
- (`https://erinaldordso-mine.hf.space/v1`), nunca loopback.
69
-
70
- Os aliases antigos continuam aceitos apenas para compatibilidade de checkpoints
71
- e clientes: `granite-4.0-h-tiny` resolve para o modelo auxiliar e
72
- `voyager-qwen3-coder` para o principal.
73
-
74
- ### Brain lock
75
-
76
- `voyager_runner.py` recusa iniciar se a tupla
77
- `(perfil, base_url, modelo, modelo_auxiliar)` não for exatamente a esperada.
78
- Sem isso, um deploy poderia apontar silenciosamente para outro provedor. A
79
- checagem tem escape: definir `VOYAGER_BRAIN_LOCK=off` no Space pula o lock, o
80
- que permite subir o bot mesmo com o Mine fora do ar.
81
-
82
- ## Política de exploração do Action Agent
83
-
84
- O mundo é um **bolo de camadas** (`WORLD_FLAT_LAYERS` em `minecraft_stack.py`).
85
- Todo minério é uma **camada horizontal contínua numa altura fixa**, e o bot pisa
86
- na grama em `y=63`:
87
-
88
- | Camada | y | Distância da superfície |
89
- |---|---|---|
90
- | `emerald_ore` | 58 | 5 |
91
- | `lapis_ore` | 57 | 6 |
92
- | `diamond_ore` | 56 | 7 |
93
- | `redstone_ore` | 54-55 | 8-9 |
94
- | `gold_ore` | 52-53 | 10-11 |
95
- | `iron_ore` | 48-51 | 12-15 |
96
- | `copper_ore` | 44-47 | 16-19 |
97
- | `coal_ore` | 40-43 | 20-23 |
98
- | `deepslate_*_ore` | −57 até −42 | ~100 (repetição no fundo) |
99
-
100
- Entre a superfície e o minério mais fundo há **81 camadas de pedra**. Logo um
101
- minério que o bot enxerga está **sempre logo abaixo dele** — e andar na
102
- horizontal nunca muda o `y`, então um passeio horizontal é incapaz de alcançar
103
- uma camada que está de 5 a 23 blocos reto para baixo. As alturas exatas são
104
- afirmadas no Action prompt e conferidas contra o mundo por
105
- `tests/test_voyager_mineblock_depth.py`.
106
-
107
- Por isso `exploreUntil` tem dois modos, e o modo é escolhido pelo lugar onde o
108
- alvo realmente está:
109
-
110
- | Alvo | Direção | Comportamento |
111
- |---|---|---|
112
- | Bloco de superfície (tronco, grama, areia, bioma) | `y = 0` | Caminhada horizontal, como sempre |
113
- | Minério ou qualquer bloco subterrâneo | `y = -1` ou `"down"` | Escava uma escada na própria coluna, um bloco por vez |
114
-
115
- A descida é **controlada**: o bot mina o bloco sob os próprios pés em vez de
116
- pedir ao pathfinder um destino mais baixo, nunca cai, e para em 32 blocos de
117
- profundidade. Como ele permanece na própria coluna X/Z, cada camada minerada é
118
- uma célula por onde consegue voltar — e `allow1by1towers` fica ligado em
119
- Creative justamente para que ele consiga sair do próprio poço. O próprio
120
- `mineBlock` também desce até a camada quando ela está fora de alcance, então o
121
- modelo não precisa orquestrar a descida à mão.
122
-
123
- > Nota histórica: uma versão anterior forçava `dy = 0` para toda exploração em
124
- > Creative, para impedir que o `Vec3(0,-1,0)` do upstream derrubasse o bot de
125
- > uma beirada. Isso resolvia aquele problema e criava um pior: o Action prompt
126
- > mandava cavar para baixo e a primitiva reescrevia o pedido em um passeio
127
- > horizontal, então todo minério subterrâneo falhava. Medido no bot ao vivo:
128
- > 8 dos últimos 12 fracassos eram minérios subterrâneos (ferro, ouro, lápis,
129
- > redstone, cobre, diamante, esmeralda, quartzo), enquanto as tarefas que
130
- > concluíam eram todas alcançáveis na superfície. A guarda contra regressão
131
- > está em `tests/test_voyager_vertical_exploration.py`.
132
-
133
- > Nota histórica 2 — **o piso que se movia**. A primeira tentativa de deixar o
134
- > `mineBlock` cavar usou `candidate.position.y >= Math.floor(bot.y) - 2`. O
135
- > problema é que esse piso era medido **a partir do bot**, então descia junto
136
- > com ele: em `y=63` o filtro era `y >= 61` e recusava o ferro em `y=48`, que
137
- > estava visível no raio de 32. O bot lançava `use exploreUntil to descend`, o
138
- > modelo descia um bloco, e o piso descia um bloco junto — a janela recuava na
139
- > mesma velocidade que o bot. **Por construção o minério era inalcançável.**
140
- > Sintoma: `completed_count` parado em 145 por horas, 51 restarts do watchdog,
141
- > `mine.distinct_count = 2` (só `diamond_pickaxe` e `obsidian`, nenhum minério
142
- > real) e `explore.distinct_count = 2` (só horizontal). A correção troca o piso
143
- > relativo por um **orçamento de profundidade absoluto** (`MAX_DIG_DEPTH = 28`)
144
- > e faz a primitiva escavar uma escada até a camada. Guarda em
145
- > `tests/test_voyager_mineblock_depth.py`, que lê as alturas do próprio
146
- > `minecraft_stack.py` para que o prompt não possa divergir do mundo.
147
-
148
- > Nota histórica 3 — **o boot que nunca terminava**. Depois de corrigir o piso,
149
- > o `completed_count` continuou parado em 145. A pista decisiva foi o log: ele
150
- > terminava **exatamente** em `Loading Skill Manager from ...`, sem uma linha
151
- > depois, com o processo vivo e `restart_count` subindo a cada ~25 min.
152
- >
153
- > A causa: `SkillManager.__init__` construía `Chroma(...)` **sem deadline
154
- > nenhum**, com `embedding_function=OpenAIEmbeddings()`. No `langchain==0.0.350`
155
- > esse cliente é o `openai==0.28.1`, cujo `Embedding.create` **não tem timeout
156
- > default**. Como a base URL passou a ser o cérebro remoto, uma resposta travada
157
- > de `/v1/embeddings` bloqueava o construtor para sempre — e como o construtor
158
- > vem antes de `learn()`, o loop nunca chegava a propor tarefa: **nada falhava,
159
- > nada retentava, nenhum contador se movia.** O watchdog de estagnação, que
160
- > dispara a cada 900 s, parecia a causa e era o sintoma.
161
- >
162
- > O mesmo construtor terminava com um `assert` de sincronia. Um índice uma
163
- > unidade fora de sincronia é **recuperável** — `skills.json` é a fonte da
164
- > verdade e já traz código e descrição de cada skill —, mas o `assert`
165
- > transformava isso num processo morto, exatamente o passo manual que a própria
166
- > mensagem de erro mandava fazer. Agora o `__init__` reindexa o que falta e
167
- > remove o que sobra.
168
- >
169
- > A correção: abertura sob deadline (`VECTORDB_OPEN_SECONDS = 180`) e timeout
170
- > explícito na função de embedding (`EMBED_DEADLINE_SECONDS = 60`), de modo que
171
- > uma dependência lenta vire exceção retentável em vez de bloqueio. Guarda em
172
- > `tests/test_voyager_skillmanager_bounds.py`.
173
- >
174
- > **Lição de observabilidade:** diagnosticar isso de fora era impossível.
175
- > `/voyager/status` remove `log_tail` de propósito, e o stream de log da
176
- > Hugging Face servia uma janela ~35 h atrasada (terminava em
177
- > `2026-09-29T00:24Z` com o boot atual em `2026-09-30T11:34Z`), então um runner
178
- > travado e um saudável ficavam indistinguíveis. O `GET /voyager/log` lê o mesmo
179
- > arquivo do status, sob demanda, com `?lines=` e `?find=`; a guarda está em
180
- > `tests/test_voyager_log_observability.py`.
181
-
182
- > Nota histórica 4 — **o mesmo bug, uma camada acima**. Com o construtor do
183
- > `SkillManager` liberado, o loop finalmente chegou ao modelo (uma rodada real
184
- > de `Curriculum Agent Question` / `Answer` aparece no log) e morreu assim no
185
- > ciclo seguinte:
186
- >
187
- > ```
188
- > [voyager] learning loop raised AssertionError: ; keeping the Minecraft
189
- > connection and retrying in 120s (attempt 4)
190
- > ```
191
- >
192
- > A mensagem **vazia** é a assinatura: `AssertionError` sem texto só vem de um
193
- > `assert` puro. A linha exata estava em `run_qa`:
194
- >
195
- > ```python
196
- > if docs_and_scores and docs_and_scores[0][1] < 0.05:
197
- > question_cached = docs_and_scores[0][0].page_content
198
- > assert question_cached in self.qa_cache # <-- sem mensagem
199
- > ```
200
- >
201
- > O vetor de `qa_cache` e o `qa_cache.json` divergem assim que um dos dois é
202
- > reconstruído ou escrito pela metade, e então a busca por similaridade devolve
203
- > uma pergunta que o JSON não tem mais. O `assert` puro **escapava de `learn()`**
204
- > — o `try` interno só envolve `rollout()` — e chegava ao runner, que retentava
205
- > a cada 120 s. Novamente: erro a cada ciclo, nenhum avanço, `completed_count`
206
- > parado.
207
- >
208
- > Um acerto de cache que não existe no JSON **não é corrupção**, é um miss:
209
- > recalcula-se a resposta e segue. Agora o acerto ausente é descartado (com o
210
- > vetor obsoleto removido) e a busca tem deadline (`QA_LOOKUP_SECONDS = 45`),
211
- > porque `similarity_search` embeda a pergunta e essa chamada também não tinha
212
- > limite. `run_qa` não é um método de decisão atestado, então a mudança não
213
- > toca a atestação; `tests/test_voyager_qacache_selfheal.py` fixa as três
214
- > propriedades (sem `assert`, miss vira recálculo, busca limitada).
215
-
216
- > Nota histórica 5 — **o deadline que não valia nada**. Depois das três
217
- > correções de `assert`, o runner parou de reiniciar em laço, mas ficou **343 s
218
- > sem escrever uma linha** depois de `Loading Skill Manager`, com
219
- > `action_model_messages = 0` e `curriculum_model_messages = 0` — ou seja,
220
- > `learn()` nunca foi alcançado. O detalhe que denuncia o problema: **já
221
- > existia um deadline de 180 s** naquele caminho, e mesmo assim pendurou.
222
- >
223
- > A causa, reproduzida fora do Space:
224
- >
225
- > ```python
226
- > with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
227
- > future = pool.submit(chamada_que_trava)
228
- > try:
229
- > return future.result(timeout=180)
230
- > except concurrent.futures.TimeoutError:
231
- > raise RuntimeError("deadline hit")
232
- > ```
233
- >
234
- > `future.result(timeout=...)` levanta `TimeoutError` no prazo, sim. Mas sair do
235
- > bloco `with` chama `shutdown(wait=True)`, que faz **join no worker** — a mesma
236
- > chamada que está travada. O prazo dispara e o join bloqueia logo atrás, para
237
- > sempre. Saída do experimento real:
238
- >
239
- > ```
240
- > [2.0s] TimeoutError raised as expected
241
- > (nunca imprime "RuntimeError propagated")
242
- > (nunca imprime "reached end")
243
- > ```
244
- >
245
- > O `except` nunca roda, então o erro é indistinguível de um travamento. O
246
- > deadline era decorativo. Quatro pontos tinham exatamente essa forma:
247
- > `_open_vectordb`, `_open_qa_vectordb`, a busca do `qa_cache` e `_bounded_step`
248
- > (que embrulha o `env.step("")` do boot do `learn()` — o mais provável dos
249
- > quatro, por estar no caminho quente).
250
- >
251
- > A correção usa thread **daemon** e, no timeout, **abandona** a thread em vez
252
- > de esperá-la: `worker.join(seconds)`, `if worker.is_alive(): raise`. Uma
253
- > thread daemon não segura o interpretador vivo, então abandoná-la é seguro.
254
- > Levantar (em vez de degradar para um índice quebrado) é deliberado — o
255
- > supervisor do runner reinicia com backoff, então um endpoint de embedding
256
- > realmente inalcançável vira retentativa em vez de loop meio-funcional.
257
- > `tests/test_voyager_executor_deadline.py` fixa as propriedades **e roda a
258
- > prova viva do bug** num subprocesso, para que a regressão não possa voltar
259
- > silenciosamente.
260
-
261
- ## Só o servidor de Minecraft
262
-
263
- O cérebro mora no Space `Erinaldordso/Mine`. Este Space roda o servidor Java, a
264
- ponte Mineflayer, o runner do Voyager e o proxy do Gemini — e **nenhum peso de
265
- modelo**.
266
-
267
- Por que: o Qwen principal e o Granite auxiliar eram carregados aqui e ninguém
268
- os usava. O runner do Voyager define `OPENAI_API_BASE` para o endpoint do Mine,
269
- então tanto o chat quanto os embeddings dele já saíam deste Space; o único
270
- consumidor local restante era o embedding do Gemini_Bot. Manter dois GGUF
271
- residentes por causa disso consumia a RAM que o servidor Java precisa — e uma
272
- carga local que falhava aparecia como `aux_ready: false` em `/health`, o que se
273
- lê como "bot quebrado" quando na verdade é "carregador sem uso".
274
-
275
- `MINECRAFT_SERVER_ONLY=1` (default) faz cada ponto de entrada que poderia tocar
276
- um peso falhar com o host correto na mensagem, em vez de iniciar um download de
277
- 18 GB na primeira requisição:
278
-
279
- | Superfície | Comportamento |
280
- |---|---|
281
- | `_load_model`, `_load_aux_model`, `_load_embed_model` | `RuntimeError` nomeando `BRAIN_BASE_URL`, antes de qualquer `hf_hub_download`/`Llama(` |
282
- | `_startup` | não cria nenhuma das três threads de preload; servidor, Voyager e Gemini sobem normalmente |
283
- | `_select_chat_model` | recusa com redirect, em vez de tentar carregar |
284
- | `POST /v1/embeddings` | recusa **antes** de ler o corpo, para o erro ler como "host errado" e não "requisição inválida" |
285
- | `GET /health` | `server_only: true`, `local_models: false` e o mapa de papéis para o host real — sem `aux_ready`/`ready` |
286
- | `GET /status` | idem, mantendo as métricas de RAM/CPU, que continuam verdadeiras |
287
- | `GET /v1/models` | lista vazia + `brain_base_url`: anunciar um modelo que este Space recusa servir manda o cliente para o host errado com base numa descoberta bem-sucedida |
288
-
289
- Os embeddings do Gemini_Bot passam a apontar para o Mine
290
- (`GEMINI_VOYAGER_EMBED_BASE`). A troca é segura porque **os dois Spaces carregam
291
- o mesmo `Qwen/Qwen3-Embedding-0.6B-GGUF`** (alias `text-embedding-ada-002`) e os
292
- dois devolvem vetores de **1024 dimensões**, então nenhum índice Chroma
293
- existente muda de significado. `EMBED_BASE` é usado só para `/embeddings`, nunca
294
- para chat — a guarda em `tests/test_voyager_server_only.py` verifica isso, além
295
- de cada recusa e cada default que apontava para `127.0.0.1:7860`.
296
-
297
- ## Defesas contra o congelamento silencioso
298
-
299
- Depois da correção acima o bot passou a se mover, mas apareceu uma falha de
300
- outra natureza, sem nenhum sinal de erro: **o processo continuava vivo
301
- (`running=true`, `restart_count=0`), todos os contadores de erro em zero, e
302
- nenhuma chamada ao modelo em 45 minutos.** Não era lentidão nem falha — era um
303
- **bloqueio**. Um loop que falha deixa contadores; um bloqueio não deixa nada.
304
-
305
- A causa estava no caminho de recuperação: o upstream espera processos e responde
306
- a HTTP sem limite de tempo, e uma dependência travada transforma a própria
307
- recuperação na trava.
308
-
309
- | Onde | Antes | Agora |
310
- |---|---|---|
311
- | `SubprocessMonitor.stop()` | `self.process.wait()` sem limite — um node travado ignora SIGTERM e o wait nunca volta | `wait(timeout=10)`, depois `kill()` |
312
- | `SubprocessMonitor.run()` | `self.ready_event.wait()` sem limite — se o node nunca imprime a linha de ready, trava para sempre | `wait(timeout=60)`, então erro explícito |
313
- | `env.step("")` no início de `learn()`/`inference()` | sem limite | `_bounded_step("", timeout=120)` |
314
- | Recuperação de exceção no `voyager.py` | `env.step("")` sem limite no lugar do reset duro | mesmo helper, com `timeout=60`, caindo no reset duro |
315
- | Loop `learn()` do runner | retry infinito (`MAX_ATTEMPTS=0`); um bloqueio nunca é detectado | watchdog de estagnação: sem tarefa concluída em 15 min, o runner sai para o supervisor reiniciar |
316
-
317
- O `bridge.reset()` chama `mineflayer.stop()`, e o `learn()` começa por um
318
- `env.reset()` — ou seja, **a recuperação era exatamente o ponto de travamento**.
319
- O watchdog lê o progresso dos arquivos de checkpoint (não de uma linha de log,
320
- que pode não existir) e só arma depois da primeira tarefa concluída, então um
321
- boot frio nunca é interrompido. O limite é ajustável por
322
- `VOYAGER_STALL_SECONDS`.
323
-
324
- O watchdog observa **três** arquivos — `completed_tasks.json`,
325
- `failed_tasks.json` e `skill/skills.json` —, os mesmos que
326
- `voyager_learning._signature()` já usava para definir progresso. Vigiar só as
327
- tarefas concluídas o deixava cego para um bot que trabalha e falha: ele
328
- reciclava um runner no meio de uma tentativa. **Uma falha também é progresso**
329
- — o bot agiu e o Critic julgou —, então ela reinicia o relógio. Só um loop que
330
- não faz *nada* pela janela inteira é estagnação.
331
-
332
- A guarda contra regressão está em `tests/test_voyager_stall_contract.py`, que
333
- clona o upstream pinado, aplica o patcher e verifica o **código gerado** — não o
334
- texto do patch, que poderia virar um no-op silencioso.
335
 
336
  ## Embeddings
337
 
@@ -393,12 +121,11 @@ O endpoint mantém a compatibilidade HTTP de chat e embeddings para os clientes.
393
  ## Segundo jogador: Gemini_Bot
394
 
395
  `Gemini_Bot` executa uma segunda instância completa do Voyager no mesmo servidor
396
- Java 1.19, com processo Mineflayer e checkpoint próprios. O backend padrão
397
- atual é `GEMINI_BOT_BACKEND=mine`: ambos consultam Qwen para Action, Curriculum
398
- e Critic, Granite para QA e descrição de skills, e o embedding do Space Mine.
399
- `GEMINI_BOT_BACKEND=gemini` mantém disponível o proxy do Gemini CLI com as
400
- chaves privadas já configuradas. Ambos validam tarefas Creative pela mudança
401
- real no mundo ou pelo inventário.
402
 
403
  O Curriculum permanece no modo automático do Voyager: cada IA observa o estado
404
  atual, consulta seu checkpoint e decide o próximo objetivo. Não existe sequência
@@ -438,14 +165,18 @@ blocos, não teleporta e não altera a exploração horizontal do Curriculum.
438
  - Controle de inicialização: `GEMINI_BOT_AUTOSTART=0` desativa somente o
439
  segundo bot. O Naldo_Bot e o mundo persistente seguem independentes.
440
 
441
- Base URL:
442
 
443
  `https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1`
444
 
445
- Modelo de chat:
446
 
447
  `granite-4.0-h-tiny`
448
 
 
 
 
 
449
  Modelo de embeddings:
450
 
451
  `text-embedding-ada-002`
@@ -545,44 +276,3 @@ Validação offline, sem iniciar modelos ou servidor de jogo:
545
  O contrato do runtime (decisões upstream, adaptação declarada do retry do
546
  curriculum e patch aplicado sobre a árvore oficial) é verificado por
547
  `tests/test_voyager_official_contract.py` e `tests/test_voyager_patch_application.py`.
548
-
549
- ## Terceiro bot: AltoClef com OpenCode
550
-
551
- `OpenCode_Bot` usa **AltoClef 0.19 / Baritone**, do fork
552
- [MiranCZ/altoclef](https://github.com/MiranCZ/altoclef). O motor tem tarefas reais
553
- para encontrar o stronghold, abrir o portal, enfrentar o dragão e entrar no portal
554
- de retorno. É uma alternativa ao Voyager focada em concluir o jogo; não depende
555
- de gerar JavaScript para cada movimento. Os dois jogadores Voyager existentes
556
- mantêm seus checkpoints.
557
-
558
- O cliente Fabric **1.19.4** conecta ao servidor **1.19** pelo ViaProxy já existente
559
- em `127.0.0.1:25568`. Não há alteração da versão do mundo. Xvfb e Mesa llvmpipe
560
- renderizam o cliente em CPU, a 20 FPS, com heap de 2 GiB e dois processadores
561
- ativos. Os pesos de IA continuam exclusivamente no Space Mine.
562
-
563
- OpenCode **1.18.34** recebe observações reais para supervisionar o progresso.
564
- O provedor ativo é o endpoint Mine do usuário, sem API key, temperatura 0 e
565
- contexto compatível com Qwen (16K) e Granite (8K). Os modelos gratuitos do
566
- catálogo OpenCode apareceram na consulta, mas uma chamada genérica a
567
- `opencode/big-pickle` retornou HTTP 403. Eles não são o cérebro padrão e nenhuma
568
- disponibilidade contínua é presumida. Uma indisponibilidade do modelo não
569
- interrompe as tarefas determinísticas do AltoClef.
570
-
571
- O mundo atual continua **Creative / Peaceful**. O equipamento inicial ausente
572
- é fornecido somente ao novo jogador, sem limpar inventários. Isso é uma
573
- conclusão assistida em Creative, não um speedrun de Survival. Não são usados
574
- comandos para matar o dragão, construir um portal artificial ou teleportar o bot.
575
- `goal_completed` exige que o cliente tenha entrado no End e observado os créditos.
576
-
577
- Estado e logs: `/opencode-bot/status`, `/opencode-bot/log`. Dados persistentes:
578
- `/persist/minecraft-friends/altoclef/`; cliente, configuração e observações
579
- sobrevivem a reinícios. `ALTOCLEF_AUTOSTART=0` desativa o início automático.
580
- Falhas de inicialização têm backoff de 30 a 480 segundos. O adaptador Java
581
- usa as classes e os métodos conferidos no JAR AltoClef e nas mappings Yarn
582
- 1.19.4. Para recompilá-lo com Java 17: `python altoclef/build.py`.
583
-
584
- Revisão: Phoenix foi descartado porque seu pipeline `DefeatEnderDragon` usa
585
- `UnsupportedEndgameTask` e lança `NOT_IMPLEMENTED`. O AltoClef tem implementação
586
- de portal e combate; sua conclusão ainda precisa ser observada neste mundo.
587
- O toolkit dos Voyager inclui 64 cobblestones para a navegação subir torres quando
588
- os jogadores estão abaixo da superfície; em Creative, minerar não gera drops.
 
10
  pinned: false
11
  ---
12
 
13
+ # Programa — endpoint CPU para Voyager
14
+
15
+ Endpoint OpenAI-compatible dedicado ao projeto original MineDojo/Voyager.
16
+
17
+ ## Endpoint local de compatibilidade (Granite)
18
+
19
+ O endpoint OpenAI-compatible público continua disponível para clientes antigos e
20
+ para os componentes locais auxiliares. Ele não é mais o cérebro generativo do
21
+ `Naldo_Bot`; o Naldo usa OpenCode conforme a seção seguinte.
22
+
23
+ - GGUF oficial: `ibm-granite/granite-4.0-h-tiny-GGUF`
24
+ - Arquivo: `granite-4.0-h-tiny-Q4_K_M.gguf`
25
+ - Revisão fixada: `08d5a8a9741dd5c1a95d2d39e25253226aa1464e`
26
+ - Quantização: `Q4_K_M`
27
+ - Arquitetura: Granite 4.0 H-Tiny MoE/Mamba2, 7B totais e ~1B parâmetros ativos
28
+ - Contexto operacional do Voyager: 8.192 tokens
29
+ - Saída padrão: 384 tokens
30
+ - Saída máxima: 512 tokens
31
+ - Alias principal: `granite-4.0-h-tiny`
32
+ - CPU: 16 threads de decode e 16 threads de prefill/batch
33
+
34
+ Os aliases antigos permanecem aceitos apenas para compatibilidade de clientes e
35
+ continuam roteando para o Granite local. O Voyager do Naldo não usa esses aliases
36
+ para geração.
37
+
38
+ ## Cérebro do Naldo_Bot: OpenCode
39
+
40
+ Action, Curriculum, Critic e Skill Manager do `Naldo_Bot` usam uma ponte
41
+ OpenAI-compatible local em `127.0.0.1:7864`, que traduz as chamadas do Voyager
42
+ para um servidor `opencode serve` preso ao loopback em `127.0.0.1:7865`.
43
+
44
+ - OpenCode pinado: `opencode-ai@1.18.33`, instalado localmente no runtime sem `npm -g`.
45
+ - Provider: `opencode`.
46
+ - Modelo principal: `muse-spark-1.3-contributor-free`.
47
+ - Fallbacks: `fledge-alpha-free` e `nemotron-3-ultra-free`, somente do provider OpenCode gratuito.
48
+ - Nenhuma credencial do usuário é copiada; o processo recebe HOME/XDG próprios no runtime.
49
+ - As mensagens de inferência enviam `tools: {}`. No OpenCode 1.18.33 isso foi validado
50
+ sem execução de bash/arquivos; permissões customizadas de sessão não são usadas porque
51
+ o Zen gratuito as rejeita com `FreeTierError`.
52
+ - O bridge não inventa suporte a `temperature`/`max_tokens`: registra os valores pedidos
53
+ e preserva a resposta integral, evitando cortar JavaScript do Action Agent.
54
+ - Embeddings continuam locais em `127.0.0.1:7860/v1/embeddings`.
55
+ - Se OpenCode ou bridge morrerem, o supervisor interrompe o Naldo e recupera o cérebro
56
+ antes de religar o bot; não existe fallback generativo para Granite ou Gemini.
57
+
58
+ O Curriculum em Creative continua usando o estado real e os checkpoints persistentes.
59
+ A conclusão da tarefa continua exigindo verificação independente do mundo.
60
+
61
+ Referência do modelo:
62
+ https://huggingface.co/ibm-granite/granite-4.0-h-tiny-GGUF
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  ## Embeddings
65
 
 
121
  ## Segundo jogador: Gemini_Bot
122
 
123
  `Gemini_Bot` executa uma segunda instância completa do Voyager no mesmo servidor
124
+ Java 1.19, com processo Mineflayer e checkpoint próprios. Curriculum, Action,
125
+ Critic e Skill Manager consultam o Gemini CLI por um proxy local exclusivo;
126
+ somente os embeddings usam o modelo local. `Naldo_Bot` usa exclusivamente o
127
+ bridge OpenCode descrito acima para geração. Ambos validam tarefas Creative pela
128
+ mudança real no mundo ou pelo inventário.
 
129
 
130
  O Curriculum permanece no modo automático do Voyager: cada IA observa o estado
131
  atual, consulta seu checkpoint e decide o próximo objetivo. Não existe sequência
 
165
  - Controle de inicialização: `GEMINI_BOT_AUTOSTART=0` desativa somente o
166
  segundo bot. O Naldo_Bot e o mundo persistente seguem independentes.
167
 
168
+ API pública de compatibilidade:
169
 
170
  `https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1`
171
 
172
+ Modelo de chat público (não é o cérebro do Naldo):
173
 
174
  `granite-4.0-h-tiny`
175
 
176
+ Cérebro interno do Naldo:
177
+
178
+ `opencode/muse-spark-1.3-contributor-free` via `127.0.0.1:7864/v1`
179
+
180
  Modelo de embeddings:
181
 
182
  `text-embedding-ada-002`
 
276
  O contrato do runtime (decisões upstream, adaptação declarada do retry do
277
  curriculum e patch aplicado sobre a árvore oficial) é verificado por
278
  `tests/test_voyager_official_contract.py` e `tests/test_voyager_patch_application.py`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
gemini_bot_stack.py CHANGED
@@ -12,10 +12,9 @@ import urllib.request
12
  from pathlib import Path
13
  from typing import Any
14
 
15
- from voyager_stack import ROOT as VOYAGER_ROOT, SRC as VOYAGER_SRC, CKPT_DIR as NALDO_CKPT_DIR, VOYAGER, _wait_tcp
16
- from voyager_runtime_contract import UPSTREAM_COMMIT, read_attestation, read_exit_report
17
  from voyager_learning import read_learning_state
18
- from voyager_processes import stop_runner
19
 
20
  ROOT = Path(os.environ.get("GEMINI_BOT_RUNTIME_HOME", "/tmp/gemini-bot-runtime"))
21
  PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
@@ -39,7 +38,14 @@ def _node_version() -> int:
39
 
40
 
41
  def _terminate(proc: subprocess.Popen[str] | None, timeout: int = 15) -> None:
42
- stop_runner(proc, timeout=timeout)
 
 
 
 
 
 
 
43
 
44
 
45
  def _read_json(path: Path) -> list[str]:
@@ -52,7 +58,6 @@ def _read_json(path: Path) -> list[str]:
52
 
53
  class GeminiBotStack:
54
  def __init__(self) -> None:
55
- self.remote_mine = os.environ.get("GEMINI_BOT_BACKEND", "mine") == "mine"
56
  self.lock = threading.RLock()
57
  self.worker: threading.Thread | None = None
58
  self.proc: subprocess.Popen[str] | None = None
@@ -157,39 +162,10 @@ class GeminiBotStack:
157
  "GEMINI_BOT_SYSTEM_MD": str(workspace / ".gemini" / "system.md"),
158
  "GEMINI_VOYAGER_MODEL": MODEL,
159
  "GEMINI_VOYAGER_PROXY_PORT": str(PROXY_PORT),
160
- # Embeddings live in the Mine Space alongside the rest of the brain.
161
- # This Space no longer loads a local embedding model (see the
162
- # SERVER_ONLY note in app.py), so the old default of
163
- # http://127.0.0.1:7860/v1 would now answer 503. Mine serves the
164
- # same Qwen3-Embedding-0.6B alias and the same 1024-dim vectors, so
165
- # switching hosts cannot change any stored vector.
166
- "GEMINI_VOYAGER_EMBED_BASE": os.environ.get(
167
- "VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1"
168
- ),
169
  })
170
  return env
171
 
172
- def _mine_env(self) -> dict[str, str]:
173
- """No Gemini CLI or model weights are needed by the second runner."""
174
- ROOT.mkdir(parents=True, exist_ok=True)
175
- CKPT_DIR.mkdir(parents=True, exist_ok=True)
176
- node_modules = VOYAGER_SRC / "voyager" / "env" / "mineflayer" / "node_modules"
177
- marker = VOYAGER_ROOT / ".node-ready"
178
- deadline = time.monotonic() + 900
179
- while not self.stop_event.is_set() and (
180
- not marker.exists() or not (node_modules / "mineflayer").exists()
181
- ):
182
- if time.monotonic() > deadline:
183
- raise RuntimeError("Voyager Mineflayer dependencies did not become ready")
184
- self.stop_event.wait(2)
185
- if self.stop_event.is_set():
186
- raise RuntimeError("stopped")
187
- env = os.environ.copy()
188
- for name in (*KEY_NAMES, "GEMINI_API_KEY", "GOOGLE_API_KEY"):
189
- env.pop(name, None)
190
- env["NODE_PATH"] = str(node_modules)
191
- return env
192
-
193
  def _start_proxy(self, env: dict[str, str]) -> None:
194
  entrypoint = Path(__file__).resolve().parent / "gemini_voyager_proxy.py"
195
  PROXY_LOG_PATH.write_text("", encoding="utf-8")
@@ -201,7 +177,6 @@ class GeminiBotStack:
201
  stderr=subprocess.STDOUT,
202
  text=True,
203
  bufsize=1,
204
- start_new_session=(os.name == "posix"),
205
  )
206
  threading.Thread(
207
  target=self._pump,
@@ -240,15 +215,6 @@ class GeminiBotStack:
240
  "ANONYMIZED_TELEMETRY": "False",
241
  "PYTHONPATH": str(VOYAGER_SRC),
242
  })
243
- if self.remote_mine:
244
- from voyager_stack import BRAIN_BASE_URL, BRAIN_MODEL, BRAIN_AUX_MODEL
245
- voyager_env.update({
246
- "VOYAGER_OPENAI_BASE": BRAIN_BASE_URL,
247
- "VOYAGER_BRAIN_PROFILE": "gemini-mine-qwen3-coder",
248
- "VOYAGER_API_KEY": os.environ.get("VOYAGER_API_KEY", "local-voyager"),
249
- "VOYAGER_MODEL": BRAIN_MODEL,
250
- "VOYAGER_AUX_MODEL": BRAIN_AUX_MODEL,
251
- })
252
  LOG_PATH.write_text("", encoding="utf-8")
253
  self.proc = subprocess.Popen(
254
  [sys.executable, "-u", str(runner)],
@@ -258,7 +224,6 @@ class GeminiBotStack:
258
  stderr=subprocess.STDOUT,
259
  text=True,
260
  bufsize=1,
261
- start_new_session=(os.name == "posix"),
262
  )
263
  threading.Thread(
264
  target=self._pump,
@@ -288,7 +253,7 @@ class GeminiBotStack:
288
  if not ready_file.exists():
289
  raise RuntimeError("Minecraft world did not finish loading")
290
 
291
- if not self.remote_mine and not any(os.environ.get(name) for name in KEY_NAMES):
292
  with self.lock:
293
  self.state = "waiting-credentials"
294
  self.error = "Set GEMINI_BOT_KEY_1 as a Space secret"
@@ -296,19 +261,16 @@ class GeminiBotStack:
296
 
297
  with self.lock:
298
  self.state = "preparing"
299
- if self.remote_mine:
300
- env = self._mine_env()
301
- else:
302
- cli, workspace = self._prepare()
303
- env = self._child_env(cli, workspace)
304
  backoff = 5
305
 
306
  while not self.stop_event.is_set():
307
- if not self.remote_mine:
308
- self._start_proxy(env)
309
  self._start_voyager(env)
310
  time.sleep(3)
311
- if self.proc.poll() is None and (self.remote_mine or self.proxy_proc.poll() is None):
312
  with self.lock:
313
  self.state = "running"
314
  self.error = ""
@@ -316,7 +278,7 @@ class GeminiBotStack:
316
  while (
317
  not self.stop_event.is_set()
318
  and self.proc.poll() is None
319
- and (self.remote_mine or self.proxy_proc.poll() is None)
320
  ):
321
  self.stop_event.wait(1)
322
 
@@ -325,8 +287,8 @@ class GeminiBotStack:
325
 
326
  runner_rc = self.proc.returncode if self.proc else None
327
  proxy_rc = self.proxy_proc.returncode if self.proxy_proc else None
328
- stop_runner(self.proc)
329
- stop_runner(self.proxy_proc)
330
  self.restart_count += 1
331
  with self.lock:
332
  self.state = "restarting"
@@ -336,15 +298,15 @@ class GeminiBotStack:
336
  )
337
  if self.stop_event.wait(backoff):
338
  break
339
- backoff = min(backoff * 2, 120)
340
 
341
- stop_runner(self.proc)
342
- stop_runner(self.proxy_proc)
343
  with self.lock:
344
  self.state = "stopped"
345
  except Exception as exc:
346
- stop_runner(self.proc)
347
- stop_runner(self.proxy_proc)
348
  with self.lock:
349
  self.state = "error"
350
  self.error = str(exc)[:200]
@@ -385,8 +347,6 @@ class GeminiBotStack:
385
  return {"ready": False}
386
 
387
  def _proxy_status(self) -> dict[str, Any]:
388
- if self.remote_mine:
389
- return {"enabled": False, "reason": "all model roles use the Mine Space"}
390
  try:
391
  with urllib.request.urlopen(
392
  f"http://127.0.0.1:{PROXY_PORT}/health", timeout=1.5
@@ -399,10 +359,7 @@ class GeminiBotStack:
399
  def status(self) -> dict[str, Any]:
400
  completed = _read_json(CKPT_DIR / "curriculum" / "completed_tasks.json")
401
  failed = _read_json(CKPT_DIR / "curriculum" / "failed_tasks.json")
402
- try:
403
- lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
404
- except OSError:
405
- lines = []
406
  mineflayer = self._mineflayer_status()
407
  learning = read_learning_state(CKPT_DIR)
408
  proxy = self._proxy_status()
@@ -418,24 +375,20 @@ class GeminiBotStack:
418
  return {
419
  "state": self.state,
420
  "error": self.error or None,
421
- "running": runner_running and (self.remote_mine or proxy_running),
422
  "pid": proc.pid if runner_running else None,
423
  "proxy_pid": proxy_proc.pid if proxy_running else None,
424
  "restart_count": self.restart_count,
425
- "last_exit": read_exit_report(VOYAGER_ROOT, "Gemini_Bot"),
426
  "started_at": self.started_at,
427
  "architecture": "Voyager",
428
  "same_voyager_runtime_as_naldo": bool(engine and naldo_engine and engine.get("source_root") == naldo_engine.get("source_root") == str(VOYAGER_SRC.resolve()) and engine.get("upstream_commit") == naldo_engine.get("upstream_commit") == UPSTREAM_COMMIT),
429
  "engine_runtime": engine,
430
  "bot_username": "Gemini_Bot",
431
- "goal": os.environ.get("MINECRAFT_GAME_GOAL") or None,
432
- "model": os.environ.get("VOYAGER_BRAIN_MODEL", "qwen3-coder-30b-a3b-instruct") if self.remote_mine else MODEL,
433
- "aux_model": os.environ.get("VOYAGER_BRAIN_AUX_MODEL", "granite-4.0-h-tiny") if self.remote_mine else MODEL,
434
- "openai_base": os.environ.get("VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1") if self.remote_mine else f"http://127.0.0.1:{PROXY_PORT}/v1",
435
- "decision_brain": "remote-mine-space" if self.remote_mine else "gemini-cli-only",
436
- "brain_profile": "gemini-mine-qwen3-coder" if self.remote_mine else "gemini-cli",
437
- "embedding_backend": "remote-mine-space",
438
- "cli_version": None if self.remote_mine else CLI_VERSION,
439
  "configured_key_count": sum(
440
  bool(os.environ.get(name)) for name in KEY_NAMES
441
  ),
 
12
  from pathlib import Path
13
  from typing import Any
14
 
15
+ from voyager_stack import ROOT as VOYAGER_ROOT, SRC as VOYAGER_SRC, CKPT_DIR as NALDO_CKPT_DIR, VOYAGER, _wait_tcp, restart_backoff_after, tail_lines
16
+ from voyager_runtime_contract import UPSTREAM_COMMIT, read_attestation
17
  from voyager_learning import read_learning_state
 
18
 
19
  ROOT = Path(os.environ.get("GEMINI_BOT_RUNTIME_HOME", "/tmp/gemini-bot-runtime"))
20
  PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
 
38
 
39
 
40
  def _terminate(proc: subprocess.Popen[str] | None, timeout: int = 15) -> None:
41
+ if not proc or proc.poll() is not None:
42
+ return
43
+ proc.terminate()
44
+ try:
45
+ proc.wait(timeout=timeout)
46
+ except subprocess.TimeoutExpired:
47
+ proc.kill()
48
+ proc.wait(timeout=5)
49
 
50
 
51
  def _read_json(path: Path) -> list[str]:
 
58
 
59
  class GeminiBotStack:
60
  def __init__(self) -> None:
 
61
  self.lock = threading.RLock()
62
  self.worker: threading.Thread | None = None
63
  self.proc: subprocess.Popen[str] | None = None
 
162
  "GEMINI_BOT_SYSTEM_MD": str(workspace / ".gemini" / "system.md"),
163
  "GEMINI_VOYAGER_MODEL": MODEL,
164
  "GEMINI_VOYAGER_PROXY_PORT": str(PROXY_PORT),
165
+ "GEMINI_VOYAGER_EMBED_BASE": "http://127.0.0.1:7860/v1",
 
 
 
 
 
 
 
 
166
  })
167
  return env
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def _start_proxy(self, env: dict[str, str]) -> None:
170
  entrypoint = Path(__file__).resolve().parent / "gemini_voyager_proxy.py"
171
  PROXY_LOG_PATH.write_text("", encoding="utf-8")
 
177
  stderr=subprocess.STDOUT,
178
  text=True,
179
  bufsize=1,
 
180
  )
181
  threading.Thread(
182
  target=self._pump,
 
215
  "ANONYMIZED_TELEMETRY": "False",
216
  "PYTHONPATH": str(VOYAGER_SRC),
217
  })
 
 
 
 
 
 
 
 
 
218
  LOG_PATH.write_text("", encoding="utf-8")
219
  self.proc = subprocess.Popen(
220
  [sys.executable, "-u", str(runner)],
 
224
  stderr=subprocess.STDOUT,
225
  text=True,
226
  bufsize=1,
 
227
  )
228
  threading.Thread(
229
  target=self._pump,
 
253
  if not ready_file.exists():
254
  raise RuntimeError("Minecraft world did not finish loading")
255
 
256
+ if not any(os.environ.get(name) for name in KEY_NAMES):
257
  with self.lock:
258
  self.state = "waiting-credentials"
259
  self.error = "Set GEMINI_BOT_KEY_1 as a Space secret"
 
261
 
262
  with self.lock:
263
  self.state = "preparing"
264
+ cli, workspace = self._prepare()
265
+ env = self._child_env(cli, workspace)
 
 
 
266
  backoff = 5
267
 
268
  while not self.stop_event.is_set():
269
+ run_started = time.monotonic()
270
+ self._start_proxy(env)
271
  self._start_voyager(env)
272
  time.sleep(3)
273
+ if self.proc.poll() is None and self.proxy_proc.poll() is None:
274
  with self.lock:
275
  self.state = "running"
276
  self.error = ""
 
278
  while (
279
  not self.stop_event.is_set()
280
  and self.proc.poll() is None
281
+ and self.proxy_proc.poll() is None
282
  ):
283
  self.stop_event.wait(1)
284
 
 
287
 
288
  runner_rc = self.proc.returncode if self.proc else None
289
  proxy_rc = self.proxy_proc.returncode if self.proxy_proc else None
290
+ _terminate(self.proc)
291
+ _terminate(self.proxy_proc)
292
  self.restart_count += 1
293
  with self.lock:
294
  self.state = "restarting"
 
298
  )
299
  if self.stop_event.wait(backoff):
300
  break
301
+ backoff = restart_backoff_after(backoff, time.monotonic() - run_started)
302
 
303
+ _terminate(self.proc)
304
+ _terminate(self.proxy_proc)
305
  with self.lock:
306
  self.state = "stopped"
307
  except Exception as exc:
308
+ _terminate(self.proc)
309
+ _terminate(self.proxy_proc)
310
  with self.lock:
311
  self.state = "error"
312
  self.error = str(exc)[:200]
 
347
  return {"ready": False}
348
 
349
  def _proxy_status(self) -> dict[str, Any]:
 
 
350
  try:
351
  with urllib.request.urlopen(
352
  f"http://127.0.0.1:{PROXY_PORT}/health", timeout=1.5
 
359
  def status(self) -> dict[str, Any]:
360
  completed = _read_json(CKPT_DIR / "curriculum" / "completed_tasks.json")
361
  failed = _read_json(CKPT_DIR / "curriculum" / "failed_tasks.json")
362
+ lines = tail_lines(LOG_PATH)
 
 
 
363
  mineflayer = self._mineflayer_status()
364
  learning = read_learning_state(CKPT_DIR)
365
  proxy = self._proxy_status()
 
375
  return {
376
  "state": self.state,
377
  "error": self.error or None,
378
+ "running": runner_running and proxy_running,
379
  "pid": proc.pid if runner_running else None,
380
  "proxy_pid": proxy_proc.pid if proxy_running else None,
381
  "restart_count": self.restart_count,
 
382
  "started_at": self.started_at,
383
  "architecture": "Voyager",
384
  "same_voyager_runtime_as_naldo": bool(engine and naldo_engine and engine.get("source_root") == naldo_engine.get("source_root") == str(VOYAGER_SRC.resolve()) and engine.get("upstream_commit") == naldo_engine.get("upstream_commit") == UPSTREAM_COMMIT),
385
  "engine_runtime": engine,
386
  "bot_username": "Gemini_Bot",
387
+ "model": MODEL,
388
+ "decision_brain": "gemini-cli-only",
389
+ "brain_profile": "gemini-cli",
390
+ "embedding_backend": "local-embeddings-only",
391
+ "cli_version": CLI_VERSION,
 
 
 
392
  "configured_key_count": sum(
393
  bool(os.environ.get(name)) for name in KEY_NAMES
394
  ),
minecraft_stack.py CHANGED
@@ -1,221 +1,226 @@
1
- from __future__ import annotations
2
-
3
  import hashlib
4
  import uuid
5
  import json
6
  import datetime
7
- import os
8
- import re
9
- import shutil
10
- import socket
11
- import subprocess
12
- import tarfile
13
- import threading
14
- import tempfile
15
- import time
16
- import urllib.request
17
- from pathlib import Path
18
- from typing import Any
19
-
20
  import yaml
21
-
22
- BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
23
- PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
24
- JAVA_DIR = BASE / "java"
25
- VIA_DIR = BASE / "viaproxy"
26
- # Microsoft/Xbox OAuth cache must survive Space rebuilds/restarts. The HF Bucket
27
- # mounted at /persist is private and read-write; nether2rak refreshes token.json
28
- # in-place, so using this directory preserves both initial login and token rotation.
29
- RELAY_DIR = PERSIST_ROOT / "relay"
30
- WORLD_DIR = PERSIST_ROOT / "world-20260929-features-y64-v1"
31
- # Generator settings are only read when the world is created, so replacing the map
32
- # means pointing at a new directory. The previous world is kept so the human
33
- # player's Bedrock session can be carried over instead of being reset.
34
- PREVIOUS_WORLD_DIR = PERSIST_ROOT / "world-20260928-ores-y64-v1"
35
- WORLD_PROFILE = "minecraft:flat"
36
- WORLD_SURFACE_Y = 64
37
- # Resource-rich farming map, bottom to top. Two constraints fix this shape:
38
- # 1.18+ superflat stacks the layers upward from the -64 world floor, and Voyager's
39
- # placement, navigation and prompts are all pinned to a walkable surface at Y=64,
40
- # so the heights below must total exactly WORLD_FLOOR + WORLD_SURFACE_Y.
41
- # Every ore is duplicated: a shallow band 6-24 blocks under the topsoil that a
42
- # mining bot reaches from the surface, and a deepslate band at the bottom of the
43
- # column. Plains with no lakes and no features keeps the surface predictable for
44
- # the pathfinder and for players building on it.
45
- WORLD_FLAT_LAYERS = (
46
- ("minecraft:bedrock", 1),
47
- ("minecraft:deepslate", 6),
48
- ("minecraft:deepslate_coal_ore", 3),
49
- ("minecraft:deepslate_copper_ore", 3),
50
- ("minecraft:deepslate_iron_ore", 3),
51
- ("minecraft:deepslate_gold_ore", 2),
52
- ("minecraft:deepslate_redstone_ore", 2),
53
- ("minecraft:deepslate_diamond_ore", 1),
54
- ("minecraft:deepslate_lapis_ore", 1),
55
- ("minecraft:deepslate_emerald_ore", 1),
56
- ("minecraft:stone", 81),
57
- ("minecraft:coal_ore", 4),
58
- ("minecraft:copper_ore", 4),
59
- ("minecraft:iron_ore", 4),
60
- ("minecraft:gold_ore", 2),
61
- ("minecraft:redstone_ore", 2),
62
- ("minecraft:diamond_ore", 1),
63
- ("minecraft:lapis_ore", 1),
64
- ("minecraft:emerald_ore", 1),
65
- ("minecraft:gravel", 1),
66
- ("minecraft:dirt", 3),
67
- ("minecraft:grass_block", 1),
68
- )
69
- WORLD_FLOOR_Y = -64
70
- if sum(height for _, height in WORLD_FLAT_LAYERS) != WORLD_SURFACE_Y - WORLD_FLOOR_Y:
71
- raise RuntimeError(
72
- "World layers must total the distance from the world floor to WORLD_SURFACE_Y"
73
- )
74
- FLAT_GENERATOR_SETTINGS = json.dumps(
75
- {
76
- "biome": "minecraft:plains",
77
- "layers": [{"block": block, "height": height} for block, height in WORLD_FLAT_LAYERS],
78
- # Per minecraft.wiki/w/Superflat/Settings (Java 1.19+):
79
- # features - "whether or not to generate biome-specific placed
80
- # features". Without it a plains superflat has no
81
- # trees, grass or flowers, so the official curriculum
82
- # asks for wood it can never obtain.
83
- # lakes - only controls lava lakes, so it stays off.
84
- # structure_overrides - omitted on purpose: the documented default is
85
- # "all the structure sets". An empty list, which is
86
- # what this used to pass, disables every structure.
87
- "lakes": False,
88
- "features": True,
89
- },
90
- separators=(",", ":"),
91
  )
92
  BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
93
  ADMIN_USERNAME = "IffyBinkie6134"
94
  MAX_PLAYERS = 100
95
- TOOLS_DIR = BASE / "tools"
96
- BIN_DIR = BASE / "bin"
97
- LOG_PATH = BASE / "stack.log"
98
- READY_FILE = BASE / "minecraft.ready"
99
-
100
- FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
101
- FABRIC_MC_VERSION = "1.19"
102
- FABRIC_LOADER_VERSION = "0.14.18"
103
-
104
- # Official Voyager Fabric 1.19 server-side mod stack.
105
- FABRIC_API_URL = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0%2B1.19/fabric-api-0.58.0%2B1.19.jar"
106
- FABRIC_API_SHA256 = "0a3d2dba1ed45b002483ae7c08e2c0bcfcb8d2b166801a6c29d8ea64dba64370"
107
- COMPLETE_CONFIG_URL = "https://cdn.modrinth.com/data/GtqG8z1h/versions/2.0.0/completeconfig-2.0.0.jar"
108
- COMPLETE_CONFIG_SHA256 = "d34a3e167bf7942348d3f8c650fcefead6284878b39277ba43282ef69fa2d566"
109
- SERVER_PAUSE_URL = "https://mediafilez.forgecdn.net/files/3822/586/Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar"
110
-
111
- # Not part of the official Voyager mod set. Measured need: the Bedrock player
112
- # received ~100 "moved too quickly" corrections in under a minute, with a single
113
- # delta of -3556 blocks in x, so the server kept snapping their position back.
114
- # Geyser translates Bedrock movement to Java and the two disagree, which upstream
115
- # has not resolved (GeyserMC/Geyser#3407 is still open), and the Spigot/Paper
116
- # thresholds that issue mentions do not exist on a Fabric server. This mixin is
117
- # the server-side fix: it removes the vanilla speed check that produced the
118
- # corrections. Trade-off, stated plainly: it also stops the server rejecting an
119
- # actually-too-fast client, which is acceptable on a private world with friends.
120
- TOO_FAST_URL = "https://cdn.modrinth.com/data/w6JSkKSH/versions/ARqi4mgF/toofast-1.19-1.0.0.jar"
121
- TOO_FAST_SHA256 = "f703fed1f9be34ede7444069ec1a8ebcd50aeaac89ff507bfc98a062f435418c"
122
- SERVER_PAUSE_SHA256 = "4f92d4a4cef4627acbdfa1c601f0c58e392eee4159a69515fd0d622d3eeccd66"
123
- BETTER_RESPAWN_URL = "https://cdn.modrinth.com/data/ZUQINcGy/versions/fabric-1.19-2.0.0/better-respawn-fabric-1.19-2.0.0.jar"
124
- BETTER_RESPAWN_SHA256 = "9405e2ae53f031d5c32505dc3ee774c3f723c2d465230aaed7e6f7b3cdec0948"
125
-
126
- VIAPROXY_URL = "https://github.com/ViaVersion/ViaProxy/releases/download/v3.4.13/ViaProxy-3.4.13.jar"
127
- VIAPROXY_SHA256 = "4bbb6a6b9d3dd6a2028773ed44b97a98751e4a48e416aaad4bf6d9c860083be9"
128
-
129
- GEYSER_URL = "https://download.geysermc.org/v2/projects/geyser/versions/2.11.3/builds/1247/downloads/viaproxy"
130
- GEYSER_SHA256 = "85eea53e7fe58e4d97c2144a9d061577d37f619ab635227b68a948aa62d22924"
131
-
132
- GO_URL = "https://go.dev/dl/go1.25.14.linux-amd64.tar.gz"
133
- GO_SHA256 = "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712"
134
-
135
- JAVA_URL = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_x64_linux_hotspot_21.0.12.1_1.tar.gz"
136
- JAVA_SHA256 = "2413149700df0f7d440500a84a8f764c535f21e5a5e87d38328b64eec2c5b500"
137
- MC_JAVA_URL = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1%2B1/OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz"
138
- MC_JAVA_SHA256 = "0b2b640e3046b64c8ec504de0ab9d91bb5610182bda21fad454681ce54d45a62"
139
-
140
- BEDROCK_PROTOCOL = int(os.environ.get("BEDROCK_PROTOCOL", "2193"))
141
- BEDROCK_VERSION = os.environ.get("BEDROCK_VERSION", "1.26.51")
142
- WORLD_NAME = os.environ.get("MINECRAFT_WORLD_NAME", "Voyager AI World")
143
- HOST_NAME = os.environ.get("MINECRAFT_HOST_NAME", "Voyager AI")
144
-
145
-
146
- def _mkdirs() -> None:
147
- for path in (BASE, PERSIST_ROOT, JAVA_DIR, VIA_DIR, RELAY_DIR, WORLD_DIR, TOOLS_DIR, BIN_DIR):
148
- path.mkdir(parents=True, exist_ok=True)
149
-
150
-
151
- def _log(message: str) -> None:
152
- _mkdirs()
153
- line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
154
- print(line, flush=True)
155
- with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
156
- fh.write(line + "\n")
157
-
158
-
159
- def _sha256(path: Path) -> str:
160
- h = hashlib.sha256()
161
- with path.open("rb") as fh:
162
- for chunk in iter(lambda: fh.read(1024 * 1024), b""):
163
- h.update(chunk)
164
- return h.hexdigest()
165
-
166
-
167
- def _download(url: str, path: Path, sha256: str | None = None) -> Path:
168
- path.parent.mkdir(parents=True, exist_ok=True)
169
- if path.exists() and (not sha256 or _sha256(path) == sha256):
170
- return path
171
- tmp = path.with_suffix(path.suffix + ".part")
172
- tmp.unlink(missing_ok=True)
173
- req = urllib.request.Request(url, headers={"User-Agent": "minecraft-friends-ai-zerogpu/1.0"})
174
- _log(f"download {url}")
175
- with urllib.request.urlopen(req, timeout=120) as response, tmp.open("wb") as out:
176
- shutil.copyfileobj(response, out)
177
- if sha256 and _sha256(tmp) != sha256:
178
- tmp.unlink(missing_ok=True)
179
- raise RuntimeError(f"SHA256 mismatch for {url}")
180
- tmp.replace(path)
181
- return path
182
-
183
-
184
- def _wait_tcp(host: str, port: int, timeout: float) -> bool:
185
- deadline = time.time() + timeout
186
- while time.time() < deadline:
187
- try:
188
- with socket.create_connection((host, port), timeout=1):
189
- return True
190
- except OSError:
191
- time.sleep(1)
192
- return False
193
-
194
-
195
- def _run_capture(args: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> str:
196
- _log("exec " + " ".join(args))
197
- proc = subprocess.run(
198
- args,
199
- cwd=str(cwd),
200
- env=env,
201
- text=True,
202
- stdout=subprocess.PIPE,
203
- stderr=subprocess.STDOUT,
204
- timeout=timeout,
205
- check=False,
206
- )
207
- output = proc.stdout or ""
208
- for line in output.splitlines()[-80:]:
209
- _log("setup> " + line)
210
- if proc.returncode != 0:
211
- raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}")
212
- return output
213
-
214
-
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def _patch_scalar(text: str, key: str, value: str) -> str:
216
- pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
217
- if re.search(pattern, text):
218
- return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
219
  return text + f"\n{key}: {value}\n"
220
 
221
 
@@ -272,85 +277,43 @@ def _relay_diagnostics(lines: list[str]) -> dict[str, Any]:
272
  }
273
 
274
 
275
- RELAY_PING_PORT = 7777
276
- RELAY_PING_URL = f"http://127.0.0.1:{RELAY_PING_PORT}/ping"
277
-
278
-
279
- def _relay_player_latency(events) -> dict[str, Any]:
280
- """Report the joining player's real end-to-end RTT, as measured by the relay.
281
-
282
- nether2rak registers each connected player's client-leg and backend-leg
283
- rolling round-trip time and exposes the sum over a loopback-only HTTP API
284
- (bridge/pingserver.go). Nothing else measures what the player actually
285
- experiences, so publish it rather than guessing from CPU counters. The full
286
- XUID is a personal identifier and is deliberately not published.
287
- """
288
- player = xuid = None
289
- for line in events:
290
- if "player authenticated via xbox live" not in line:
291
- continue
292
- name = re.search(r"displayName=\"?([^\"\s]+)\"?", line)
293
- ident = re.search(r"xuid=(\d+)", line)
294
- if ident:
295
- player = name.group(1) if name else player
296
- xuid = ident.group(1)
297
- if not xuid:
298
- return {"available": False, "reason": "no authenticated player in the relay log yet"}
299
- try:
300
- with urllib.request.urlopen(f"{RELAY_PING_URL}?xuid={xuid}", timeout=3) as response:
301
- payload = json.loads(response.read().decode())
302
- except Exception as exc:
303
- return {
304
- "available": False,
305
- "player": player,
306
- "reason": f"{type(exc).__name__}: {exc}",
307
- }
308
- return {
309
- "available": True,
310
- "player": player,
311
- "xuid_suffix": xuid[-4:],
312
- "rtt_ms": int(payload.get("ms", 0)),
313
- "measured_by": "nether2rak client leg + backend leg, rolling average",
314
- }
315
-
316
-
317
- def _offline_player_uuid(username: str) -> str:
318
- return str(uuid.UUID(bytes=hashlib.md5(
319
- f"OfflinePlayer:{username}".encode("utf-8")
320
- ).digest(), version=3))
321
-
322
-
323
- def _carry_player_saves(previous: Path, current: Path) -> None:
324
- """Copy human player saves into a newly generated world.
325
-
326
- The Xbox OAuth token lives in RELAY_DIR and is never touched here, so a map
327
- change must not cost a player their session. Player saves are copied instead
328
- of the world itself: they only store a player's position, inventory and
329
- gamemode, which are valid in any world of the same dimension.
330
-
331
- The bot is deliberately excluded. A full bot inventory is what pins the
332
- official CurriculumAgent to its hardcoded full-inventory branch, so a fresh
333
- world has to start the bot from an empty save.
334
- """
335
- source = previous / "playerdata"
336
- if not source.is_dir():
337
- return
338
- destination = current / "playerdata"
339
- destination.mkdir(parents=True, exist_ok=True)
340
- bot_save = f"{_offline_player_uuid(BOT_USERNAME)}.dat"
341
- carried = []
342
- for save in sorted(source.glob("*.dat")):
343
- if save.name == bot_save:
344
- continue
345
- try:
346
- shutil.copy2(save, destination / save.name)
347
- except OSError as exc:
348
- _log(f"could not carry player save {save.name}: {exc}")
349
- continue
350
- carried.append(save.name)
351
- if carried:
352
- _log(f"Carried {len(carried)} player save(s) into the new world: {', '.join(carried)}")
353
- else:
354
  _log("No human player saves to carry into the new world")
355
 
356
 
@@ -362,31 +325,12 @@ def _server_performance(lines: list[str], now: datetime.datetime | None = None)
362
  tick_lag = [line for line in recent if "Can't keep up!" in line]
363
  delays = [int(match.group(1)) for line in tick_lag
364
  if (match := re.search(r"Running (\d+)ms or \d+ ticks behind", line))]
365
- movement = [line for line in recent
366
- if "moved too quickly" in line or "moved wrongly" in line]
367
- # The count alone cannot say whether this is one entity joining or the whole
368
- # server degrading. Measured: the counter went 0 -> 107 in under a minute and
369
- # then stopped, which is a burst, so the entity behind it has to be visible.
370
- # Deduplicate on the message, not the raw line: a burst carries a distinct
371
- # timestamp on every entry, so comparing raw lines would fill the payload with
372
- # five copies of one problem instead of showing five distinct ones.
373
- samples: list[str] = []
374
- seen: set[str] = set()
375
- for line in movement:
376
- trimmed = line.strip()[:200]
377
- key = re.sub(r"^\[[^\]]*\]\s*", "", trimmed)
378
- if key in seen:
379
- continue
380
- seen.add(key)
381
- samples.append(trimmed)
382
- if len(samples) >= 5:
383
- break
384
  return {
385
  "window_seconds": 300,
386
  "tick_lag_events": len(tick_lag),
387
  "max_reported_tick_delay_ms": max(delays, default=None),
388
- "movement_warnings": len(movement),
389
- "movement_warning_samples": samples,
390
  "last_tick_lag_at": tick_lag[-1][1:20] if tick_lag else None,
391
  }
392
 
@@ -427,163 +371,169 @@ def _ensure_admin_operator(path: Path, username: str) -> None:
427
  )]
428
  preserved.append(entry)
429
  path.write_text(json.dumps(preserved, indent=2) + "\n", encoding="utf-8")
430
-
431
-
432
- class MinecraftStack:
433
- def __init__(self) -> None:
434
- self.lock = threading.RLock()
435
- self.worker: threading.Thread | None = None
436
- self.processes: dict[str, subprocess.Popen[str]] = {}
437
- self.state = "stopped"
438
- self.error = ""
439
- self.started_at: float | None = None
440
- self.minecraft_ready = threading.Event()
441
-
442
- def _pump(self, name: str, proc: subprocess.Popen[str]) -> None:
443
- assert proc.stdout is not None
444
- try:
445
- for line in proc.stdout:
446
- clean = line.rstrip()
447
- _log(f"{name}> {clean}")
448
- if name == "minecraft" and "Done (" in clean:
449
- self.minecraft_ready.set()
450
- except Exception as exc:
451
- _log(f"{name} log pump error: {exc!r}")
452
-
453
- def _spawn(self, name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen[str]:
454
- _log(f"start {name}: {' '.join(args)}")
455
- proc = subprocess.Popen(
456
- args,
457
- cwd=str(cwd),
458
- env=env,
459
- stdin=subprocess.PIPE,
460
- stdout=subprocess.PIPE,
461
- stderr=subprocess.STDOUT,
462
- text=True,
463
- bufsize=1,
464
- )
465
- self.processes[name] = proc
466
- threading.Thread(target=self._pump, args=(name, proc), daemon=True, name=f"log-{name}").start()
467
- return proc
468
-
469
- def _stop_process(self, name: str) -> None:
470
- proc = self.processes.get(name)
471
- if not proc or proc.poll() is not None:
472
- return
473
- try:
474
- if name == "minecraft" and proc.stdin:
475
- proc.stdin.write("stop\n")
476
- proc.stdin.flush()
477
- proc.wait(timeout=20)
478
- return
479
- except Exception:
480
- pass
481
- try:
482
- proc.terminate()
483
- proc.wait(timeout=12)
484
- except Exception:
485
- try:
486
- proc.kill()
487
- except Exception:
488
- pass
489
-
490
- def _ensure_java(self) -> Path:
491
- root = TOOLS_DIR / "jre21"
492
- existing = list(root.glob("*/bin/java")) if root.exists() else []
493
- if existing:
494
- return existing[0]
495
- archive = _download(JAVA_URL, TOOLS_DIR / "temurin21-jre.tar.gz", JAVA_SHA256)
496
- if root.exists():
497
- shutil.rmtree(root)
498
- root.mkdir(parents=True, exist_ok=True)
499
- with tarfile.open(archive, "r:gz") as tf:
500
- tf.extractall(root)
501
- found = list(root.glob("*/bin/java"))
502
- if not found:
503
- raise RuntimeError("Temurin JRE 21 extraction failed")
504
- found[0].chmod(0o755)
505
- version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
506
- _log("portable Java ready: " + version.splitlines()[0])
507
- return found[0]
508
-
509
- def _ensure_minecraft_java(self) -> Path:
510
- root = TOOLS_DIR / "jre17"
511
- existing = list(root.glob("*/bin/java")) if root.exists() else []
512
- if existing:
513
- return existing[0]
514
- archive = _download(MC_JAVA_URL, TOOLS_DIR / "temurin17-jre.tar.gz", MC_JAVA_SHA256)
515
- if root.exists():
516
- shutil.rmtree(root)
517
- root.mkdir(parents=True, exist_ok=True)
518
- with tarfile.open(archive, "r:gz") as tf:
519
- tf.extractall(root)
520
- found = list(root.glob("*/bin/java"))
521
- if not found:
522
- raise RuntimeError("Temurin JRE 17 extraction failed")
523
- found[0].chmod(0o755)
524
- version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
525
- _log("Minecraft Java 17 ready: " + version.splitlines()[0])
526
- return found[0]
527
-
528
- def _ensure_persistent_world(self) -> None:
529
- WORLD_DIR.mkdir(parents=True, exist_ok=True)
530
- # level.dat only exists once the server has generated the world, so its
531
- # absence means this is the first boot of a new map.
532
- if not (WORLD_DIR / "level.dat").exists() and PREVIOUS_WORLD_DIR.is_dir():
533
- _carry_player_saves(PREVIOUS_WORLD_DIR, WORLD_DIR)
534
- # Probe the exact temporary-file + rename pattern used by player saves.
535
- # This never touches a player's .dat file or existing world data.
536
- player_dir = WORLD_DIR / "playerdata"
537
- probe = None
538
- target = player_dir / ".voyager-storage-probe"
539
- try:
540
- player_dir.mkdir(parents=True, exist_ok=True)
541
- marker = player_dir / ".keep"
542
- if not marker.exists():
543
- marker.write_text("Preserve playerdata directory in object storage.\n", encoding="utf-8")
544
- fd, filename = tempfile.mkstemp(prefix=".voyager-probe-", suffix=".dat", dir=player_dir)
545
- probe = Path(filename)
546
- with os.fdopen(fd, "wb") as handle:
547
- handle.write(b"voyager storage check")
548
- probe.replace(target)
549
- assert target.read_bytes() == b"voyager storage check"
550
- _log("Player storage probe: temporary create/write/rename/read succeeded")
551
- except Exception as exc:
552
- _log(f"PLAYER STORAGE PROBE FAILED: {type(exc).__name__}: {exc}")
553
- finally:
554
- for candidate in (probe, target):
555
- if candidate is not None:
556
- try:
557
- candidate.unlink(missing_ok=True)
558
- except OSError:
559
- pass
560
- WORLD_DIR.mkdir(parents=True, exist_ok=True)
561
- world_link = JAVA_DIR / "world"
562
- if world_link.is_symlink():
563
- try:
564
- if world_link.resolve() == WORLD_DIR.resolve():
565
- return
566
- except OSError:
567
- pass
568
- world_link.unlink(missing_ok=True)
569
- elif world_link.exists():
570
- if not any(WORLD_DIR.iterdir()):
571
- shutil.copytree(world_link, WORLD_DIR, dirs_exist_ok=True)
572
- _log("Migrated existing world into persistent storage")
573
- shutil.rmtree(world_link)
574
- world_link.symlink_to(WORLD_DIR, target_is_directory=True)
575
- _log(f"Persistent Minecraft world linked: {world_link} -> {WORLD_DIR}")
576
-
577
- def _ensure_voyager_mods(self) -> None:
578
- mods_dir = JAVA_DIR / "mods"
579
- mods_dir.mkdir(parents=True, exist_ok=True)
580
- mods = (
581
- ("fabric-api-0.58.0+1.19.jar", FABRIC_API_URL, FABRIC_API_SHA256),
582
- ("completeconfig-2.0.0.jar", COMPLETE_CONFIG_URL, COMPLETE_CONFIG_SHA256),
583
- ("Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar", SERVER_PAUSE_URL, SERVER_PAUSE_SHA256),
584
- # Local addition, not an upstream requirement: see TOO_FAST_URL.
585
- ("toofast-1.19-1.0.0.jar", TOO_FAST_URL, TOO_FAST_SHA256),
586
- )
 
 
 
 
 
 
587
  for filename, url, sha256 in mods:
588
  _download(url, mods_dir / filename, sha256)
589
 
@@ -597,279 +547,280 @@ class MinecraftStack:
597
  _log("Removed faulty Better Respawn 1.19 mod from Fabric mods directory")
598
  _log(
599
  "Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
600
- "CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0, "
601
- "TooFast 1.0.0 (local, removes the Bedrock movement corrections). "
602
  "Better Respawn 1.19-2.0.0 is disabled due to its player-save NPE. Mod Menu is client-only and is "
603
- "intentionally omitted on this headless dedicated server."
604
- )
605
-
606
- def _ensure_fabric(self) -> Path:
607
- java = self._ensure_minecraft_java()
608
- installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
609
- JAVA_DIR.mkdir(parents=True, exist_ok=True)
610
- launcher = JAVA_DIR / "fabric-server-launch.jar"
611
- if not launcher.exists():
612
- _run_capture(
613
- [
614
- str(java), "-jar", str(installer), "server",
615
- "-mcversion", FABRIC_MC_VERSION,
616
- "-loader", FABRIC_LOADER_VERSION,
617
- "-downloadMinecraft",
618
- "-dir", str(JAVA_DIR),
619
- ],
620
- JAVA_DIR,
621
- timeout=300,
622
- )
623
- self._ensure_voyager_mods()
624
- (JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
625
- props = {
626
- "server-ip": "127.0.0.1",
627
- "server-port": "25565",
628
- "online-mode": "false",
629
- "enforce-secure-profile": "false",
630
- "enable-command-block": "true",
631
- "spawn-protection": "0",
632
- "allow-flight": "true",
633
- "gamemode": "creative",
634
- "force-gamemode": "true",
635
  "difficulty": "peaceful",
636
  "max-players": str(MAX_PLAYERS),
637
  "op-permission-level": "4",
638
- "view-distance": "8",
639
- "simulation-distance": "6",
640
- "level-name": "world",
641
  "level-type": "minecraft:flat",
642
  "generator-settings": FLAT_GENERATOR_SETTINGS,
643
- "generate-structures": "true",
644
- "motd": "Voyager AI + Bedrock Friends",
645
- }
646
  (JAVA_DIR / "server.properties").write_text(
647
  "\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
648
  encoding="utf-8",
649
  )
650
  _ensure_admin_operator(JAVA_DIR / "ops.json", ADMIN_USERNAME)
651
  self._ensure_persistent_world()
652
- return launcher
653
-
654
- def _start_fabric(self) -> None:
655
- launcher = self._ensure_fabric()
656
- self.minecraft_ready.clear()
657
- READY_FILE.unlink(missing_ok=True)
658
- proc = self._spawn(
659
- "minecraft",
660
- [str(self._ensure_minecraft_java()), "-Xms1G", "-Xmx8G",
661
- "--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED",
662
- f"-javaagent:{Path(__file__).resolve().parent / 'diagnostics/player-save-diagnostics.jar'}",
663
- "-jar", str(launcher), "nogui"],
664
- JAVA_DIR,
665
- )
666
- if not _wait_tcp("127.0.0.1", 25565, 120):
667
- raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
668
- if not self.minecraft_ready.wait(timeout=180):
669
- raise RuntimeError("Minecraft Java opened 25565 but world did not finish loading")
670
- if proc.stdin:
671
- for cmd in (
672
- f"op {BOT_USERNAME}",
673
- f"gamemode creative {BOT_USERNAME}",
674
- "gamerule keepInventory true",
675
- "gamerule doDaylightCycle false",
676
- # The cycle is frozen, so set the time once: the world used to be
677
- # frozen at night, which is what the bot reported in every
678
- # observation and what a player saw on join.
679
- "time set day",
680
- "difficulty peaceful",
681
- ):
682
- proc.stdin.write(cmd + "\n")
683
- proc.stdin.flush()
684
- READY_FILE.write_text("ready\n", encoding="utf-8")
685
- _log("Minecraft Java 1.19/Fabric fully ready on 127.0.0.1:25565")
686
-
687
- def _ensure_viaproxy(self) -> tuple[Path, Path]:
688
- jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
689
- plugin_dir = VIA_DIR / "plugins"
690
- plugin_dir.mkdir(parents=True, exist_ok=True)
691
- _download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
692
- cfg = VIA_DIR / "viaproxy.yml"
693
- if not cfg.exists():
694
- proc = subprocess.Popen(
695
- [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)],
696
- cwd=str(VIA_DIR),
697
- stdout=subprocess.PIPE,
698
- stderr=subprocess.STDOUT,
699
- text=True,
700
- )
701
- deadline = time.time() + 25
702
- while time.time() < deadline and not cfg.exists() and proc.poll() is None:
703
- time.sleep(1)
704
- if proc.poll() is None:
705
- proc.terminate()
706
- try:
707
- proc.wait(timeout=8)
708
- except Exception:
709
- proc.kill()
710
- if not cfg.exists():
711
- raise RuntimeError("ViaProxy did not generate viaproxy.yml")
712
- text = cfg.read_text(encoding="utf-8", errors="replace")
713
- text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
714
- text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
715
- text = _patch_scalar(text, "proxy-online-mode", "false")
716
- text = _patch_scalar(text, "auth-method", "none")
717
- cfg.write_text(text, encoding="utf-8")
718
- return jar, cfg
719
-
720
- def _patch_geyser(self, path: Path) -> None:
721
- data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
722
- bedrock = data.setdefault("bedrock", {})
723
- bedrock["address"] = "127.0.0.1"
724
- bedrock["port"] = 19132
725
- bedrock["clone-remote-port"] = False
726
- java = data.setdefault("java", {})
727
- java["auth-type"] = "offline"
728
- advanced = data.setdefault("advanced", {})
729
- adv_bedrock = advanced.setdefault("bedrock", {})
730
- adv_bedrock["broadcast-port"] = 19132
731
- adv_bedrock["validate-bedrock-login"] = False
732
- data["debug-mode"] = False
733
- path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
734
- _log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
735
-
736
- def _start_viaproxy(self) -> None:
737
- jar, cfg = self._ensure_viaproxy()
738
- geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
739
- if not geyser_cfg.exists():
740
- proc = self._spawn("viaproxy-bootstrap", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
741
- deadline = time.time() + 35
742
- while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
743
- time.sleep(1)
744
- self._stop_process("viaproxy-bootstrap")
745
- if not geyser_cfg.exists():
746
- raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
747
- self._patch_geyser(geyser_cfg)
748
- proc = self._spawn("viaproxy", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
749
- time.sleep(12)
750
- if proc.poll() is not None:
751
- raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
752
- _log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
753
-
754
- def _ensure_go(self) -> Path:
755
- go = TOOLS_DIR / "go" / "bin" / "go"
756
- if go.exists():
757
- return go
758
- archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
759
- target = TOOLS_DIR / "go"
760
- if target.exists():
761
- shutil.rmtree(target)
762
- with tarfile.open(archive, "r:gz") as tf:
763
- tf.extractall(TOOLS_DIR)
764
- if not go.exists():
765
- raise RuntimeError("Go toolchain extraction failed")
766
- return go
767
-
768
- def _ensure_nether2rak(self) -> Path:
769
- binary = BIN_DIR / "nether2rak"
770
- if binary.exists():
771
- return binary
772
- go = self._ensure_go()
773
- src = Path("/app/third_party/nether2rak")
774
- if not src.exists():
775
- src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
776
- if not src.exists():
777
- raise RuntimeError("Vendored nether2rak source is missing")
778
- env = os.environ.copy()
779
- env.update(
780
- {
781
- "PATH": f"{go.parent}:{env.get('PATH', '')}",
782
- "GOTOOLCHAIN": "local",
783
- "GOCACHE": str(BASE / "go-cache"),
784
- "GOMODCACHE": str(BASE / "go-mod-cache"),
785
- "CGO_ENABLED": "0",
786
- }
787
- )
788
- _run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
789
- binary.chmod(0o755)
790
- return binary
791
-
792
- def _write_relay_config(self) -> Path:
793
- cfg = {
794
- "backend_transport": "raknet",
795
- "geyser_address": "127.0.0.1:19132",
796
- "nethernet_backend_address": "http://127.0.0.1:19134",
797
- "host_name": HOST_NAME,
798
- "world_name": WORLD_NAME,
799
- "max_players": MAX_PLAYERS,
800
- "fake_player_count": 0,
801
- "protocol": BEDROCK_PROTOCOL,
802
- "version": BEDROCK_VERSION,
803
- "update_interval_seconds": 30,
804
- "allowed_xuids": [],
805
- "compression": "snappy",
806
- "compression_threshold": 256,
807
- "fix_native_bds_persistence": False,
808
- "disable_client_encryption": False,
809
- "relay_direct_ip": False,
810
- "relay_direct_ip_listen_address": ":19134",
811
- "invite_port": 7782,
812
- "ping_port": 7777,
813
- "pprof_port": 6060,
814
- "extra_broadcasts": [],
815
- }
816
- path = RELAY_DIR / "config.json"
817
- path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
818
- return path
819
-
820
- def _start_relay(self) -> None:
821
- binary = self._ensure_nether2rak()
822
- self._write_relay_config()
823
- proc = self._spawn("friends", [str(binary)], RELAY_DIR)
824
- time.sleep(4)
825
- if proc.poll() is not None:
826
- raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
827
- _log("Xbox Friends relay started; Microsoft device-code login may be required")
828
-
829
- def _boot(self) -> None:
830
  with self.lock:
831
- self.state = "starting"
832
- self.error = ""
833
- self.started_at = time.time()
834
- try:
835
- _mkdirs()
836
- _log("=== Minecraft stack bootstrap ===")
837
- self._start_fabric()
838
- self._start_viaproxy()
839
- self._start_relay()
840
- with self.lock:
841
- self.state = "running"
842
- _log("=== Minecraft stack running ===")
843
- except Exception as exc:
844
- with self.lock:
845
- self.state = "error"
846
- self.error = repr(exc)
847
- _log(f"STACK ERROR: {exc!r}")
848
-
849
- def start_async(self) -> dict[str, Any]:
850
- with self.lock:
851
- if self.worker and self.worker.is_alive():
852
- return self.status()
853
- if any(p.poll() is None for p in self.processes.values()) and self.state == "running":
854
- return self.status()
855
- self.worker = threading.Thread(target=self._boot, daemon=True, name="minecraft-stack")
856
- self.worker.start()
857
- return self.status()
858
-
859
- def stop(self) -> dict[str, Any]:
860
- with self.lock:
861
- for name in ("friends", "viaproxy", "viaproxy-bootstrap", "minecraft"):
862
- self._stop_process(name)
863
- self.state = "stopped"
864
- _log("Minecraft stack stopped")
865
- return self.status()
866
-
867
- def tail(self, lines: int = 160) -> str:
868
- if not LOG_PATH.exists():
869
- return "Sem logs do Minecraft ainda."
870
- items = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
871
- return "\n".join(items[-max(10, min(lines, 500)):])
872
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  def status(self) -> dict[str, Any]:
874
  with self.lock:
875
  processes = {
@@ -879,14 +830,10 @@ class MinecraftStack:
879
  for name, process in processes.items():
880
  if process["running"]:
881
  process["rss_gib"] = _process_rss_gib(process["pid"])
882
- try:
883
- all_lines = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()
884
- boot = next((i for i in range(len(all_lines) - 1, -1, -1)
885
- if "=== Minecraft stack bootstrap ===" in all_lines[i]), 0)
886
- server_lines = [line for line in all_lines[boot:] if "minecraft>" in line]
887
- except OSError:
888
- all_lines = []
889
- server_lines = []
890
  log = "\n".join(all_lines[-120:]) if all_lines else "Sem logs do Minecraft ainda."
891
  relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
892
  access_state = _access_state(self.state, self.started_at, relay_health)
@@ -921,57 +868,55 @@ class MinecraftStack:
921
  player_stat = None
922
  player_save = {
923
  "better_respawn_jar_present": (JAVA_DIR / "mods" / "better-respawn-fabric-1.19-2.0.0.jar").exists(),
924
- "failed_saves_since_boot": sum("Failed to save player data" in line for line in server_lines),
925
- "respawn_npe_since_boot": sum("respawnDimension" in line and "null" in line
926
- for line in server_lines),
927
  "player_file_exists": player_stat is not None,
928
  "player_file_size": player_stat.st_size if player_stat else None,
929
  "player_file_mtime": player_stat.st_mtime if player_stat else None,
930
  }
931
- login_url = None
932
- device_code = None
933
- url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
934
- if url_match:
935
- login_url = url_match[-1]
936
- for pattern in (
937
- r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
938
- r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
939
- r"using\s+the\s+code\s+([A-Z0-9-]{6,16})",
940
- ):
941
- found = re.findall(pattern, log, flags=re.I)
942
- if found:
943
- device_code = found[-1].upper()
944
- break
945
- return {
946
- "state": self.state,
947
- "error": self.error or None,
948
- "started_at": self.started_at,
949
- "java_server": "127.0.0.1:25565",
950
- "bedrock_backend": "127.0.0.1:19132",
951
- "access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
952
- "auth_persistence": str(RELAY_DIR / "token.json"),
953
- "auth_token_cached": (RELAY_DIR / "token.json").exists(),
954
- "persistent_root": str(PERSIST_ROOT),
955
  "world_persistence": str(WORLD_DIR),
956
  "world_profile": WORLD_PROFILE,
957
  "world_surface_y": WORLD_SURFACE_Y,
958
  "player_save": player_save,
959
  "relay_health": relay_health,
960
- "relay_latency": _relay_player_latency(all_lines[boot:] if all_lines else []),
961
  "access_state": access_state,
962
  "performance": performance,
963
  "player_limit": {"java": java_player_limit, "xbox_friends": xbox_player_limit},
964
  "admin_operator": admin_operator,
965
  "admin_player_file_exists": admin_player_file_exists,
966
- "bot_username": BOT_USERNAME,
967
- "bot_display_name": "Naldo Bot",
968
- "bedrock_protocol": BEDROCK_PROTOCOL,
969
- "bedrock_version": BEDROCK_VERSION,
970
- "microsoft_login_url": login_url,
971
- "microsoft_device_code": device_code,
972
- "processes": processes,
973
- "log_tail": log,
974
- }
975
-
976
-
977
- STACK = MinecraftStack()
 
1
+ from __future__ import annotations
2
+
3
  import hashlib
4
  import uuid
5
  import json
6
  import datetime
7
+ import os
8
+ import re
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ import tarfile
13
+ import threading
14
+ import tempfile
15
+ import time
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
  import yaml
21
+
22
+ BASE = Path(os.environ.get("MINECRAFT_HOME", "/tmp/minecraft-stack"))
23
+ PERSIST_ROOT = Path(os.environ.get("MINECRAFT_PERSIST_HOME", "/persist/minecraft-friends"))
24
+ JAVA_DIR = BASE / "java"
25
+ VIA_DIR = BASE / "viaproxy"
26
+ # Microsoft/Xbox OAuth cache must survive Space rebuilds/restarts. The HF Bucket
27
+ # mounted at /persist is private and read-write; nether2rak refreshes token.json
28
+ # in-place, so using this directory preserves both initial login and token rotation.
29
+ RELAY_DIR = PERSIST_ROOT / "relay"
30
+ WORLD_DIR = PERSIST_ROOT / "world-20260928-ores-y64-v1"
31
+ # Generator settings are only read when the world is created, so replacing the map
32
+ # means pointing at a new directory. The previous world is kept so the human
33
+ # player's Bedrock session can be carried over instead of being reset.
34
+ PREVIOUS_WORLD_DIR = PERSIST_ROOT / "world-20260927-flat-y64-v1"
35
+ WORLD_PROFILE = "minecraft:flat"
36
+ WORLD_SURFACE_Y = 64
37
+ # Resource-rich farming map, bottom to top. Two constraints fix this shape:
38
+ # 1.18+ superflat stacks the layers upward from the -64 world floor, and Voyager's
39
+ # placement, navigation and prompts are all pinned to a walkable surface at Y=64,
40
+ # so the heights below must total exactly WORLD_FLOOR + WORLD_SURFACE_Y.
41
+ # Every ore is duplicated: a shallow band 6-24 blocks under the topsoil that a
42
+ # mining bot reaches from the surface, and a deepslate band at the bottom of the
43
+ # column. Plains with no lakes and no features keeps the surface predictable for
44
+ # the pathfinder and for players building on it.
45
+ WORLD_FLAT_LAYERS = (
46
+ ("minecraft:bedrock", 1),
47
+ ("minecraft:deepslate", 6),
48
+ ("minecraft:deepslate_coal_ore", 3),
49
+ ("minecraft:deepslate_copper_ore", 3),
50
+ ("minecraft:deepslate_iron_ore", 3),
51
+ ("minecraft:deepslate_gold_ore", 2),
52
+ ("minecraft:deepslate_redstone_ore", 2),
53
+ ("minecraft:deepslate_diamond_ore", 1),
54
+ ("minecraft:deepslate_lapis_ore", 1),
55
+ ("minecraft:deepslate_emerald_ore", 1),
56
+ ("minecraft:stone", 81),
57
+ ("minecraft:coal_ore", 4),
58
+ ("minecraft:copper_ore", 4),
59
+ ("minecraft:iron_ore", 4),
60
+ ("minecraft:gold_ore", 2),
61
+ ("minecraft:redstone_ore", 2),
62
+ ("minecraft:diamond_ore", 1),
63
+ ("minecraft:lapis_ore", 1),
64
+ ("minecraft:emerald_ore", 1),
65
+ ("minecraft:gravel", 1),
66
+ ("minecraft:dirt", 3),
67
+ ("minecraft:grass_block", 1),
68
+ )
69
+ WORLD_FLOOR_Y = -64
70
+ if sum(height for _, height in WORLD_FLAT_LAYERS) != WORLD_SURFACE_Y - WORLD_FLOOR_Y:
71
+ raise RuntimeError(
72
+ "World layers must total the distance from the world floor to WORLD_SURFACE_Y"
73
+ )
74
+ FLAT_GENERATOR_SETTINGS = json.dumps(
75
+ {
76
+ "biome": "minecraft:plains",
77
+ "layers": [{"block": block, "height": height} for block, height in WORLD_FLAT_LAYERS],
78
+ "lakes": False,
79
+ "features": False,
80
+ "structure_overrides": [],
81
+ },
82
+ separators=(",", ":"),
 
 
 
 
 
 
 
 
83
  )
84
  BOT_USERNAME = os.environ.get("MC_USERNAME", "Naldo_Bot")
85
  ADMIN_USERNAME = "IffyBinkie6134"
86
  MAX_PLAYERS = 100
87
+ TOOLS_DIR = BASE / "tools"
88
+ BIN_DIR = BASE / "bin"
89
+ LOG_PATH = BASE / "stack.log"
90
+ READY_FILE = BASE / "minecraft.ready"
91
+
92
+ FABRIC_INSTALLER_URL = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/0.11.2/fabric-installer-0.11.2.jar"
93
+ FABRIC_MC_VERSION = "1.19"
94
+ FABRIC_LOADER_VERSION = "0.14.18"
95
+
96
+ # Official Voyager Fabric 1.19 server-side mod stack.
97
+ FABRIC_API_URL = "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0%2B1.19/fabric-api-0.58.0%2B1.19.jar"
98
+ FABRIC_API_SHA256 = "0a3d2dba1ed45b002483ae7c08e2c0bcfcb8d2b166801a6c29d8ea64dba64370"
99
+ COMPLETE_CONFIG_URL = "https://cdn.modrinth.com/data/GtqG8z1h/versions/2.0.0/completeconfig-2.0.0.jar"
100
+ COMPLETE_CONFIG_SHA256 = "d34a3e167bf7942348d3f8c650fcefead6284878b39277ba43282ef69fa2d566"
101
+ SERVER_PAUSE_URL = "https://mediafilez.forgecdn.net/files/3822/586/Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar"
102
+ SERVER_PAUSE_SHA256 = "4f92d4a4cef4627acbdfa1c601f0c58e392eee4159a69515fd0d622d3eeccd66"
103
+ BETTER_RESPAWN_URL = "https://cdn.modrinth.com/data/ZUQINcGy/versions/fabric-1.19-2.0.0/better-respawn-fabric-1.19-2.0.0.jar"
104
+ BETTER_RESPAWN_SHA256 = "9405e2ae53f031d5c32505dc3ee774c3f723c2d465230aaed7e6f7b3cdec0948"
105
+
106
+ VIAPROXY_URL = "https://github.com/ViaVersion/ViaProxy/releases/download/v3.4.13/ViaProxy-3.4.13.jar"
107
+ VIAPROXY_SHA256 = "4bbb6a6b9d3dd6a2028773ed44b97a98751e4a48e416aaad4bf6d9c860083be9"
108
+
109
+ GEYSER_URL = "https://download.geysermc.org/v2/projects/geyser/versions/2.11.3/builds/1247/downloads/viaproxy"
110
+ GEYSER_SHA256 = "85eea53e7fe58e4d97c2144a9d061577d37f619ab635227b68a948aa62d22924"
111
+
112
+ GO_URL = "https://go.dev/dl/go1.25.14.linux-amd64.tar.gz"
113
+ GO_SHA256 = "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712"
114
+
115
+ JAVA_URL = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_x64_linux_hotspot_21.0.12.1_1.tar.gz"
116
+ JAVA_SHA256 = "2413149700df0f7d440500a84a8f764c535f21e5a5e87d38328b64eec2c5b500"
117
+ MC_JAVA_URL = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1%2B1/OpenJDK17U-jre_x64_linux_hotspot_17.0.20.1_1.tar.gz"
118
+ MC_JAVA_SHA256 = "0b2b640e3046b64c8ec504de0ab9d91bb5610182bda21fad454681ce54d45a62"
119
+
120
+ BEDROCK_PROTOCOL = int(os.environ.get("BEDROCK_PROTOCOL", "2193"))
121
+ BEDROCK_VERSION = os.environ.get("BEDROCK_VERSION", "1.26.51")
122
+ WORLD_NAME = os.environ.get("MINECRAFT_WORLD_NAME", "Voyager AI World")
123
+ HOST_NAME = os.environ.get("MINECRAFT_HOST_NAME", "Voyager AI")
124
+
125
+
126
+ def _mkdirs() -> None:
127
+ for path in (BASE, PERSIST_ROOT, JAVA_DIR, VIA_DIR, RELAY_DIR, WORLD_DIR, TOOLS_DIR, BIN_DIR):
128
+ path.mkdir(parents=True, exist_ok=True)
129
+
130
+
131
+ def _log(message: str) -> None:
132
+ _mkdirs()
133
+ line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
134
+ print(line, flush=True)
135
+ with LOG_PATH.open("a", encoding="utf-8", errors="replace") as fh:
136
+ fh.write(line + "\n")
137
+
138
+
139
+ def _sha256(path: Path) -> str:
140
+ h = hashlib.sha256()
141
+ with path.open("rb") as fh:
142
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
143
+ h.update(chunk)
144
+ return h.hexdigest()
145
+
146
+
147
+ def _download(url: str, path: Path, sha256: str | None = None) -> Path:
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ if path.exists() and (not sha256 or _sha256(path) == sha256):
150
+ return path
151
+ tmp = path.with_suffix(path.suffix + ".part")
152
+ tmp.unlink(missing_ok=True)
153
+ req = urllib.request.Request(url, headers={"User-Agent": "minecraft-friends-ai-zerogpu/1.0"})
154
+ _log(f"download {url}")
155
+ with urllib.request.urlopen(req, timeout=120) as response, tmp.open("wb") as out:
156
+ shutil.copyfileobj(response, out)
157
+ if sha256 and _sha256(tmp) != sha256:
158
+ tmp.unlink(missing_ok=True)
159
+ raise RuntimeError(f"SHA256 mismatch for {url}")
160
+ tmp.replace(path)
161
+ return path
162
+
163
+
164
+ def _wait_tcp(host: str, port: int, timeout: float) -> bool:
165
+ deadline = time.time() + timeout
166
+ while time.time() < deadline:
167
+ try:
168
+ with socket.create_connection((host, port), timeout=1):
169
+ return True
170
+ except OSError:
171
+ time.sleep(1)
172
+ return False
173
+
174
+
175
+ def _tail_lines(path: Path, max_lines: int = 5000) -> list[str]:
176
+ """Read at most max_lines trailing lines without loading a days-long log.
177
+
178
+ status() runs on every supervisor tick and UI click, so a full read of an
179
+ ever-growing stack.log would stall the watchdog it is meant to serve.
180
+ """
181
+ try:
182
+ with path.open("rb") as fh:
183
+ fh.seek(0, os.SEEK_END)
184
+ size = fh.tell()
185
+ chunk_size = 65536
186
+ data = b""
187
+ position = size
188
+ while position > 0 and data.count(b"\n") <= max_lines:
189
+ step = min(chunk_size, position)
190
+ position -= step
191
+ fh.seek(position)
192
+ data = fh.read(step) + data
193
+ if len(data) > (max_lines + 1) * 512:
194
+ break
195
+ return data.decode("utf-8", errors="replace").splitlines()[-max_lines:]
196
+ except OSError:
197
+ return []
198
+
199
+
200
+ def _run_capture(args: list[str], cwd: Path, timeout: int = 300, env: dict[str, str] | None = None) -> str:
201
+ _log("exec " + " ".join(args))
202
+ proc = subprocess.run(
203
+ args,
204
+ cwd=str(cwd),
205
+ env=env,
206
+ text=True,
207
+ stdout=subprocess.PIPE,
208
+ stderr=subprocess.STDOUT,
209
+ timeout=timeout,
210
+ check=False,
211
+ )
212
+ output = proc.stdout or ""
213
+ for line in output.splitlines()[-80:]:
214
+ _log("setup> " + line)
215
+ if proc.returncode != 0:
216
+ raise RuntimeError(f"command failed rc={proc.returncode}: {' '.join(args)}")
217
+ return output
218
+
219
+
220
  def _patch_scalar(text: str, key: str, value: str) -> str:
221
+ pattern = rf"(?m)^(\s*{re.escape(key)}:\s*).*$"
222
+ if re.search(pattern, text):
223
+ return re.sub(pattern, lambda m: m.group(1) + value, text, count=1)
224
  return text + f"\n{key}: {value}\n"
225
 
226
 
 
277
  }
278
 
279
 
280
+ def _offline_player_uuid(username: str) -> str:
281
+ return str(uuid.UUID(bytes=hashlib.md5(
282
+ f"OfflinePlayer:{username}".encode("utf-8")
283
+ ).digest(), version=3))
284
+
285
+
286
+ def _carry_player_saves(previous: Path, current: Path) -> None:
287
+ """Copy human player saves into a newly generated world.
288
+
289
+ The Xbox OAuth token lives in RELAY_DIR and is never touched here, so a map
290
+ change must not cost a player their session. Player saves are copied instead
291
+ of the world itself: they only store a player's position, inventory and
292
+ gamemode, which are valid in any world of the same dimension.
293
+
294
+ The bot is deliberately excluded. A full bot inventory is what pins the
295
+ official CurriculumAgent to its hardcoded full-inventory branch, so a fresh
296
+ world has to start the bot from an empty save.
297
+ """
298
+ source = previous / "playerdata"
299
+ if not source.is_dir():
300
+ return
301
+ destination = current / "playerdata"
302
+ destination.mkdir(parents=True, exist_ok=True)
303
+ bot_save = f"{_offline_player_uuid(BOT_USERNAME)}.dat"
304
+ carried = []
305
+ for save in sorted(source.glob("*.dat")):
306
+ if save.name == bot_save:
307
+ continue
308
+ try:
309
+ shutil.copy2(save, destination / save.name)
310
+ except OSError as exc:
311
+ _log(f"could not carry player save {save.name}: {exc}")
312
+ continue
313
+ carried.append(save.name)
314
+ if carried:
315
+ _log(f"Carried {len(carried)} player save(s) into the new world: {', '.join(carried)}")
316
+ else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  _log("No human player saves to carry into the new world")
318
 
319
 
 
325
  tick_lag = [line for line in recent if "Can't keep up!" in line]
326
  delays = [int(match.group(1)) for line in tick_lag
327
  if (match := re.search(r"Running (\d+)ms or \d+ ticks behind", line))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  return {
329
  "window_seconds": 300,
330
  "tick_lag_events": len(tick_lag),
331
  "max_reported_tick_delay_ms": max(delays, default=None),
332
+ "movement_warnings": sum("moved too quickly" in line or "moved wrongly" in line
333
+ for line in recent),
334
  "last_tick_lag_at": tick_lag[-1][1:20] if tick_lag else None,
335
  }
336
 
 
371
  )]
372
  preserved.append(entry)
373
  path.write_text(json.dumps(preserved, indent=2) + "\n", encoding="utf-8")
374
+
375
+
376
+ class MinecraftStack:
377
+ def __init__(self) -> None:
378
+ self.lock = threading.RLock()
379
+ self.worker: threading.Thread | None = None
380
+ self.processes: dict[str, subprocess.Popen[str]] = {}
381
+ self.state = "stopped"
382
+ self.error = ""
383
+ self.started_at: float | None = None
384
+ self.minecraft_ready = threading.Event()
385
+ self.failed_saves_since_boot = 0
386
+ self.respawn_npe_since_boot = 0
387
+
388
+ def _pump(self, name: str, proc: subprocess.Popen[str]) -> None:
389
+ assert proc.stdout is not None
390
+ try:
391
+ for line in proc.stdout:
392
+ clean = line.rstrip()
393
+ _log(f"{name}> {clean}")
394
+ if name == "minecraft":
395
+ with self.lock:
396
+ if "Failed to save player data" in clean:
397
+ self.failed_saves_since_boot += 1
398
+ if "respawnDimension" in clean and "null" in clean:
399
+ self.respawn_npe_since_boot += 1
400
+ if name == "minecraft" and "Done (" in clean:
401
+ self.minecraft_ready.set()
402
+ except Exception as exc:
403
+ _log(f"{name} log pump error: {exc!r}")
404
+
405
+ def _spawn(self, name: str, args: list[str], cwd: Path, env: dict[str, str] | None = None) -> subprocess.Popen[str]:
406
+ _log(f"start {name}: {' '.join(args)}")
407
+ proc = subprocess.Popen(
408
+ args,
409
+ cwd=str(cwd),
410
+ env=env,
411
+ stdin=subprocess.PIPE,
412
+ stdout=subprocess.PIPE,
413
+ stderr=subprocess.STDOUT,
414
+ text=True,
415
+ bufsize=1,
416
+ )
417
+ self.processes[name] = proc
418
+ threading.Thread(target=self._pump, args=(name, proc), daemon=True, name=f"log-{name}").start()
419
+ return proc
420
+
421
+ def _stop_process(self, name: str) -> None:
422
+ proc = self.processes.get(name)
423
+ if not proc or proc.poll() is not None:
424
+ return
425
+ try:
426
+ if name == "minecraft" and proc.stdin:
427
+ proc.stdin.write("stop\n")
428
+ proc.stdin.flush()
429
+ proc.wait(timeout=20)
430
+ return
431
+ except Exception:
432
+ pass
433
+ try:
434
+ proc.terminate()
435
+ proc.wait(timeout=12)
436
+ except Exception:
437
+ try:
438
+ proc.kill()
439
+ except Exception:
440
+ pass
441
+
442
+ def _ensure_java(self) -> Path:
443
+ root = TOOLS_DIR / "jre21"
444
+ existing = list(root.glob("*/bin/java")) if root.exists() else []
445
+ if existing:
446
+ return existing[0]
447
+ archive = _download(JAVA_URL, TOOLS_DIR / "temurin21-jre.tar.gz", JAVA_SHA256)
448
+ if root.exists():
449
+ shutil.rmtree(root)
450
+ root.mkdir(parents=True, exist_ok=True)
451
+ with tarfile.open(archive, "r:gz") as tf:
452
+ tf.extractall(root)
453
+ found = list(root.glob("*/bin/java"))
454
+ if not found:
455
+ raise RuntimeError("Temurin JRE 21 extraction failed")
456
+ found[0].chmod(0o755)
457
+ version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
458
+ _log("portable Java ready: " + version.splitlines()[0])
459
+ return found[0]
460
+
461
+ def _ensure_minecraft_java(self) -> Path:
462
+ root = TOOLS_DIR / "jre17"
463
+ existing = list(root.glob("*/bin/java")) if root.exists() else []
464
+ if existing:
465
+ return existing[0]
466
+ archive = _download(MC_JAVA_URL, TOOLS_DIR / "temurin17-jre.tar.gz", MC_JAVA_SHA256)
467
+ if root.exists():
468
+ shutil.rmtree(root)
469
+ root.mkdir(parents=True, exist_ok=True)
470
+ with tarfile.open(archive, "r:gz") as tf:
471
+ tf.extractall(root)
472
+ found = list(root.glob("*/bin/java"))
473
+ if not found:
474
+ raise RuntimeError("Temurin JRE 17 extraction failed")
475
+ found[0].chmod(0o755)
476
+ version = _run_capture([str(found[0]), "-version"], BASE, timeout=30)
477
+ _log("Minecraft Java 17 ready: " + version.splitlines()[0])
478
+ return found[0]
479
+
480
+ def _ensure_persistent_world(self) -> None:
481
+ WORLD_DIR.mkdir(parents=True, exist_ok=True)
482
+ # level.dat only exists once the server has generated the world, so its
483
+ # absence means this is the first boot of a new map.
484
+ if not (WORLD_DIR / "level.dat").exists() and PREVIOUS_WORLD_DIR.is_dir():
485
+ _carry_player_saves(PREVIOUS_WORLD_DIR, WORLD_DIR)
486
+ # Probe the exact temporary-file + rename pattern used by player saves.
487
+ # This never touches a player's .dat file or existing world data.
488
+ player_dir = WORLD_DIR / "playerdata"
489
+ probe = None
490
+ target = player_dir / ".voyager-storage-probe"
491
+ try:
492
+ player_dir.mkdir(parents=True, exist_ok=True)
493
+ marker = player_dir / ".keep"
494
+ if not marker.exists():
495
+ marker.write_text("Preserve playerdata directory in object storage.\n", encoding="utf-8")
496
+ fd, filename = tempfile.mkstemp(prefix=".voyager-probe-", suffix=".dat", dir=player_dir)
497
+ probe = Path(filename)
498
+ with os.fdopen(fd, "wb") as handle:
499
+ handle.write(b"voyager storage check")
500
+ probe.replace(target)
501
+ assert target.read_bytes() == b"voyager storage check"
502
+ _log("Player storage probe: temporary create/write/rename/read succeeded")
503
+ except Exception as exc:
504
+ _log(f"PLAYER STORAGE PROBE FAILED: {type(exc).__name__}: {exc}")
505
+ finally:
506
+ for candidate in (probe, target):
507
+ if candidate is not None:
508
+ try:
509
+ candidate.unlink(missing_ok=True)
510
+ except OSError:
511
+ pass
512
+ WORLD_DIR.mkdir(parents=True, exist_ok=True)
513
+ world_link = JAVA_DIR / "world"
514
+ if world_link.is_symlink():
515
+ try:
516
+ if world_link.resolve() == WORLD_DIR.resolve():
517
+ return
518
+ except OSError:
519
+ pass
520
+ world_link.unlink(missing_ok=True)
521
+ elif world_link.exists():
522
+ if not any(WORLD_DIR.iterdir()):
523
+ shutil.copytree(world_link, WORLD_DIR, dirs_exist_ok=True)
524
+ _log("Migrated existing world into persistent storage")
525
+ shutil.rmtree(world_link)
526
+ world_link.symlink_to(WORLD_DIR, target_is_directory=True)
527
+ _log(f"Persistent Minecraft world linked: {world_link} -> {WORLD_DIR}")
528
+
529
+ def _ensure_voyager_mods(self) -> None:
530
+ mods_dir = JAVA_DIR / "mods"
531
+ mods_dir.mkdir(parents=True, exist_ok=True)
532
+ mods = (
533
+ ("fabric-api-0.58.0+1.19.jar", FABRIC_API_URL, FABRIC_API_SHA256),
534
+ ("completeconfig-2.0.0.jar", COMPLETE_CONFIG_URL, COMPLETE_CONFIG_SHA256),
535
+ ("Multiplayer-Server-Pause-1.19-Fabric-1.1.0.jar", SERVER_PAUSE_URL, SERVER_PAUSE_SHA256),
536
+ )
537
  for filename, url, sha256 in mods:
538
  _download(url, mods_dir / filename, sha256)
539
 
 
547
  _log("Removed faulty Better Respawn 1.19 mod from Fabric mods directory")
548
  _log(
549
  "Official Voyager Fabric mods ready: Fabric API 0.58.0+1.19, "
550
+ "CompleteConfig 2.0.0, Multiplayer Server Pause 1.1.0. "
 
551
  "Better Respawn 1.19-2.0.0 is disabled due to its player-save NPE. Mod Menu is client-only and is "
552
+ "intentionally omitted on this headless dedicated server."
553
+ )
554
+
555
+ def _ensure_fabric(self) -> Path:
556
+ java = self._ensure_minecraft_java()
557
+ installer = _download(FABRIC_INSTALLER_URL, TOOLS_DIR / "fabric-installer-0.11.2.jar")
558
+ JAVA_DIR.mkdir(parents=True, exist_ok=True)
559
+ launcher = JAVA_DIR / "fabric-server-launch.jar"
560
+ if not launcher.exists():
561
+ _run_capture(
562
+ [
563
+ str(java), "-jar", str(installer), "server",
564
+ "-mcversion", FABRIC_MC_VERSION,
565
+ "-loader", FABRIC_LOADER_VERSION,
566
+ "-downloadMinecraft",
567
+ "-dir", str(JAVA_DIR),
568
+ ],
569
+ JAVA_DIR,
570
+ timeout=300,
571
+ )
572
+ self._ensure_voyager_mods()
573
+ (JAVA_DIR / "eula.txt").write_text("eula=true\n", encoding="utf-8")
574
+ props = {
575
+ "server-ip": "127.0.0.1",
576
+ "server-port": "25565",
577
+ "online-mode": "false",
578
+ "enforce-secure-profile": "false",
579
+ "enable-command-block": "true",
580
+ "spawn-protection": "0",
581
+ "allow-flight": "true",
582
+ "gamemode": "creative",
583
+ "force-gamemode": "true",
584
  "difficulty": "peaceful",
585
  "max-players": str(MAX_PLAYERS),
586
  "op-permission-level": "4",
587
+ "view-distance": "8",
588
+ "simulation-distance": "6",
589
+ "level-name": "world",
590
  "level-type": "minecraft:flat",
591
  "generator-settings": FLAT_GENERATOR_SETTINGS,
592
+ "generate-structures": "false",
593
+ "motd": "Voyager AI + Bedrock Friends",
594
+ }
595
  (JAVA_DIR / "server.properties").write_text(
596
  "\n".join(f"{k}={v}" for k, v in props.items()) + "\n",
597
  encoding="utf-8",
598
  )
599
  _ensure_admin_operator(JAVA_DIR / "ops.json", ADMIN_USERNAME)
600
  self._ensure_persistent_world()
601
+ return launcher
602
+
603
+ def _reset_boot_counters(self) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  with self.lock:
605
+ self.failed_saves_since_boot = 0
606
+ self.respawn_npe_since_boot = 0
607
+
608
+ def _start_fabric(self) -> None:
609
+ launcher = self._ensure_fabric()
610
+ self.minecraft_ready.clear()
611
+ self._reset_boot_counters()
612
+ READY_FILE.unlink(missing_ok=True)
613
+ proc = self._spawn(
614
+ "minecraft",
615
+ [str(self._ensure_minecraft_java()), "-Xms1G", "-Xmx8G",
616
+ "--add-exports=java.base/jdk.internal.org.objectweb.asm=ALL-UNNAMED",
617
+ f"-javaagent:{Path(__file__).resolve().parent / 'diagnostics/player-save-diagnostics.jar'}",
618
+ "-jar", str(launcher), "nogui"],
619
+ JAVA_DIR,
620
+ )
621
+ if not _wait_tcp("127.0.0.1", 25565, 120):
622
+ raise RuntimeError("Minecraft Java 1.19 did not open 127.0.0.1:25565")
623
+ if not self.minecraft_ready.wait(timeout=180):
624
+ raise RuntimeError("Minecraft Java opened 25565 but world did not finish loading")
625
+ if proc.stdin:
626
+ for cmd in (
627
+ f"op {BOT_USERNAME}",
628
+ f"gamemode creative {BOT_USERNAME}",
629
+ "gamerule keepInventory true",
630
+ "gamerule doDaylightCycle false",
631
+ "difficulty peaceful",
632
+ ):
633
+ proc.stdin.write(cmd + "\n")
634
+ proc.stdin.flush()
635
+ READY_FILE.write_text("ready\n", encoding="utf-8")
636
+ _log("Minecraft Java 1.19/Fabric fully ready on 127.0.0.1:25565")
637
+
638
+ def _ensure_viaproxy(self) -> tuple[Path, Path]:
639
+ jar = _download(VIAPROXY_URL, VIA_DIR / "ViaProxy-3.4.13.jar", VIAPROXY_SHA256)
640
+ plugin_dir = VIA_DIR / "plugins"
641
+ plugin_dir.mkdir(parents=True, exist_ok=True)
642
+ _download(GEYSER_URL, plugin_dir / "Geyser-ViaProxy.jar", GEYSER_SHA256)
643
+ cfg = VIA_DIR / "viaproxy.yml"
644
+ if not cfg.exists():
645
+ proc = subprocess.Popen(
646
+ [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)],
647
+ cwd=str(VIA_DIR),
648
+ stdout=subprocess.PIPE,
649
+ stderr=subprocess.STDOUT,
650
+ text=True,
651
+ )
652
+ deadline = time.time() + 25
653
+ while time.time() < deadline and not cfg.exists() and proc.poll() is None:
654
+ time.sleep(1)
655
+ if proc.poll() is None:
656
+ proc.terminate()
657
+ try:
658
+ proc.wait(timeout=8)
659
+ except Exception:
660
+ proc.kill()
661
+ if not cfg.exists():
662
+ raise RuntimeError("ViaProxy did not generate viaproxy.yml")
663
+ text = cfg.read_text(encoding="utf-8", errors="replace")
664
+ text = _patch_scalar(text, "bind-address", "127.0.0.1:25568")
665
+ text = _patch_scalar(text, "target-address", "127.0.0.1:25565")
666
+ text = _patch_scalar(text, "proxy-online-mode", "false")
667
+ text = _patch_scalar(text, "auth-method", "none")
668
+ cfg.write_text(text, encoding="utf-8")
669
+ return jar, cfg
670
+
671
+ def _patch_geyser(self, path: Path) -> None:
672
+ data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
673
+ bedrock = data.setdefault("bedrock", {})
674
+ bedrock["address"] = "127.0.0.1"
675
+ bedrock["port"] = 19132
676
+ bedrock["clone-remote-port"] = False
677
+ java = data.setdefault("java", {})
678
+ java["auth-type"] = "offline"
679
+ advanced = data.setdefault("advanced", {})
680
+ adv_bedrock = advanced.setdefault("bedrock", {})
681
+ adv_bedrock["broadcast-port"] = 19132
682
+ adv_bedrock["validate-bedrock-login"] = False
683
+ data["debug-mode"] = False
684
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
685
+ _log("Geyser configured loopback-only; Xbox/NetherNet relay remains the auth boundary")
686
+
687
+ def _start_viaproxy(self) -> None:
688
+ jar, cfg = self._ensure_viaproxy()
689
+ geyser_cfg = VIA_DIR / "plugins" / "Geyser" / "config.yml"
690
+ if not geyser_cfg.exists():
691
+ proc = self._spawn("viaproxy-bootstrap", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
692
+ deadline = time.time() + 35
693
+ while time.time() < deadline and not geyser_cfg.exists() and proc.poll() is None:
694
+ time.sleep(1)
695
+ self._stop_process("viaproxy-bootstrap")
696
+ if not geyser_cfg.exists():
697
+ raise RuntimeError("Geyser-ViaProxy did not generate plugins/Geyser/config.yml")
698
+ self._patch_geyser(geyser_cfg)
699
+ proc = self._spawn("viaproxy", [str(self._ensure_java()), "-jar", str(jar), "config", str(cfg)], VIA_DIR)
700
+ time.sleep(12)
701
+ if proc.poll() is not None:
702
+ raise RuntimeError(f"ViaProxy/Geyser exited rc={proc.returncode}")
703
+ _log("ViaProxy + Geyser running; Bedrock backend is 127.0.0.1:19132")
704
+
705
+ def _ensure_go(self) -> Path:
706
+ go = TOOLS_DIR / "go" / "bin" / "go"
707
+ if go.exists():
708
+ return go
709
+ archive = _download(GO_URL, TOOLS_DIR / "go1.25.14.linux-amd64.tar.gz", GO_SHA256)
710
+ target = TOOLS_DIR / "go"
711
+ if target.exists():
712
+ shutil.rmtree(target)
713
+ with tarfile.open(archive, "r:gz") as tf:
714
+ tf.extractall(TOOLS_DIR)
715
+ if not go.exists():
716
+ raise RuntimeError("Go toolchain extraction failed")
717
+ return go
718
+
719
+ def _ensure_nether2rak(self) -> Path:
720
+ binary = BIN_DIR / "nether2rak"
721
+ if binary.exists():
722
+ return binary
723
+ go = self._ensure_go()
724
+ src = Path("/app/third_party/nether2rak")
725
+ if not src.exists():
726
+ src = Path(__file__).resolve().parent / "third_party" / "nether2rak"
727
+ if not src.exists():
728
+ raise RuntimeError("Vendored nether2rak source is missing")
729
+ env = os.environ.copy()
730
+ env.update(
731
+ {
732
+ "PATH": f"{go.parent}:{env.get('PATH', '')}",
733
+ "GOTOOLCHAIN": "local",
734
+ "GOCACHE": str(BASE / "go-cache"),
735
+ "GOMODCACHE": str(BASE / "go-mod-cache"),
736
+ "CGO_ENABLED": "0",
737
+ }
738
+ )
739
+ _run_capture([str(go), "build", "-trimpath", "-o", str(binary), "."], src, timeout=900, env=env)
740
+ binary.chmod(0o755)
741
+ return binary
742
+
743
+ def _write_relay_config(self) -> Path:
744
+ cfg = {
745
+ "backend_transport": "raknet",
746
+ "geyser_address": "127.0.0.1:19132",
747
+ "nethernet_backend_address": "http://127.0.0.1:19134",
748
+ "host_name": HOST_NAME,
749
+ "world_name": WORLD_NAME,
750
+ "max_players": MAX_PLAYERS,
751
+ "fake_player_count": 0,
752
+ "protocol": BEDROCK_PROTOCOL,
753
+ "version": BEDROCK_VERSION,
754
+ "update_interval_seconds": 30,
755
+ "allowed_xuids": [],
756
+ "compression": "snappy",
757
+ "compression_threshold": 256,
758
+ "fix_native_bds_persistence": False,
759
+ "disable_client_encryption": False,
760
+ "relay_direct_ip": False,
761
+ "relay_direct_ip_listen_address": ":19134",
762
+ "invite_port": 7782,
763
+ "ping_port": 7777,
764
+ "pprof_port": 6060,
765
+ "extra_broadcasts": [],
766
+ }
767
+ path = RELAY_DIR / "config.json"
768
+ path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
769
+ return path
770
+
771
+ def _start_relay(self) -> None:
772
+ binary = self._ensure_nether2rak()
773
+ self._write_relay_config()
774
+ proc = self._spawn("friends", [str(binary)], RELAY_DIR)
775
+ time.sleep(4)
776
+ if proc.poll() is not None:
777
+ raise RuntimeError(f"Xbox Friends relay exited rc={proc.returncode}")
778
+ _log("Xbox Friends relay started; Microsoft device-code login may be required")
779
+
780
+ def _boot(self) -> None:
781
+ with self.lock:
782
+ self.state = "starting"
783
+ self.error = ""
784
+ self.started_at = time.time()
785
+ try:
786
+ _mkdirs()
787
+ _log("=== Minecraft stack bootstrap ===")
788
+ self._start_fabric()
789
+ self._start_viaproxy()
790
+ self._start_relay()
791
+ with self.lock:
792
+ self.state = "running"
793
+ _log("=== Minecraft stack running ===")
794
+ except Exception as exc:
795
+ with self.lock:
796
+ self.state = "error"
797
+ self.error = repr(exc)
798
+ _log(f"STACK ERROR: {exc!r}")
799
+
800
+ def start_async(self) -> dict[str, Any]:
801
+ with self.lock:
802
+ if self.worker and self.worker.is_alive():
803
+ return self.status()
804
+ if any(p.poll() is None for p in self.processes.values()) and self.state == "running":
805
+ return self.status()
806
+ self.worker = threading.Thread(target=self._boot, daemon=True, name="minecraft-stack")
807
+ self.worker.start()
808
+ return self.status()
809
+
810
+ def stop(self) -> dict[str, Any]:
811
+ with self.lock:
812
+ for name in ("friends", "viaproxy", "viaproxy-bootstrap", "minecraft"):
813
+ self._stop_process(name)
814
+ self.state = "stopped"
815
+ _log("Minecraft stack stopped")
816
+ return self.status()
817
+
818
+ def tail(self, lines: int = 160) -> str:
819
+ if not LOG_PATH.exists():
820
+ return "Sem logs do Minecraft ainda."
821
+ items = _tail_lines(LOG_PATH, max(10, min(lines, 500)))
822
+ return "\n".join(items[-max(10, min(lines, 500)):])
823
+
824
  def status(self) -> dict[str, Any]:
825
  with self.lock:
826
  processes = {
 
830
  for name, process in processes.items():
831
  if process["running"]:
832
  process["rss_gib"] = _process_rss_gib(process["pid"])
833
+ all_lines = _tail_lines(LOG_PATH)
834
+ boot = next((i for i in range(len(all_lines) - 1, -1, -1)
835
+ if "=== Minecraft stack bootstrap ===" in all_lines[i]), 0)
836
+ server_lines = [line for line in all_lines[boot:] if "minecraft>" in line]
 
 
 
 
837
  log = "\n".join(all_lines[-120:]) if all_lines else "Sem logs do Minecraft ainda."
838
  relay_health = _relay_diagnostics(all_lines[boot:] if all_lines else [])
839
  access_state = _access_state(self.state, self.started_at, relay_health)
 
868
  player_stat = None
869
  player_save = {
870
  "better_respawn_jar_present": (JAVA_DIR / "mods" / "better-respawn-fabric-1.19-2.0.0.jar").exists(),
871
+ "failed_saves_since_boot": self.failed_saves_since_boot,
872
+ "respawn_npe_since_boot": self.respawn_npe_since_boot,
 
873
  "player_file_exists": player_stat is not None,
874
  "player_file_size": player_stat.st_size if player_stat else None,
875
  "player_file_mtime": player_stat.st_mtime if player_stat else None,
876
  }
877
+ login_url = None
878
+ device_code = None
879
+ url_match = re.findall(r"https://(?:www\.)?microsoft\.com/link", log, flags=re.I)
880
+ if url_match:
881
+ login_url = url_match[-1]
882
+ for pattern in (
883
+ r"(?:code|c[oó]digo)\s*[:=]\s*([A-Z0-9-]{6,16})",
884
+ r"enter\s+([A-Z0-9-]{6,16})\s+(?:to|at)",
885
+ r"using\s+the\s+code\s+([A-Z0-9-]{6,16})",
886
+ ):
887
+ found = re.findall(pattern, log, flags=re.I)
888
+ if found:
889
+ device_code = found[-1].upper()
890
+ break
891
+ return {
892
+ "state": self.state,
893
+ "error": self.error or None,
894
+ "started_at": self.started_at,
895
+ "java_server": "127.0.0.1:25565",
896
+ "bedrock_backend": "127.0.0.1:19132",
897
+ "access": "Xbox/Microsoft Friends via NetherNet; no public UDP tunnel",
898
+ "auth_persistence": str(RELAY_DIR / "token.json"),
899
+ "auth_token_cached": (RELAY_DIR / "token.json").exists(),
900
+ "persistent_root": str(PERSIST_ROOT),
901
  "world_persistence": str(WORLD_DIR),
902
  "world_profile": WORLD_PROFILE,
903
  "world_surface_y": WORLD_SURFACE_Y,
904
  "player_save": player_save,
905
  "relay_health": relay_health,
 
906
  "access_state": access_state,
907
  "performance": performance,
908
  "player_limit": {"java": java_player_limit, "xbox_friends": xbox_player_limit},
909
  "admin_operator": admin_operator,
910
  "admin_player_file_exists": admin_player_file_exists,
911
+ "bot_username": BOT_USERNAME,
912
+ "bot_display_name": "Naldo Bot",
913
+ "bedrock_protocol": BEDROCK_PROTOCOL,
914
+ "bedrock_version": BEDROCK_VERSION,
915
+ "microsoft_login_url": login_url,
916
+ "microsoft_device_code": device_code,
917
+ "processes": processes,
918
+ "log_tail": log,
919
+ }
920
+
921
+
922
+ STACK = MinecraftStack()
naldo_opencode.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Naldo_Bot brain constants and offline-safe helpers for the OpenCode path.
2
+
3
+ Only Naldo_Bot uses OpenCode. Gemini_Bot keeps its own proxy and must not
4
+ import this module's model list as a fallback.
5
+
6
+ Design notes (do not drift without updating the tests):
7
+ - Provider is always ``opencode``. Only FREE opencode models are allowed.
8
+ Never Granite, Gemini, external OpenAI, Anthropic, or any other family.
9
+ - No OPENCODE_API_KEY is required: the free opencode models work with cost 0
10
+ in a clean HOME. This module never reads, copies, or logs credential files.
11
+ - ``opencode serve`` binds 127.0.0.1 only, on a dedicated internal port.
12
+ - The bridge (naldo_opencode_bridge.py) binds 127.0.0.1 only on its own port
13
+ and translates OpenAI-compatible chat calls into OpenCode session calls:
14
+ POST /session -> POST /session/{id}/message, health via GET /global/health.
15
+ - OpenCode session message API exposes no temperature field, so the bridge
16
+ cannot forward Voyager's temperature. The requested value is recorded for
17
+ observability; the effective sampling is the provider default.
18
+ - OpenCode here is a TEXT GENERATOR for Voyager agents (Curriculum/Action/
19
+ Critic/SkillManager). In OpenCode 1.18.33, message-level ``tools: {}``
20
+ disables tool execution while remaining compatible with the Zen free tier.
21
+ Custom deny-all session permissions are intentionally not sent because Zen
22
+ rejects those customized sessions with FreeTierError/403.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import subprocess
30
+ import urllib.parse
31
+ import urllib.request
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ OPENCODE_PROVIDER = "opencode"
36
+ OPENCODE_VERSION = "1.18.33"
37
+ OPENCODE_NPM_SPEC = "opencode-ai@1.18.33"
38
+
39
+ PRIMARY_MODEL = "muse-spark-1.3-contributor-free"
40
+ FALLBACK_MODELS: tuple[str, ...] = (
41
+ "muse-spark-1.3-contributor-free",
42
+ "fledge-alpha-free",
43
+ "nemotron-3-ultra-free",
44
+ )
45
+ ALLOWED_MODELS = frozenset(FALLBACK_MODELS)
46
+
47
+ BRAIN_PROFILE = "naldo-opencode"
48
+
49
+ RUNTIME_HOME = Path(os.environ.get("VOYAGER_RUNTIME_HOME", "/tmp/voyager-runtime"))
50
+ OPENCODE_INSTALL_DIR = Path(os.environ.get("OPENCODE_INSTALL_DIR", str(RUNTIME_HOME / "opencode-cli")))
51
+ OPENCODE_HOME_DIR = Path(os.environ.get("OPENCODE_HOME_DIR", str(RUNTIME_HOME / "opencode-home")))
52
+ OPENCODE_SESSION_DIR = Path(os.environ.get("OPENCODE_SESSION_DIR", str(RUNTIME_HOME / "opencode-sessions")))
53
+
54
+ OPENCODE_PORT = int(os.environ.get("OPENCODE_PORT", "7865"))
55
+ BRIDGE_PORT = int(os.environ.get("NALDO_BRIDGE_PORT", "7864"))
56
+ OPENCODE_BASE = os.environ.get("OPENCODE_BASE", f"http://127.0.0.1:{OPENCODE_PORT}").rstrip("/")
57
+ BRIDGE_BASE = os.environ.get("NALDO_BRIDGE_BASE", f"http://127.0.0.1:{BRIDGE_PORT}/v1").rstrip("/")
58
+
59
+ # Local embeddings stay on the public 7860 endpoint so SkillManager/QA keep
60
+ # working. Only generative chat moves to the OpenCode bridge.
61
+ LOCAL_EMBED_BASE = os.environ.get("NALDO_EMBED_BASE", "http://127.0.0.1:7860/v1").rstrip("/")
62
+
63
+ FORBIDDEN_MODEL_SUBSTRINGS = (
64
+ "granite",
65
+ "gemini",
66
+ "gpt-",
67
+ "openai",
68
+ "anthropic",
69
+ "claude",
70
+ "qwen",
71
+ )
72
+
73
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
74
+
75
+ TEMPERATURE_NOTE = (
76
+ "OpenCode session message API exposes no temperature field; "
77
+ "Voyager's requested temperature is recorded but not forwarded. "
78
+ "Effective sampling is the opencode provider default."
79
+ )
80
+
81
+
82
+ def canonical_model_id(name: str) -> str:
83
+ """Strip an optional ``opencode/`` prefix to the bare free-model id."""
84
+ text = str(name or "").strip()
85
+ if "/" in text:
86
+ provider, _, model = text.partition("/")
87
+ if provider.strip().lower() == OPENCODE_PROVIDER:
88
+ return model.strip()
89
+ return text
90
+ return text
91
+
92
+
93
+ def is_allowed_model(name: str) -> bool:
94
+ """True only for FREE models of the ``opencode`` provider."""
95
+ text = str(name or "").strip()
96
+ if not text:
97
+ return False
98
+ lowered = text.lower()
99
+ for forbidden in FORBIDDEN_MODEL_SUBSTRINGS:
100
+ if forbidden in lowered:
101
+ return False
102
+ if "/" in text:
103
+ provider, _, model = text.partition("/")
104
+ if provider.strip().lower() != OPENCODE_PROVIDER:
105
+ return False
106
+ return model.strip() in ALLOWED_MODELS
107
+ return text in ALLOWED_MODELS
108
+
109
+
110
+ def is_loopback_url(url: str) -> bool:
111
+ try:
112
+ host = (urllib.parse.urlparse(url).hostname or "").lower()
113
+ except Exception:
114
+ return False
115
+ return host in LOOPBACK_HOSTS
116
+
117
+
118
+ def _content(value: Any) -> str:
119
+ if value is None:
120
+ return ""
121
+ if isinstance(value, str):
122
+ return value
123
+ if isinstance(value, list):
124
+ parts: list[str] = []
125
+ for item in value:
126
+ if isinstance(item, dict):
127
+ text = item.get("text")
128
+ if isinstance(text, str):
129
+ parts.append(text)
130
+ elif isinstance(item, str):
131
+ parts.append(item)
132
+ return "\n".join(parts)
133
+ return str(value)
134
+
135
+
136
+ def build_opencode_prompt(messages: list[dict[str, Any]]) -> dict[str, str]:
137
+ """Render OpenAI-style messages into a deterministic role-tagged prompt.
138
+
139
+ System messages go to the OpenCode ``system`` field AND stay tagged in the
140
+ prompt body so role boundaries survive a single-text-part transport.
141
+ tool_calls/tool outputs are rendered as inert text; they are never
142
+ executed. OpenCode tools stay disabled server-side.
143
+ """
144
+ systems: list[str] = []
145
+ rendered: list[str] = []
146
+ has_tool_traffic = False
147
+ for raw in messages or []:
148
+ if not isinstance(raw, dict):
149
+ continue
150
+ role = str(raw.get("role", "user")).upper()
151
+ body = _content(raw.get("content"))
152
+ extra = ""
153
+ tool_calls = raw.get("tool_calls")
154
+ if isinstance(tool_calls, list) and tool_calls:
155
+ has_tool_traffic = True
156
+ try:
157
+ extra = "\n[TOOL_CALLS]\n" + json.dumps(tool_calls, ensure_ascii=False)[:4000]
158
+ except Exception:
159
+ extra = "\n[TOOL_CALLS]\n<unserializable>"
160
+ if raw.get("tool_call_id") or role == "TOOL":
161
+ has_tool_traffic = True
162
+ if role == "SYSTEM":
163
+ if body:
164
+ systems.append(body)
165
+ rendered.append(f"[SYSTEM]\n{body}{extra}")
166
+ else:
167
+ rendered.append(f"[{role}]\n{body}{extra}")
168
+ header = (
169
+ "You are the language model inside the Voyager Minecraft agent. "
170
+ "Follow the supplied system and user instructions exactly. "
171
+ "Return only the response format requested by the final prompt; no wrapper commentary. "
172
+ "OpenCode tools are disabled; you are a text generator, not a file/shell executor."
173
+ )
174
+ if has_tool_traffic:
175
+ header += (
176
+ " Note: any TOOL_CALLS/TOOL sections above are rendered as inert text "
177
+ "from the client; do not attempt tool calls, return plain text only."
178
+ )
179
+ system_text = "\n\n".join(systems)
180
+ prompt = header + "\n\n" + "\n\n".join(rendered) if rendered else header
181
+ return {"system": system_text, "prompt": prompt}
182
+
183
+
184
+ def extract_assistant_text(payload: Any) -> str:
185
+ """Return only assistant ``text`` parts from an OpenCode message result."""
186
+ parts: Any = None
187
+ if isinstance(payload, dict):
188
+ parts = payload.get("parts", payload.get("message", {}).get("parts") if isinstance(payload.get("message"), dict) else None)
189
+ if parts is None and isinstance(payload.get("info"), dict):
190
+ # Some shapes nest under info/parts; fall through to generic scan.
191
+ parts = payload.get("parts")
192
+ elif isinstance(payload, list):
193
+ parts = payload
194
+ if not isinstance(parts, list):
195
+ return ""
196
+ texts: list[str] = []
197
+ for part in parts:
198
+ if not isinstance(part, dict):
199
+ continue
200
+ if part.get("type") != "text":
201
+ continue
202
+ text = part.get("text")
203
+ if isinstance(text, str) and text:
204
+ texts.append(text)
205
+ return "".join(texts)
206
+
207
+
208
+ def opencode_message_body(model_id: str, system: str, prompt: str) -> dict[str, Any]:
209
+ """Build POST /session/{id}/message body with tools blocked.
210
+
211
+ No temperature is sent: the session API has no such field (see
212
+ TEMPERATURE_NOTE). ``tools: {}`` disables file/shell execution.
213
+ """
214
+ body: dict[str, Any] = {
215
+ "model": {"providerID": OPENCODE_PROVIDER, "modelID": canonical_model_id(model_id)},
216
+ "tools": {},
217
+ "parts": [{"type": "text", "text": prompt}],
218
+ }
219
+ if system:
220
+ body["system"] = system
221
+ return body
222
+
223
+
224
+ def opencode_binary_path(install_dir: Path = OPENCODE_INSTALL_DIR) -> Path:
225
+ name = "opencode.cmd" if os.name == "nt" else "opencode"
226
+ return install_dir / "node_modules" / ".bin" / name
227
+
228
+
229
+ def opencode_serve_command(binary: str | Path, port: int = OPENCODE_PORT) -> list[str]:
230
+ return [str(binary), "serve", "--hostname", "127.0.0.1", "--port", str(port)]
231
+
232
+
233
+ def bridge_command(port: int = BRIDGE_PORT) -> list[str]:
234
+ import sys
235
+
236
+ entrypoint = Path(__file__).resolve().parent / "naldo_opencode_bridge.py"
237
+ return [sys.executable, "-u", str(entrypoint)]
238
+
239
+
240
+ def _http_json(
241
+ url: str,
242
+ method: str = "GET",
243
+ body: dict[str, Any] | None = None,
244
+ timeout: float = 10.0,
245
+ ) -> Any:
246
+ data = json.dumps(body).encode("utf-8") if body is not None else None
247
+ request = urllib.request.Request(url, data=data, method=method)
248
+ if data is not None:
249
+ request.add_header("Content-Type", "application/json")
250
+ with urllib.request.urlopen(request, timeout=timeout) as response:
251
+ raw = response.read().decode("utf-8", errors="replace")
252
+ try:
253
+ return json.loads(raw) if raw else {}
254
+ except ValueError:
255
+ return {"raw": raw}
256
+
257
+
258
+ def check_opencode_health(base: str = OPENCODE_BASE, timeout: float = 5.0) -> dict[str, Any]:
259
+ try:
260
+ payload = _http_json(base.rstrip("/") + "/global/health", timeout=timeout)
261
+ ready = bool(isinstance(payload, dict) and payload.get("healthy") is True)
262
+ return {
263
+ "ok": ready,
264
+ "ready": ready,
265
+ "detail": payload if isinstance(payload, dict) else {"raw": str(payload)[:200]},
266
+ }
267
+ except Exception as exc:
268
+ return {"ok": False, "ready": False, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
269
+
270
+
271
+ def check_bridge_health(base: str = BRIDGE_BASE, timeout: float = 5.0) -> dict[str, Any]:
272
+ try:
273
+ root = base.rstrip("/")
274
+ health_url = root + "/health" if root.endswith("/v1") else root + "/health"
275
+ # BRIDGE_BASE ends with /v1; health lives at /health (no /v1 prefix).
276
+ if health_url.endswith("/v1/health"):
277
+ health_url = health_url[: -len("/v1/health")] + "/health"
278
+ payload = _http_json(health_url, timeout=timeout)
279
+ ready = bool(isinstance(payload, dict) and payload.get("ready"))
280
+ return {"ok": True, "ready": ready, "detail": payload if isinstance(payload, dict) else {}}
281
+ except Exception as exc:
282
+ return {"ok": False, "ready": False, "error": f"{type(exc).__name__}: {str(exc)[:160]}"}
283
+
284
+
285
+ def opencode_subprocess_env(
286
+ base_env: dict[str, str] | None = None,
287
+ home_dir: Path = OPENCODE_HOME_DIR,
288
+ ) -> dict[str, str]:
289
+ """Build an isolated child environment without inheriting OpenCode auth."""
290
+ env = dict(base_env if base_env is not None else os.environ)
291
+ config_dir = home_dir / "config"
292
+ data_dir = home_dir / "data"
293
+ for directory in (home_dir, config_dir, data_dir, OPENCODE_SESSION_DIR):
294
+ directory.mkdir(parents=True, exist_ok=True)
295
+ env.pop("OPENCODE_API_KEY", None)
296
+ env["HOME"] = str(home_dir)
297
+ env["XDG_CONFIG_HOME"] = str(config_dir)
298
+ env["XDG_DATA_HOME"] = str(data_dir)
299
+ return env
300
+
301
+
302
+ def _probe_opencode(binary: Path, timeout: int) -> str:
303
+ try:
304
+ proc = subprocess.run(
305
+ [str(binary), "--version"],
306
+ stdout=subprocess.PIPE,
307
+ stderr=subprocess.STDOUT,
308
+ text=True,
309
+ timeout=timeout,
310
+ check=False,
311
+ )
312
+ except (OSError, subprocess.SubprocessError):
313
+ return ""
314
+ return (proc.stdout or "").strip() if proc.returncode == 0 else ""
315
+
316
+
317
+ def ensure_opencode_installed(
318
+ install_dir: Path = OPENCODE_INSTALL_DIR,
319
+ timeout: int = 60,
320
+ ) -> tuple[str, str]:
321
+ """Install the pinned CLI locally; never require a global npm prefix/root."""
322
+ install_dir.mkdir(parents=True, exist_ok=True)
323
+ binary = opencode_binary_path(install_dir)
324
+ reported = _probe_opencode(binary, timeout)
325
+ if reported == OPENCODE_VERSION:
326
+ return str(binary), reported
327
+
328
+ npm = "npm.cmd" if os.name == "nt" else "npm"
329
+ proc = subprocess.run(
330
+ [
331
+ npm, "install", "--no-audit", "--no-fund",
332
+ "--prefix", str(install_dir), OPENCODE_NPM_SPEC,
333
+ ],
334
+ stdout=subprocess.PIPE,
335
+ stderr=subprocess.STDOUT,
336
+ text=True,
337
+ timeout=600,
338
+ check=False,
339
+ )
340
+ output = proc.stdout or ""
341
+ if proc.returncode != 0:
342
+ raise RuntimeError(f"opencode local install failed rc={proc.returncode}: {output[-2000:]}")
343
+ reported = _probe_opencode(binary, timeout)
344
+ if reported != OPENCODE_VERSION:
345
+ raise RuntimeError(
346
+ f"opencode CLI version mismatch after local install: expected={OPENCODE_VERSION!r} got={reported!r}"
347
+ )
348
+ return str(binary), reported
naldo_opencode_bridge.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI-compatible bridge for Naldo_Bot backed exclusively by OpenCode.
2
+
3
+ Listens on 127.0.0.1 only. Translates POST /v1/chat/completions into the real
4
+ OpenCode session API discovered via /doc:
5
+
6
+ - POST /session -> create session (body may carry title)
7
+ - POST /session/{sessionID}/message -> send prompt, wait for reply
8
+ - GET /global/health -> opencode health
9
+
10
+ Each inference creates a fresh session (isolated per Voyager call) and sends
11
+ ``tools: {}`` on the message. OpenCode 1.18.33 was verified to return no tool
12
+ parts and perform no requested bash side effect in this mode. Custom deny-all
13
+ session permissions are not used because Zen free-tier rejects them with 403.
14
+ Only assistant ``text`` parts are returned.
15
+
16
+ Temperature: OpenCode session API exposes no temperature field, so Voyager's
17
+ requested temperature is recorded and logged, never forwarded. See
18
+ naldo_opencode.TEMPERATURE_NOTE.
19
+
20
+ Embeddings (SkillManager/QA) are NOT served here; /v1/embeddings proxies to
21
+ the existing local 7860 endpoint to preserve upstream behavior.
22
+
23
+ No OPENCODE_API_KEY is used. No credential file is read or copied. Logs never
24
+ include prompts, completions, or credentials.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import threading
32
+ import time
33
+ import urllib.request
34
+ import uuid
35
+ from typing import Any
36
+
37
+ from fastapi import FastAPI, HTTPException, Request
38
+ from fastapi.responses import JSONResponse, StreamingResponse
39
+ from starlette.concurrency import run_in_threadpool
40
+
41
+ from naldo_opencode import (
42
+ ALLOWED_MODELS,
43
+ BRIDGE_PORT,
44
+ FALLBACK_MODELS,
45
+ LOCAL_EMBED_BASE,
46
+ OPENCODE_BASE,
47
+ OPENCODE_PROVIDER,
48
+ OPENCODE_SESSION_DIR,
49
+ PRIMARY_MODEL,
50
+ TEMPERATURE_NOTE,
51
+ build_opencode_prompt,
52
+ canonical_model_id,
53
+ extract_assistant_text,
54
+ is_allowed_model,
55
+ opencode_message_body,
56
+ )
57
+
58
+ OPENCODE = OPENCODE_BASE.rstrip("/")
59
+ EMBED_BASE = LOCAL_EMBED_BASE.rstrip("/")
60
+ PORT = int(os.environ.get("NALDO_BRIDGE_PORT", str(BRIDGE_PORT)))
61
+ REQUEST_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_TIMEOUT", "240"))
62
+ SESSION_TIMEOUT = float(os.environ.get("NALDO_BRIDGE_SESSION_TIMEOUT", "15"))
63
+
64
+ STATE_LOCK = threading.Lock()
65
+ state: dict[str, Any] = {
66
+ "calls": 0,
67
+ "failures": 0,
68
+ "fallbacks_used": 0,
69
+ "last_model": None,
70
+ "last_fallback": None,
71
+ "last_elapsed_s": None,
72
+ "prompt_tokens": 0,
73
+ "completion_tokens": 0,
74
+ "last_error": None,
75
+ "last_requested_temperature": None,
76
+ "last_requested_max_tokens": None,
77
+ "effective_temperature": TEMPERATURE_NOTE,
78
+ "opencode_base": OPENCODE,
79
+ }
80
+
81
+
82
+ def _http_json(
83
+ url: str,
84
+ method: str = "GET",
85
+ body: dict[str, Any] | None = None,
86
+ timeout: float = 10.0,
87
+ retries: int = 2,
88
+ ) -> Any:
89
+ last: Exception | None = None
90
+ for attempt in range(retries + 1):
91
+ try:
92
+ data = json.dumps(body).encode("utf-8") if body is not None else None
93
+ request = urllib.request.Request(url, data=data, method=method)
94
+ if data is not None:
95
+ request.add_header("Content-Type", "application/json")
96
+ with urllib.request.urlopen(request, timeout=timeout) as response:
97
+ raw = response.read().decode("utf-8", errors="replace")
98
+ return json.loads(raw) if raw else {}
99
+ except Exception as exc: # noqa: BLE001 - mapped to HTTP below
100
+ last = exc
101
+ if attempt < retries:
102
+ time.sleep(min(2.0 * (attempt + 1), 5.0))
103
+ raise RuntimeError(f"{type(last).__name__}: {str(last)[:200]}" if last else "request failed")
104
+
105
+
106
+ def _create_session(model_id: str, timeout: float = SESSION_TIMEOUT) -> str:
107
+ model = canonical_model_id(model_id)
108
+ if model not in ALLOWED_MODELS:
109
+ raise RuntimeError(f"refusing non-OpenCode/free session model: {model_id}")
110
+ OPENCODE_SESSION_DIR.mkdir(parents=True, exist_ok=True)
111
+ session_body = {
112
+ "title": "naldo-voyager",
113
+ "model": {"id": model, "providerID": OPENCODE_PROVIDER},
114
+ "location": {"directory": str(OPENCODE_SESSION_DIR)},
115
+ }
116
+ payload = _http_json(
117
+ OPENCODE + "/session", method="POST", body=session_body, timeout=timeout, retries=0
118
+ )
119
+ session_id = ""
120
+ if isinstance(payload, dict):
121
+ session_id = str(payload.get("id") or payload.get("sessionID") or payload.get("session_id") or "")
122
+ info = payload.get("info")
123
+ if not session_id and isinstance(info, dict):
124
+ session_id = str(info.get("id") or "")
125
+ if not session_id:
126
+ raise RuntimeError("OpenCode session creation returned no id")
127
+ return session_id
128
+
129
+
130
+ def _send_message(session_id: str, model_id: str, system: str, prompt: str) -> str:
131
+ body = opencode_message_body(model_id, system, prompt)
132
+ url = OPENCODE + f"/session/{session_id}/message"
133
+ try:
134
+ # Never retry the same generation POST: a loopback timeout can mean the
135
+ # first request is still running, and retrying would duplicate the turn.
136
+ result = _http_json(url, method="POST", body=body, timeout=REQUEST_TIMEOUT, retries=0)
137
+ finally:
138
+ try:
139
+ _http_json(OPENCODE + f"/session/{session_id}", method="DELETE", timeout=5.0)
140
+ except Exception:
141
+ pass
142
+ text = extract_assistant_text(result)
143
+ if not text:
144
+ raise RuntimeError("OpenCode returned no assistant text parts")
145
+ return text
146
+
147
+
148
+ def _candidate_models(requested: str) -> list[str]:
149
+ ordered: list[str] = []
150
+ bare_requested = canonical_model_id(requested)
151
+ if bare_requested in ALLOWED_MODELS:
152
+ ordered.append(bare_requested)
153
+ for model in FALLBACK_MODELS:
154
+ if model not in ordered:
155
+ ordered.append(model)
156
+ return ordered
157
+
158
+
159
+ def _chat_payload(body: dict[str, Any]) -> dict[str, Any]:
160
+ requested_model = str(body.get("model") or PRIMARY_MODEL)
161
+ if not is_allowed_model(requested_model):
162
+ raise ValueError(f"Unknown model for Naldo OpenCode bridge: {requested_model}")
163
+ requested_temp = body.get("temperature")
164
+ requested_max_tokens = body.get("max_tokens", body.get("max_completion_tokens"))
165
+ with STATE_LOCK:
166
+ state["last_requested_temperature"] = requested_temp
167
+ state["last_requested_max_tokens"] = requested_max_tokens
168
+ state["effective_temperature"] = TEMPERATURE_NOTE
169
+ if isinstance(requested_temp, (int, float)) and float(requested_temp) != 0.0:
170
+ # Voyager normally sends temperature 0; OpenCode session API has no
171
+ # temperature parameter, so record drift without inventing support.
172
+ print(
173
+ f"[naldo-bridge] requested temperature={requested_temp}; {TEMPERATURE_NOTE}",
174
+ flush=True,
175
+ )
176
+ # max_tokens is also not supported by the OpenCode session endpoint. Keep
177
+ # it only as telemetry: never character-truncate JavaScript/JSON responses.
178
+ rendered = build_opencode_prompt(body.get("messages") or [])
179
+
180
+ last_error = "OpenCode inference failed"
181
+ tried: list[str] = []
182
+ for model_id in _candidate_models(requested_model):
183
+ tried.append(model_id)
184
+ started = time.monotonic()
185
+ try:
186
+ session_id = _create_session(model_id)
187
+ answer = _send_message(session_id, model_id, rendered["system"], rendered["prompt"])
188
+ except Exception as exc: # noqa: BLE001 - retried across free models
189
+ last_error = f"{type(exc).__name__}: {str(exc)[:200]}"
190
+ continue
191
+ elapsed = round(time.monotonic() - started, 2)
192
+ with STATE_LOCK:
193
+ state["calls"] += 1
194
+ state["last_model"] = model_id
195
+ state["last_elapsed_s"] = elapsed
196
+ state["last_error"] = None
197
+ if model_id != canonical_model_id(requested_model):
198
+ state["fallbacks_used"] += 1
199
+ state["last_fallback"] = model_id
200
+ else:
201
+ state["last_fallback"] = None
202
+ return {
203
+ "id": "chatcmpl-" + uuid.uuid4().hex[:16],
204
+ "object": "chat.completion",
205
+ "created": int(time.time()),
206
+ "model": canonical_model_id(requested_model),
207
+ "choices": [
208
+ {"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
209
+ ],
210
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
211
+ "backend": "opencode-bridge",
212
+ "provider": OPENCODE_PROVIDER,
213
+ }
214
+ with STATE_LOCK:
215
+ state["failures"] += 1
216
+ state["last_error"] = f"tried={tried} last={last_error}"
217
+ raise RuntimeError(f"All OpenCode free models failed (tried={tried}): {last_error}")
218
+
219
+
220
+ api = FastAPI(title="Naldo OpenCode Bridge", version="1.0.0")
221
+
222
+
223
+ @api.get("/health")
224
+ def health() -> dict[str, Any]:
225
+ try:
226
+ upstream = _http_json(OPENCODE + "/global/health", timeout=5.0, retries=0)
227
+ upstream_ok = bool(isinstance(upstream, dict) and upstream.get("healthy") is True)
228
+ except Exception as exc: # noqa: BLE001 - health must not raise
229
+ upstream = {"error": f"{type(exc).__name__}"}
230
+ upstream_ok = False
231
+ with STATE_LOCK:
232
+ snapshot = dict(state)
233
+ return {
234
+ "ready": upstream_ok,
235
+ "bridge": "naldo-opencode",
236
+ "provider": OPENCODE_PROVIDER,
237
+ "primary_model": PRIMARY_MODEL,
238
+ "fallback_models": list(FALLBACK_MODELS),
239
+ "opencode_base": OPENCODE,
240
+ "opencode": {"ok": upstream_ok, "detail": "ok" if upstream_ok else upstream},
241
+ "temperature": TEMPERATURE_NOTE,
242
+ **{k: v for k, v in snapshot.items() if k != "opencode_base"},
243
+ }
244
+
245
+
246
+ @api.get("/v1/models")
247
+ def models() -> dict[str, Any]:
248
+ now = int(time.time())
249
+ return {
250
+ "object": "list",
251
+ "data": [
252
+ {"id": model, "object": "model", "owned_by": OPENCODE_PROVIDER}
253
+ for model in FALLBACK_MODELS
254
+ ]
255
+ + [
256
+ {"id": f"{OPENCODE_PROVIDER}/{model}", "object": "model", "owned_by": OPENCODE_PROVIDER}
257
+ for model in FALLBACK_MODELS
258
+ ],
259
+ }
260
+
261
+
262
+ @api.post("/v1/chat/completions")
263
+ async def chat(request: Request) -> Any:
264
+ try:
265
+ body = await request.json()
266
+ except Exception as exc:
267
+ raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(exc)[:160]}") from exc
268
+ if not isinstance(body.get("messages"), list):
269
+ raise HTTPException(status_code=400, detail="messages must be a list")
270
+ requested_model = str(body.get("model") or PRIMARY_MODEL)
271
+ if not is_allowed_model(requested_model):
272
+ raise HTTPException(status_code=404, detail=f"Unknown model: {requested_model}")
273
+ try:
274
+ payload = await run_in_threadpool(_chat_payload, body)
275
+ except ValueError as exc:
276
+ raise HTTPException(status_code=404, detail=str(exc)[:200]) from None
277
+ except RuntimeError as exc:
278
+ raise HTTPException(status_code=503, detail=str(exc)[:800]) from None
279
+ if not body.get("stream"):
280
+ return JSONResponse(payload)
281
+ content = payload["choices"][0]["message"]["content"]
282
+ chunk = {
283
+ "id": payload["id"],
284
+ "object": "chat.completion.chunk",
285
+ "created": payload["created"],
286
+ "model": payload["model"],
287
+ "choices": [{"index": 0, "delta": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
288
+ }
289
+
290
+ async def events(): # pragma: no cover - streaming passthrough
291
+ yield "data: " + json.dumps(chunk, ensure_ascii=False) + "\n\n"
292
+ yield "data: [DONE]\n\n"
293
+
294
+ return StreamingResponse(events(), media_type="text/event-stream")
295
+
296
+
297
+ @api.post("/v1/embeddings")
298
+ async def embeddings(request: Request) -> Any:
299
+ """Preserve local embeddings for SkillManager/QA via the 7860 endpoint."""
300
+ raw = await request.body()
301
+ forward = urllib.request.Request(
302
+ EMBED_BASE.rstrip("/") + "/embeddings", data=raw, method="POST"
303
+ )
304
+ forward.add_header("Content-Type", "application/json")
305
+ api_key = os.environ.get("API_KEY", "").strip()
306
+ if api_key:
307
+ forward.add_header("Authorization", f"Bearer {api_key}")
308
+ try:
309
+ with urllib.request.urlopen(forward, timeout=120) as response:
310
+ return JSONResponse(json.loads(response.read().decode("utf-8")))
311
+ except Exception as exc: # noqa: BLE001 - mapped to 503 without payload
312
+ raise HTTPException(
313
+ status_code=503, detail=f"embedding backend unavailable: {type(exc).__name__}"
314
+ ) from None
315
+
316
+
317
+ if __name__ == "__main__":
318
+ import uvicorn
319
+
320
+ # Loopback only: never expose OpenCode or this bridge externally.
321
+ uvicorn.run(api, host="127.0.0.1", port=PORT, log_level="warning")
tests/test_agent.py CHANGED
@@ -17,8 +17,8 @@ import urllib.request
17
 
18
  import tiktoken
19
 
20
- BASE = os.environ.get("BASE", "https://erinaldordso-programa.hf.space/v1").rstrip("/")
21
- MODEL = os.environ.get("MODEL", "qwen3-coder-30b-a3b-instruct")
22
  APIKEY = os.environ.get("APIKEY", "")
23
  PASS, FAIL = "PASS", "FAIL"
24
  results = []
@@ -81,7 +81,7 @@ def main():
81
 
82
  st, models = req("GET", "/models")
83
  ids = [m.get("id", "") for m in models.get("data", [])]
84
- check("models", st == 200 and any(MODEL in i or "qwen" in i.lower() for i in ids), f"ids={ids[:4]}")
85
 
86
  st, emb = req("POST", "/embeddings", {"model": "text-embedding-ada-002", "input": ["mine wood", "craft pickaxe"]})
87
  vecs = emb.get("data", [])
 
17
 
18
  import tiktoken
19
 
20
+ BASE = os.environ.get("BASE", "https://erinaldordso-minecraft-friends-ai-zerogpu.hf.space/v1").rstrip("/")
21
+ MODEL = os.environ.get("MODEL", "granite-4.0-h-tiny")
22
  APIKEY = os.environ.get("APIKEY", "")
23
  PASS, FAIL = "PASS", "FAIL"
24
  results = []
 
81
 
82
  st, models = req("GET", "/models")
83
  ids = [m.get("id", "") for m in models.get("data", [])]
84
+ check("models", st == 200 and any(MODEL in i or "granite" in i.lower() for i in ids), f"ids={ids[:4]}")
85
 
86
  st, emb = req("POST", "/embeddings", {"model": "text-embedding-ada-002", "input": ["mine wood", "craft pickaxe"]})
87
  vecs = emb.get("data", [])
tests/test_autonomy_stability.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stability of autonomous operation: bounded status reads, restart backoff,
2
+ and a single configurable local LLM path for Naldo_Bot.
3
+
4
+ Offline-safe: pure stdlib plus text-level assertions on the deployment files.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from unittest import mock
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(ROOT))
16
+
17
+ from voyager_stack import restart_backoff_after, tail_lines # noqa: E402
18
+ import gemini_bot_stack as gemini_module # noqa: E402
19
+ import minecraft_stack as mc_stack # noqa: E402
20
+ from minecraft_stack import _tail_lines as mc_tail_lines # noqa: E402
21
+
22
+
23
+ def test_tail_lines_matches_full_read_on_small_logs(tmp_path):
24
+ path = tmp_path / "voyager.log"
25
+ expected = [f"line {i} [llm_metric] payload" for i in range(500)]
26
+ path.write_text("\n".join(expected) + "\n", encoding="utf-8")
27
+ assert tail_lines(path) == expected
28
+ assert mc_tail_lines(path, 500) == expected
29
+
30
+
31
+ def test_tail_lines_is_bounded_on_long_logs(tmp_path):
32
+ path = tmp_path / "voyager.log"
33
+ total = 60000
34
+ with path.open("w", encoding="utf-8") as fh:
35
+ for i in range(total):
36
+ fh.write(f"line {i}\n")
37
+ window = tail_lines(path, 20000)
38
+ assert len(window) == 20000
39
+ assert window[0] == f"line {total - 20000}"
40
+ assert window[-1] == f"line {total - 1}"
41
+ assert tail_lines(tmp_path / "missing.log") == []
42
+ assert mc_tail_lines(tmp_path / "missing.log") == []
43
+
44
+
45
+ def test_restart_backoff_doubles_on_quick_crash_and_resets_when_healthy():
46
+ assert restart_backoff_after(5, 12.0) == 10
47
+ assert restart_backoff_after(60, 30.0) == 120
48
+ assert restart_backoff_after(120, 1.0) == 120
49
+ assert restart_backoff_after(120, 3600.0) == 5
50
+ assert restart_backoff_after(120, 600.0) == 5
51
+ assert restart_backoff_after(10, 599.9) == 20
52
+
53
+
54
+ def test_both_bots_share_the_same_backoff_policy():
55
+ assert gemini_module.restart_backoff_after is restart_backoff_after
56
+ assert gemini_module.tail_lines is tail_lines
57
+
58
+
59
+ def test_naldo_uses_only_the_configurable_local_endpoint():
60
+ stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
61
+ # Naldo generative brain is the loopback OpenCode bridge; 7860 stays only
62
+ # for local embeddings compatibility.
63
+ assert "NALDO_BRIDGE_BASE" in stack
64
+ assert "NALDO_BRAIN_PROFILE" in stack
65
+ assert "NALDO_PRIMARY_MODEL" in stack
66
+ assert "naldo-opencode" in stack or "NALDO_BRAIN_PROFILE" in stack
67
+ assert "granite-4.0-h-tiny" not in stack
68
+ runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
69
+ assert "http://127.0.0.1:7864/v1" in runner
70
+ assert "NALDO_BRAIN_PROFILE" in runner
71
+ assert "from naldo_opencode import" in runner
72
+ assert "naldo-granite" not in runner
73
+ assert "voyager.learn(reset_env=False)" in runner
74
+
75
+
76
+ def test_no_new_direct_llm_sdk_dependency():
77
+ requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").lower()
78
+ assert "google-generativeai" not in requirements
79
+ assert "google-genai" not in requirements
80
+ assert "anthropic" not in requirements
81
+ assert "openai==0.28.1" in requirements
82
+ assert "llama-cpp-python" in requirements
83
+
84
+
85
+ def test_local_endpoint_stays_cpu_only():
86
+ app = (ROOT / "app.py").read_text(encoding="utf-8")
87
+ assert "n_gpu_layers=0" in app
88
+ assert 'os.environ["CUDA_VISIBLE_DEVICES"] = ""' in app
89
+
90
+
91
+ def test_acceptance_script_defaults_match_this_deployment():
92
+ agent = (ROOT / "tests" / "test_agent.py").read_text(encoding="utf-8")
93
+ app = (ROOT / "app.py").read_text(encoding="utf-8")
94
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
95
+ base = re.search(r'BASE = os.environ.get\("BASE", "(.*?)"\)', agent).group(1)
96
+ model = re.search(r'MODEL = os.environ.get\("MODEL", "(.*?)"\)', agent).group(1)
97
+ assert base in readme, "acceptance BASE default must be the documented Space URL"
98
+ assert base.endswith("/v1")
99
+ assert model == "granite-4.0-h-tiny"
100
+ assert f'"{model}"' in app or f"'{model}'" in app or model in app
101
+
102
+
103
+ def test_boot_counters_survive_tail_window_and_reset_on_new_boot(tmp_path):
104
+ import inspect
105
+
106
+ # Wiring: every new minecraft boot must reset the counters.
107
+ assert "_reset_boot_counters" in inspect.getsource(
108
+ mc_stack.MinecraftStack._start_fabric
109
+ )
110
+ stack = mc_stack.MinecraftStack()
111
+ empty_log = tmp_path / "stack.log"
112
+ empty_log.write_text("log line without markers\n", encoding="utf-8")
113
+ with mock.patch.object(mc_stack, "_log", lambda *args, **kwargs: None), \
114
+ mock.patch.object(mc_stack, "LOG_PATH", empty_log):
115
+ stack._pump("minecraft", SimpleNamespace(stdout=[
116
+ "Starting minecraft server version 1.19\n",
117
+ "Failed to save player data for Naldo_Bot\n",
118
+ 'java.lang.NullPointerException: Cannot read field "respawnDimension" because "player" is null\n',
119
+ "Failed to save player data for IffyBinkie6134\n",
120
+ 'Done (1.234s)! For help, type "help"\n',
121
+ ]))
122
+ assert stack.failed_saves_since_boot == 2
123
+ assert stack.respawn_npe_since_boot == 1
124
+
125
+ stack._pump("viaproxy", SimpleNamespace(stdout=[
126
+ "Failed to save player data for x\n",
127
+ ]))
128
+ assert stack.failed_saves_since_boot == 2
129
+ assert stack.respawn_npe_since_boot == 1
130
+
131
+ # The tail window holds none of those lines, yet the counters behind
132
+ # the *_since_boot contract must still report them.
133
+ report = stack.status()
134
+ assert report["player_save"]["failed_saves_since_boot"] == 2
135
+ assert report["player_save"]["respawn_npe_since_boot"] == 1
136
+
137
+ stack._reset_boot_counters()
138
+ report = stack.status()
139
+ assert report["player_save"]["failed_saves_since_boot"] == 0
140
+ assert report["player_save"]["respawn_npe_since_boot"] == 0
141
+
142
+
143
+ if __name__ == "__main__":
144
+ import tempfile
145
+
146
+ with tempfile.TemporaryDirectory() as tmp:
147
+ tmp_path = Path(tmp)
148
+ test_tail_lines_matches_full_read_on_small_logs(tmp_path)
149
+ test_tail_lines_is_bounded_on_long_logs(tmp_path)
150
+ test_boot_counters_survive_tail_window_and_reset_on_new_boot(tmp_path)
151
+ test_restart_backoff_doubles_on_quick_crash_and_resets_when_healthy()
152
+ test_both_bots_share_the_same_backoff_policy()
153
+ test_naldo_uses_only_the_configurable_local_endpoint()
154
+ test_no_new_direct_llm_sdk_dependency()
155
+ test_local_endpoint_stays_cpu_only()
156
+ test_acceptance_script_defaults_match_this_deployment()
157
+ print("PASS autonomy stability")
tests/test_naldo_opencode.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Naldo_Bot on OpenCode only: offline unit tests.
2
+
3
+ No network, no model, no Minecraft. Pure stdlib plus text-level assertions
4
+ on the deployment files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import sys
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+ from unittest import mock
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ import naldo_opencode as brain # noqa: E402
19
+ import naldo_opencode_bridge as bridge_module # noqa: E402
20
+
21
+
22
+ def test_primary_and_fallbacks_are_all_opencode_free():
23
+ assert brain.OPENCODE_PROVIDER == "opencode"
24
+ assert brain.PRIMARY_MODEL == "muse-spark-1.3-contributor-free"
25
+ assert brain.FALLBACK_MODELS[0] == brain.PRIMARY_MODEL
26
+ assert set(brain.FALLBACK_MODELS) == {
27
+ "muse-spark-1.3-contributor-free",
28
+ "fledge-alpha-free",
29
+ "nemotron-3-ultra-free",
30
+ }
31
+ for model in brain.FALLBACK_MODELS:
32
+ assert brain.is_allowed_model(model)
33
+ assert brain.is_allowed_model(f"opencode/{model}")
34
+ lowered = model.lower()
35
+ for forbidden in ("granite", "gemini", "gpt", "claude", "anthropic", "qwen"):
36
+ assert forbidden not in lowered
37
+
38
+
39
+ def test_no_granite_gemini_fallback_anywhere():
40
+ for name in ("granite", "gemini", "gpt-", "anthropic", "claude"):
41
+ assert name not in {m.lower() for m in brain.FALLBACK_MODELS}
42
+ stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
43
+ assert "granite-4.0-h-tiny" not in stack, "Naldo stack must not reference Granite anymore"
44
+ bridge = (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
45
+ lowered = bridge.lower()
46
+ for forbidden in ("granite", "gemini", "anthropic"):
47
+ assert forbidden not in lowered, f"bridge leaked {forbidden}"
48
+ runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
49
+ # Gemini_Bot's own lock stays; Naldo's lock must not accept granite.
50
+ assert "naldo-granite" not in runner
51
+
52
+
53
+ def test_brain_lock_requires_opencode_profile_base_and_models():
54
+ runner = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
55
+ assert "NALDO_BRAIN_PROFILE" in runner or "naldo-opencode" in runner
56
+ assert "http://127.0.0.1:7864/v1" in runner
57
+ assert "muse-spark-1.3-contributor-free" in runner
58
+ assert "_naldo_model_allowed" in runner
59
+ # Rejected models: granite/gemini must not pass the predicate.
60
+ assert not brain.is_allowed_model("granite-4.0-h-tiny")
61
+ assert not brain.is_allowed_model("gemini-3.5-flash-lite")
62
+ assert not brain.is_allowed_model("gpt-4o")
63
+ assert brain.is_allowed_model("fledge-alpha-free")
64
+ assert brain.is_allowed_model("nemotron-3-ultra-free")
65
+
66
+
67
+ def test_bridge_formats_messages_without_confusing_roles():
68
+ messages = [
69
+ {"role": "system", "content": "You are a planner."},
70
+ {"role": "user", "content": "Do X"},
71
+ {"role": "assistant", "content": "Plan Y"},
72
+ {"role": "tool", "content": "tool output Z"},
73
+ {"role": "user", "content": "Now?", "tool_calls": [{"id": "1", "function": {"name": "f", "arguments": "{}"}}]},
74
+ ]
75
+ rendered = brain.build_opencode_prompt(messages)
76
+ assert "[SYSTEM]" in rendered["prompt"]
77
+ assert "[USER]" in rendered["prompt"]
78
+ assert "[ASSISTANT]" in rendered["prompt"]
79
+ assert "[TOOL]" in rendered["prompt"]
80
+ assert "You are a planner." in rendered["system"]
81
+ assert "TOOL_CALLS" in rendered["prompt"]
82
+ assert "text generator, not a file/shell executor" in rendered["prompt"]
83
+
84
+
85
+ def test_bridge_extracts_only_text_parts():
86
+ payload = {
87
+ "info": {"role": "assistant"},
88
+ "parts": [
89
+ {"type": "reasoning", "thinking": "secret plan"},
90
+ {"type": "text", "text": "hello "},
91
+ {"type": "tool", "name": "bash", "input": "rm -rf /"},
92
+ {"type": "text", "text": "world"},
93
+ ],
94
+ }
95
+ assert brain.extract_assistant_text(payload) == "hello world"
96
+ assert brain.extract_assistant_text({"parts": []}) == ""
97
+ assert brain.extract_assistant_text({}) == ""
98
+
99
+
100
+ def test_sessions_use_free_tier_compatible_tool_sandbox(tmp_path):
101
+ captured = {}
102
+
103
+ def fake_http(url, method="GET", body=None, timeout=0, retries=0):
104
+ captured.update({"url": url, "method": method, "body": body})
105
+ return {"id": "ses_test"}
106
+
107
+ with mock.patch.object(bridge_module, "OPENCODE_SESSION_DIR", tmp_path), \
108
+ mock.patch.object(bridge_module, "_http_json", side_effect=fake_http):
109
+ session_id = bridge_module._create_session("muse-spark-1.3-contributor-free")
110
+
111
+ assert session_id == "ses_test"
112
+ assert captured["method"] == "POST"
113
+ assert captured["url"].endswith("/session")
114
+ assert captured["body"]["model"] == {
115
+ "id": "muse-spark-1.3-contributor-free",
116
+ "providerID": "opencode",
117
+ }
118
+ assert captured["body"]["location"] == {"directory": str(tmp_path)}
119
+ assert "permission" not in captured["body"], "custom permissions trigger Zen free-tier 403"
120
+
121
+ message = brain.opencode_message_body("muse-spark-1.3-contributor-free", "sys", "do X")
122
+ assert message["tools"] == {}, "empty tools map is the verified free-tier tool sandbox"
123
+ assert "temperature" not in message
124
+
125
+
126
+ def test_loopback_only():
127
+ assert brain.is_loopback_url(brain.OPENCODE_BASE)
128
+ assert brain.is_loopback_url(brain.BRIDGE_BASE)
129
+ assert "0.0.0.0" not in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
130
+ stack = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
131
+ assert "opencode_serve_command(opencode_binary" in stack
132
+ assert 'host="127.0.0.1"' in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
133
+ assert "shell=True" not in (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
134
+ assert "shell=True" not in (ROOT / "naldo_opencode.py").read_text(encoding="utf-8")
135
+ serve = brain.opencode_serve_command("/tmp/opencode-local", 7865)
136
+ assert serve[:3] == ["/tmp/opencode-local", "serve", "--hostname"]
137
+ assert "127.0.0.1" in serve
138
+ assert "0.0.0.0" not in serve
139
+
140
+
141
+ def test_no_credential_dependency_or_leak(tmp_path):
142
+ bridge_src = (ROOT / "naldo_opencode_bridge.py").read_text(encoding="utf-8")
143
+ assert "auth.json" not in bridge_src
144
+ assert 'os.environ.get("OPENCODE_API_KEY"' not in bridge_src
145
+ assert 'os.environ["OPENCODE_API_KEY"]' not in bridge_src
146
+
147
+ base = {"OPENCODE_API_KEY": "must-not-survive", "PATH": "x"}
148
+ env = brain.opencode_subprocess_env(base, tmp_path / "home")
149
+ assert "OPENCODE_API_KEY" not in env
150
+ assert env["PATH"] == "x"
151
+ assert env["HOME"] == str(tmp_path / "home")
152
+ assert env["XDG_CONFIG_HOME"] == str(tmp_path / "home" / "config")
153
+ assert env["XDG_DATA_HOME"] == str(tmp_path / "home" / "data")
154
+ assert base["OPENCODE_API_KEY"] == "must-not-survive", "parent env must not be mutated"
155
+ assert "TEMPERATURE_NOTE" in (ROOT / "naldo_opencode.py").read_text(encoding="utf-8")
156
+
157
+
158
+ def test_local_opencode_install_is_pinned_and_non_root(tmp_path):
159
+ calls = []
160
+
161
+ def fake_run(args, **kwargs):
162
+ calls.append(list(args))
163
+ return SimpleNamespace(returncode=0, stdout="installed")
164
+
165
+ with mock.patch.object(brain, "_probe_opencode", side_effect=["", brain.OPENCODE_VERSION]), \
166
+ mock.patch.object(brain.subprocess, "run", side_effect=fake_run):
167
+ binary, version = brain.ensure_opencode_installed(tmp_path / "cli")
168
+
169
+ assert version == brain.OPENCODE_VERSION
170
+ assert Path(binary) == brain.opencode_binary_path(tmp_path / "cli")
171
+ install = calls[0]
172
+ assert "-g" not in install and "--global" not in install
173
+ assert "--prefix" in install
174
+ assert install[install.index("--prefix") + 1] == str(tmp_path / "cli")
175
+ assert brain.OPENCODE_NPM_SPEC in install
176
+
177
+
178
+ def test_max_tokens_is_telemetry_not_output_truncation():
179
+ long_answer = "function build() { return 1; }\n" * 300
180
+ with mock.patch.object(bridge_module, "_create_session", return_value="ses_x"), \
181
+ mock.patch.object(bridge_module, "_send_message", return_value=long_answer):
182
+ result = bridge_module._chat_payload({
183
+ "model": brain.PRIMARY_MODEL,
184
+ "messages": [{"role": "user", "content": "write program"}],
185
+ "temperature": 0,
186
+ "max_tokens": 1,
187
+ })
188
+ assert result["choices"][0]["message"]["content"] == long_answer
189
+ assert bridge_module.state["last_requested_max_tokens"] == 1
190
+
191
+
192
+ def test_supervisor_start_stop_contract():
193
+ stack_src = (ROOT / "voyager_stack.py").read_text(encoding="utf-8")
194
+ for marker in (
195
+ "_ensure_naldo_brain",
196
+ "_brain_healthy",
197
+ "_stop_brain",
198
+ "brain_status",
199
+ "_terminate_naldo_child",
200
+ "ensure_opencode_installed",
201
+ "NALDO_BRIDGE_BASE",
202
+ "NALDO_BRAIN_PROFILE",
203
+ "NALDO_PRIMARY_MODEL",
204
+ "opencode_serve_command",
205
+ "opencode_subprocess_env",
206
+ ):
207
+ assert marker in stack_src, f"supervisor marker missing: {marker}"
208
+ assert "without Granite fallback" in stack_src or "without granite" in stack_src.lower()
209
+ tree = ast.parse(stack_src)
210
+ methods = set()
211
+ for node in ast.walk(tree):
212
+ if isinstance(node, ast.ClassDef) and node.name == "VoyagerStack":
213
+ for child in node.body:
214
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
215
+ methods.add(child.name)
216
+ for required in ("_ensure_naldo_brain", "_brain_healthy", "_stop_brain", "brain_status", "stop", "start_async"):
217
+ assert required in methods
218
+ assert "self.stop_event.wait(7)" in stack_src
219
+ assert "OpenCode/bridge died mid-run; stopping Naldo immediately" in stack_src
220
+ assert "opencode_serve_command(opencode_binary" in stack_src
221
+
222
+ runner_src = (ROOT / "voyager_runner.py").read_text(encoding="utf-8")
223
+ assert "Naldo OpenCode brain module unavailable" in runner_src
224
+ assert "except Exception as exc" in runner_src
225
+
226
+
227
+ def test_supervisor_stop_terminates_all_children():
228
+ import voyager_stack as stack_module
229
+
230
+ stack = stack_module.VoyagerStack()
231
+ naldo = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(naldo, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
232
+ bridge = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(bridge, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
233
+ opencode = SimpleNamespace(poll=lambda: None, terminate=lambda: setattr(opencode, "t", True), kill=lambda: None, wait=lambda timeout=None: 0)
234
+ stack.proc = naldo # type: ignore[assignment]
235
+ stack.bridge_proc = bridge # type: ignore[assignment]
236
+ stack.opencode_proc = opencode # type: ignore[assignment]
237
+ naldo.pid = 111
238
+ bridge.pid = 222
239
+ opencode.pid = 333
240
+ stack.stop_event.set()
241
+ with mock.patch.object(stack_module, "_wait_tcp", return_value=True):
242
+ with mock.patch.object(stack, "brain_status", return_value={"ok": True}):
243
+ with mock.patch.object(stack, "_mineflayer_status", return_value={"ready": False}):
244
+ status = stack.stop()
245
+ assert getattr(naldo, "t", False) is True
246
+ assert getattr(bridge, "t", False) is True
247
+ assert getattr(opencode, "t", False) is True
248
+ assert status["state"] == "stopped"
249
+
250
+
251
+ def test_gemini_bot_untouched():
252
+ stack_src = (ROOT / "gemini_bot_stack.py").read_text(encoding="utf-8")
253
+ assert "7863" in stack_src
254
+ assert "gemini-3.5-flash-lite" in stack_src
255
+ assert "opencode" not in stack_src.lower()
256
+ assert "7864" not in stack_src
257
+ assert "7865" not in stack_src
258
+
259
+
260
+ if __name__ == "__main__":
261
+ test_primary_and_fallbacks_are_all_opencode_free()
262
+ test_no_granite_gemini_fallback_anywhere()
263
+ test_brain_lock_requires_opencode_profile_base_and_models()
264
+ test_bridge_formats_messages_without_confusing_roles()
265
+ test_bridge_extracts_only_text_parts()
266
+ test_sessions_created_with_denied_permissions()
267
+ test_loopback_only()
268
+ test_no_credential_dependency_or_leak()
269
+ test_supervisor_start_stop_contract()
270
+ test_gemini_bot_untouched()
271
+ print("PASS naldo opencode")
voyager_runner.py CHANGED
@@ -5,8 +5,6 @@ import json
5
  import logging
6
  import shutil
7
  import sys
8
- import threading
9
- import time
10
  from pathlib import Path
11
 
12
  VOYAGER_HOME = Path(os.environ.get("VOYAGER_HOME", "/tmp/voyager-runtime/Voyager"))
@@ -15,14 +13,10 @@ if str(VOYAGER_HOME) not in sys.path:
15
 
16
  from voyager import Voyager # noqa: E402
17
  from voyager_verification import (
18
- quarantine_invalid_skills, quarantine_placeholder_skills,
19
- quarantine_template_skills, task_spec,
20
  )
21
  from voyager_creative_critic import VerifiedCreativeCritic
22
- from voyager_runtime_contract import (
23
- ADAPTED_UPSTREAM_FILES, ADDED_UPSTREAM_FILES, attest_original_runtime,
24
- read_divergence, record_exit as record_exit_report, write_attestation,
25
- )
26
  from voyager_metrics import attach_role_metrics
27
 
28
 
@@ -30,70 +24,47 @@ def env(name: str, default: str) -> str:
30
  return os.environ.get(name, default)
31
 
32
 
33
- def record_exit(bot_username: str, exc: BaseException) -> None:
34
- """Record why this runner died, so the next restart is diagnosable.
35
-
36
- The logic lives in voyager_runtime_contract so it can be executed by a test:
37
- this module imports the pinned upstream package, which a unit test should not
38
- need. Measured need: Gemini_Bot reached restart_count 9 with the only visible
39
- symptom being "runner=1".
40
- """
41
- record_exit_report(VOYAGER_HOME, bot_username, exc)
42
-
43
-
44
  def main() -> None:
45
  from voyager_learning import refresh_learning_state, start_learning_watcher
46
  # Surface Mineflayer's subprocess logs as well as Python tracebacks.
47
  logging.basicConfig(level=logging.INFO, format="[%(name)s] %(levelname)s %(message)s")
48
- model = env("VOYAGER_MODEL", "qwen3-coder-30b-a3b-instruct")
49
- aux_model = env("VOYAGER_AUX_MODEL", "granite-4.0-h-tiny")
50
- # Sem default local: o cérebro mudou de casa. Um fallback para
51
- # http://127.0.0.1:7860/v1 apontaria para este Space, que agora roda só o
52
- # servidor de Minecraft e responde 503 em /v1/embeddings. O default é o
53
- # host real; o brain lock abaixo ainda derruba o processo se um deploy
54
- # apontar para outro provedor.
55
- base_url = env("VOYAGER_OPENAI_BASE", "https://erinaldordso-mine.hf.space/v1")
56
  api_key = env("VOYAGER_API_KEY", "local-voyager")
57
  ckpt_dir = env("VOYAGER_CKPT_DIR", "/persist/minecraft-friends/voyager/ckpt-20260927-flat-y64-v1")
58
  bot_username = env("MC_USERNAME", "Naldo_Bot")
59
  brain_profile = env("VOYAGER_BRAIN_PROFILE", "")
60
- # Escape hatch. O lock derruba o processo quando a tupla não bate, e o
61
- # watchdog reinicia em loop — um deploy ruim deixa o bot parado. Com
62
- # VOYAGER_BRAIN_LOCK=off a checagem inteira é pulada, então dá para subir
63
- # o bot mesmo com o cérebro remoto fora do ar.
64
- brain_lock = env("VOYAGER_BRAIN_LOCK", "enforce") != "off"
65
  if bot_username == "Naldo_Bot":
66
- # O cérebro saiu deste Space: quem serve é o Space `Erinaldordso/Mine`.
67
- # A URL precisa ser a pública, porque os dois Spaces não compartilham
68
- # rede. O lock continua valendo para o caso de um deploy silenciosamente
69
- # apontar para outro provedor.
70
- #
71
- # O modelo auxiliar (QA do curriculum e descrição de skill) também vem
72
- # de lá — o Mine serve os dois, para que nenhum peso de modelo sobre no
73
- # Space onde roda o jogo.
74
- expected = (
75
- "naldo-qwen3-coder",
76
- "https://erinaldordso-mine.hf.space/v1",
77
- "qwen3-coder-30b-a3b-instruct",
78
- "granite-4.0-h-tiny",
79
- )
 
 
 
 
 
 
 
80
  elif bot_username == "Gemini_Bot":
81
- if env("GEMINI_BOT_BACKEND", "mine") == "mine":
82
- expected = ("gemini-mine-qwen3-coder", "https://erinaldordso-mine.hf.space/v1", "qwen3-coder-30b-a3b-instruct", "granite-4.0-h-tiny")
83
- else:
84
- expected = ("gemini-cli", "http://127.0.0.1:7863/v1", "gemini-3.5-flash-lite", "gemini-3.5-flash-lite")
85
  else:
86
  expected = None
87
- if expected and brain_lock and (brain_profile, base_url, model, aux_model) != expected:
88
  raise RuntimeError(
89
  f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
90
- f"model={model!r}, aux={aux_model!r}; expected={expected!r}"
91
- )
92
- if expected and not brain_lock:
93
- print(
94
- f"[voyager] brain lock DESLIGADO (VOYAGER_BRAIN_LOCK=off); aceitando "
95
- f"profile={brain_profile!r} base={base_url!r} model={model!r} aux={aux_model!r}",
96
- flush=True,
97
  )
98
  ckpt = Path(ckpt_dir)
99
  required_resume_defaults = {
@@ -236,45 +207,20 @@ def main() -> None:
236
  critic_agent_temperature=0,
237
  skill_manager_model_name=aux_model,
238
  skill_manager_temperature=0,
239
- # Documented departure from the upstream code default (5), from the
240
- # upstream FAQ: "It's highly recommended to use GPT-4 ... If you insist
241
- # on using GPT-3.5, it is essential to configure it with
242
- # skill_manager_retrieval_top_k <= 2 to reduce the context length of the
243
- # prompt."
244
- #
245
- # The brain is now Qwen3-Coder-30B-A3B with a 32768-token window, so the
246
- # context pressure the FAQ describes is much lower than it was at 8192.
247
- # The limit is kept until a benchmark shows that retrieving more skills
248
- # pays for the extra prefill; it only costs compositional reuse, not
249
- # correctness. Overridable without a rebuild through VOYAGER_SKILL_TOP_K.
250
- skill_manager_retrieval_top_k=int(env("VOYAGER_SKILL_TOP_K", "2")),
251
  openai_api_request_timeout=int(env("OPENAI_API_REQUEST_TIMEOUT", "240")),
252
  ckpt_dir=ckpt_dir,
253
  resume=resume,
254
  )
255
- # Install the declared Critic wrapper *before* attesting. Attesting first
256
- # certified the upstream CriticAgent and then replaced it, so the report
257
- # named a decision method that was no longer the one being called. The
258
- # attestation now resolves the wrapper's upstream delegate and attests that.
259
  attach_role_metrics(voyager, bot_username)
260
  voyager.critic_agent = VerifiedCreativeCritic(voyager.critic_agent)
261
- # The boot verified the real divergence against the declared contract before
262
- # npm install and published the measurement. Carry it into the attestation so
263
- # `checkout_verified` cannot be read as "the tree is pristine".
264
- divergence = read_divergence(VOYAGER_HOME)
265
- attestation = attest_original_runtime(voyager, VOYAGER_HOME)
266
- attestation["declared_adapted_files"] = dict(ADAPTED_UPSTREAM_FILES)
267
- attestation["declared_added_files"] = dict(ADDED_UPSTREAM_FILES)
268
- attestation["measured_divergence"] = divergence
269
  write_attestation(env("VOYAGER_ATTESTATION_FILE", ""), attestation, bot_username)
270
  print(
271
  f"[voyager] verified original engine {attestation['upstream_commit']} for "
272
  f"{bot_username}; decision methods attested: "
273
- f"{', '.join(attestation['decision_methods_verified'])}; declared wrappers: "
274
- f"{', '.join(attestation['declared_wrappers']) or 'none'}; divergence vs the "
275
- f"declared contract: {len(divergence.get('modified', []))} adapted, "
276
- f"{len(divergence.get('removed', []))} removed, "
277
- f"{len(divergence.get('added', []))} added",
278
  flush=True,
279
  )
280
 
@@ -308,108 +254,10 @@ def main() -> None:
308
 
309
  # Supported upstream option: retain the live connection and observe every step.
310
  # The existing disconnect recovery and process watchdog still reconnect on failure.
311
- #
312
- # A transient provider failure must not tear down the Minecraft connection.
313
- # Upstream learn() lets an LLM error propagate: it leaves this process, the
314
- # finally block below kills the Mineflayer child, and the watchdog restarts
315
- # the runner -- so the bot visibly leaves and rejoins the server on every
316
- # quota blip. Measured on Gemini_Bot: three leave/rejoin cycles in 3.5
317
- # minutes, each one an openai.error.ServiceUnavailableError raised from
318
- # curriculum.run_qa. Retry in place with bounded backoff instead.
319
- # learn(reset_env=False) keeps the live connection, and because upstream
320
- # sets self.resume = True after the first call, every retry takes the soft
321
- # reset path and keeps the inventory.
322
- max_attempts = int(env("VOYAGER_LEARN_MAX_ATTEMPTS", "0")) # 0 = retry until it works
323
- delay = float(env("VOYAGER_LEARN_BACKOFF_SECONDS", "15"))
324
- delay_cap = float(env("VOYAGER_LEARN_BACKOFF_CAP_SECONDS", "300"))
325
- attempt = 0
326
-
327
- # A watchdog for the failure mode that retry-forever hides: the process is
328
- # alive, every error counter is zero, and nothing progresses because
329
- # learn() is parked on a wedged dependency. Retrying cannot help there --
330
- # the loop is blocked, not failing -- so the only recovery is to exit and
331
- # let the supervisor start a fresh runner. Measured: the bot sat in that
332
- # state for 45+ minutes with zero model calls. Progress is read from the
333
- # checkpoint files (not from a log line), so it is immune to the log being
334
- # absent, and the watchdog only arms once a task has actually completed,
335
- # so a cold start is never killed.
336
- stall_seconds = float(env("VOYAGER_STALL_SECONDS", "900"))
337
-
338
- def _progress_fingerprint():
339
- # Every file that changes when the loop advances. These are the same
340
- # three voyager_learning._signature() watches, so the watchdog and the
341
- # learning watcher agree on what "progress" means.
342
- #
343
- # Watching only completed_tasks.json made the watchdog blind to a bot
344
- # that is working but failing: it would recycle a runner that was
345
- # mid-attempt. A failure is progress -- the bot acted and the Critic
346
- # judged -- so it resets the clock. Only a loop that does NOTHING for the
347
- # whole window is a stall. A new skill counts too, because the skill
348
- # library only grows when a task actually succeeded.
349
- parts = []
350
- for path in (
351
- ckpt / "curriculum" / "completed_tasks.json",
352
- ckpt / "curriculum" / "failed_tasks.json",
353
- ckpt / "skill" / "skills.json",
354
- ):
355
- try:
356
- stat = path.stat()
357
- parts.append((path.name, stat.st_mtime_ns, stat.st_size))
358
- except OSError:
359
- parts.append((path.name, None, None))
360
- return tuple(parts)
361
-
362
- def _watch_stall():
363
- last_seen = _progress_fingerprint()
364
- last_change = time.time()
365
- while True:
366
- time.sleep(60)
367
- current = _progress_fingerprint()
368
- if current != last_seen:
369
- last_seen = current
370
- last_change = time.time()
371
- continue
372
- idle = time.time() - last_change
373
- if idle >= stall_seconds:
374
- # os._exit is deliberate: the learning thread is blocked inside
375
- # a call that will not return, so a cooperative shutdown would
376
- # itself hang. Exiting hard lets the Space watchdog observe it.
377
- print(
378
- f"[voyager] STALL WATCHDOG: no completed task in {idle:.0f}s "
379
- f"(limit {stall_seconds:.0f}s); exiting so the supervisor can "
380
- f"restart the runner",
381
- flush=True,
382
- )
383
- sys.stdout.flush()
384
- sys.stderr.flush()
385
- os._exit(75)
386
-
387
  try:
388
  quarantine_invalid_skills(voyager, ckpt)
389
  quarantine_template_skills(voyager, ckpt)
390
- quarantine_placeholder_skills(voyager, ckpt)
391
- threading.Thread(target=_watch_stall, daemon=True, name="voyager-stall-watch").start()
392
- while True:
393
- attempt += 1
394
- try:
395
- voyager.learn(reset_env=False)
396
- break
397
- except Exception as exc:
398
- if max_attempts and attempt >= max_attempts:
399
- print(
400
- f"[voyager] learning loop failed {attempt} times; giving up: "
401
- f"{type(exc).__name__}: {exc}",
402
- flush=True,
403
- )
404
- raise
405
- print(
406
- f"[voyager] learning loop raised {type(exc).__name__}: {exc}; "
407
- f"keeping the Minecraft connection and retrying in {delay:.0f}s "
408
- f"(attempt {attempt})",
409
- flush=True,
410
- )
411
- time.sleep(delay)
412
- delay = min(delay * 2, delay_cap)
413
  finally:
414
  # Upstream's non-daemon log-monitor thread keeps Python alive after a
415
  # fatal curriculum error. Stop only this runner's Mineflayer child so
@@ -425,8 +273,4 @@ def main() -> None:
425
 
426
 
427
  if __name__ == "__main__":
428
- try:
429
- main()
430
- except BaseException as exc: # noqa: BLE001 - record it, then let the exit code stand
431
- record_exit(os.environ.get("MC_USERNAME", "unknown"), exc)
432
- raise
 
5
  import logging
6
  import shutil
7
  import sys
 
 
8
  from pathlib import Path
9
 
10
  VOYAGER_HOME = Path(os.environ.get("VOYAGER_HOME", "/tmp/voyager-runtime/Voyager"))
 
13
 
14
  from voyager import Voyager # noqa: E402
15
  from voyager_verification import (
16
+ quarantine_invalid_skills, quarantine_template_skills, task_spec,
 
17
  )
18
  from voyager_creative_critic import VerifiedCreativeCritic
19
+ from voyager_runtime_contract import attest_original_runtime, write_attestation
 
 
 
20
  from voyager_metrics import attach_role_metrics
21
 
22
 
 
24
  return os.environ.get(name, default)
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
27
  def main() -> None:
28
  from voyager_learning import refresh_learning_state, start_learning_watcher
29
  # Surface Mineflayer's subprocess logs as well as Python tracebacks.
30
  logging.basicConfig(level=logging.INFO, format="[%(name)s] %(levelname)s %(message)s")
31
+ model = env("VOYAGER_MODEL", "muse-spark-1.3-contributor-free")
32
+ aux_model = env("VOYAGER_AUX_MODEL", "muse-spark-1.3-contributor-free")
33
+ base_url = env("VOYAGER_OPENAI_BASE", "http://127.0.0.1:7864/v1")
 
 
 
 
 
34
  api_key = env("VOYAGER_API_KEY", "local-voyager")
35
  ckpt_dir = env("VOYAGER_CKPT_DIR", "/persist/minecraft-friends/voyager/ckpt-20260927-flat-y64-v1")
36
  bot_username = env("MC_USERNAME", "Naldo_Bot")
37
  brain_profile = env("VOYAGER_BRAIN_PROFILE", "")
 
 
 
 
 
38
  if bot_username == "Naldo_Bot":
39
+ try:
40
+ from naldo_opencode import (
41
+ ALLOWED_MODELS as NALDO_ALLOWED_MODELS,
42
+ BRAIN_PROFILE as NALDO_BRAIN_PROFILE,
43
+ BRIDGE_BASE as NALDO_BRIDGE_BASE,
44
+ is_allowed_model as _naldo_model_allowed,
45
+ )
46
+ except Exception as exc:
47
+ raise RuntimeError(f"Naldo OpenCode brain module unavailable: {exc}") from exc
48
+ if (
49
+ brain_profile != NALDO_BRAIN_PROFILE
50
+ or base_url != NALDO_BRIDGE_BASE
51
+ or not _naldo_model_allowed(model)
52
+ or not _naldo_model_allowed(aux_model)
53
+ ):
54
+ raise RuntimeError(
55
+ f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
56
+ f"model={model!r}, aux={aux_model!r}; Naldo requires profile={NALDO_BRAIN_PROFILE!r}, "
57
+ f"base={NALDO_BRIDGE_BASE!r}, models in {sorted(NALDO_ALLOWED_MODELS)} (opencode provider only)"
58
+ )
59
+ expected = None
60
  elif bot_username == "Gemini_Bot":
61
+ expected = ("gemini-cli", "http://127.0.0.1:7863/v1", "gemini-3.5-flash-lite", "gemini-3.5-flash-lite")
 
 
 
62
  else:
63
  expected = None
64
+ if expected and (brain_profile, base_url, model, aux_model) != expected:
65
  raise RuntimeError(
66
  f"Brain lock rejected {bot_username}: profile={brain_profile!r}, base={base_url!r}, "
67
+ f"model={model!r}, aux={aux_model!r}"
 
 
 
 
 
 
68
  )
69
  ckpt = Path(ckpt_dir)
70
  required_resume_defaults = {
 
207
  critic_agent_temperature=0,
208
  skill_manager_model_name=aux_model,
209
  skill_manager_temperature=0,
210
+ skill_manager_retrieval_top_k=5,
 
 
 
 
 
 
 
 
 
 
 
211
  openai_api_request_timeout=int(env("OPENAI_API_REQUEST_TIMEOUT", "240")),
212
  ckpt_dir=ckpt_dir,
213
  resume=resume,
214
  )
215
+ attestation = attest_original_runtime(voyager, VOYAGER_HOME)
 
 
 
216
  attach_role_metrics(voyager, bot_username)
217
  voyager.critic_agent = VerifiedCreativeCritic(voyager.critic_agent)
 
 
 
 
 
 
 
 
218
  write_attestation(env("VOYAGER_ATTESTATION_FILE", ""), attestation, bot_username)
219
  print(
220
  f"[voyager] verified original engine {attestation['upstream_commit']} for "
221
  f"{bot_username}; decision methods attested: "
222
+ f"{', '.join(attestation['decision_methods_verified'])}; official Critic with "
223
+ f"independent world evidence",
 
 
 
224
  flush=True,
225
  )
226
 
 
254
 
255
  # Supported upstream option: retain the live connection and observe every step.
256
  # The existing disconnect recovery and process watchdog still reconnect on failure.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  try:
258
  quarantine_invalid_skills(voyager, ckpt)
259
  quarantine_template_skills(voyager, ckpt)
260
+ voyager.learn(reset_env=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  finally:
262
  # Upstream's non-daemon log-monitor thread keeps Python alive after a
263
  # fatal curriculum error. Stop only this runner's Mineflayer child so
 
273
 
274
 
275
  if __name__ == "__main__":
276
+ main()
 
 
 
 
voyager_stack.py CHANGED
The diff for this file is too large to render. See raw diff