From 2d867cefca9e16cc68f5be3f4a88e3c3f89ef46e Mon Sep 17 00:00:00 2001 From: marcos Date: Wed, 5 Aug 2026 14:49:08 +0000 Subject: [PATCH] docs: implementation plan for /ia grounding --- docs/plans/2026-08-05-ia-grounding.md | 1634 +++++++++++++++++++++++++ 1 file changed, 1634 insertions(+) create mode 100644 docs/plans/2026-08-05-ia-grounding.md diff --git a/docs/plans/2026-08-05-ia-grounding.md b/docs/plans/2026-08-05-ia-grounding.md new file mode 100644 index 0000000..0818561 --- /dev/null +++ b/docs/plans/2026-08-05-ia-grounding.md @@ -0,0 +1,1634 @@ +# /ia Grounding and Feedback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Stop `/ia` inventing Minecraft mechanics by grounding every answer in the Portuguese Minecraft Wiki and the server's own recipe data, and add reactions, feedback and a correction loop around it. + +**Architecture:** `Ai` becomes an orchestrator. The pieces it calls — text sanitising, wiki retrieval, MiniMax transport, context assembly, per-player memory, recipe lookup — move into their own classes so each can be unit tested without a running server. Network access hides behind a `Fetcher` interface that tests replace with a stub. + +**Tech Stack:** Java 25, Paper 26.2 API, Gson (bundled with Paper), `java.net.http.HttpClient`, JUnit 5, Maven in Docker. + +**Read first:** `docs/plans/2026-08-05-ia-improvements-design.md` — it records what was measured and why each choice was made. + +**Hard constraint:** build and deploy the jar, but **do NOT restart the Minecraft server**. The new jar stays dormant until the next restart. + +--- + +## Background the executing engineer needs + +Facts established by measurement. Do not re-litigate them; do not "simplify" them away. + +1. **`pt.minecraft.wiki` returns HTTP 403 to a default user agent.** Every request must send an identifying `User-Agent`. This is MediaWiki policy, not a bug. +2. **MiniMax returns HTTP 200 on application-level errors.** The real outcome is in `base_resp.status_code`, where non-zero means failure. +3. **MiniMax models emit hidden reasoning that counts against `max_tokens`.** Too small a budget yields *empty* `content`, not a short answer. This is why answers must use 1200 and why free-text term extraction failed. +4. **`tool_choice` must force the call.** With `auto`, the model skipped the search on exactly the question it had already answered wrong twice. +5. **`explaintext` strips tables**, so wiki text never contains crafting or brewing recipes. Recipes come from `Bukkit.recipeIterator()` instead. +6. **Replies sometimes contain foreign words** mid-sentence (`搭档`, `contiennent`). They must be caught and retried. + +--- + +### Task 1: Add a test harness + +Nothing in this repo is tested yet. Everything after this task depends on it. + +**Files:** +- Modify: `pom.xml:25-32` +- Create: `src/test/java/dev/marcospaulo/canalhandia/SanityTest.java` + +**Step 1: Add JUnit 5 to the pom** + +In `pom.xml`, replace the `` block with: + +```xml + + + io.papermc.paper + paper-api + 26.2.build.92-stable + provided + + + org.junit.jupiter + junit-jupiter + 5.11.3 + test + + +``` + +**Step 2: Write a test that proves the harness runs** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SanityTest { + @Test + void harnessRuns() { + assertEquals(2, 1 + 1); + } +} +``` + +**Step 3: Run it** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B test +``` + +Expected: `Tests run: 1, Failures: 0`. If surefire cannot find the test, the JUnit dependency is wrong — fix that before continuing. + +**Step 4: Commit** + +```bash +git add pom.xml src/test +git commit -m "test: add JUnit 5 harness" +``` + +--- + +### Task 2: Move sanitising into its own class and guard foreign scripts + +`Ai.sanitise` already strips markdown, emoji, `§` and leading slashes. It does **not** catch the foreign-word leakage, and it lives inside a Bukkit-dependent class. + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/AiText.java` +- Create: `src/test/java/dev/marcospaulo/canalhandia/AiTextTest.java` +- Modify: `src/main/java/dev/marcospaulo/canalhandia/Ai.java` — delete `sanitise` and `trim`, call `AiText` instead + +**Step 1: Write the failing tests** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class AiTextTest { + + @Test + void stripsMarkdownEmphasis() { + assertEquals("use magma cream", + AiText.sanitise("use **magma cream**", 500)); + } + + @Test + void stripsEmojiAndSectionSigns() { + assertEquals("boa sorte", + AiText.sanitise("boa sorte \uD83D\uDE04 \u00a7c", 500)); + } + + @Test + void stripsLeadingSlashesSoRepliesCannotLookLikeCommands() { + assertEquals("give me diamonds", + AiText.sanitise("//give me diamonds", 500)); + } + + @Test + void keepsPortugueseAccents() { + assertEquals("poção de resistência ao fogo", + AiText.sanitise("poção de resistência ao fogo", 500)); + } + + @Test + void truncatesToLimit() { + assertEquals("abc…", AiText.sanitise("abcdefg", 3)); + } + + // The model leaked "搭档" and "contiennent" into Portuguese answers. + @Test + void detectsCjk() { + assertTrue(AiText.hasForeignScript("te aceite como搭档")); + } + + @Test + void plainPortugueseIsNotForeign() { + assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos")); + } +} +``` + +**Step 2: Run and watch it fail** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B test +``` + +Expected: compilation failure, `cannot find symbol: class AiText`. + +**Step 3: Write `AiText`** + +```java +package dev.marcospaulo.canalhandia; + +import java.util.regex.Pattern; + +/** + * Text guards for model replies. + * + *

Pure functions, deliberately free of Bukkit, so they can be tested without + * a server. + */ +final class AiText { + + /** + * Scripts that should never appear in a Portuguese answer. The model has + * been observed dropping single Chinese words mid-sentence. + */ + private static final Pattern FOREIGN = Pattern.compile( + "[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{IsHangul}\\p{IsCyrillic}\\p{IsArabic}]"); + + private AiText() { + } + + static boolean hasForeignScript(String text) { + return text != null && FOREIGN.matcher(text).find(); + } + + /** + * Makes a reply safe and readable in chat: no colour codes to forge server + * messages, no markdown or emoji (chat renders neither, and emoji are empty + * boxes on Bedrock), no leading slash that could read as a command. + */ + static String sanitise(String raw, int max) { + String text = raw.replace('§', ' ') + .replaceAll("[\\r\\n]+", " ") + .replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1") + .replaceAll("`{1,3}([^`]+)`{1,3}", "$1") + .replaceAll("^#{1,6}\\s+", "") + .replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "") + .replaceAll("\\s{2,}", " ") + .trim(); + while (text.startsWith("/")) { + text = text.substring(1).trim(); + } + if (text.length() > max) { + text = text.substring(0, max).trim() + "…"; + } + return text; + } + + /** Shortens text for a log line. */ + static String forLog(String text) { + return text.length() > 300 ? text.substring(0, 300) + "…" : text; + } +} +``` + +**Step 4: Run the tests** + +Expected: `Tests run: 8, Failures: 0`. + +**Step 5: Point `Ai` at it** + +In `Ai.java` delete the `sanitise` and `trim` methods and the now-unused +`java.util.regex` imports, then replace the two call sites with +`AiText.sanitise(...)` and `AiText.forLog(...)`. + +**Step 6: Build and commit** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +git add -A +git commit -m "refactor: extract AiText and detect foreign-script leakage" +``` + +--- + +### Task 3: A `Fetcher` seam so HTTP can be tested + +Every later task needs network calls that tests must not make. + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/Fetcher.java` +- Create: `src/main/java/dev/marcospaulo/canalhandia/HttpFetcher.java` + +**Step 1: Write the interface** + +```java +package dev.marcospaulo.canalhandia; + +import java.io.IOException; + +/** The one place the plugin talks to the network, so tests can replace it. */ +interface Fetcher { + + /** GET a URL, returning the body. */ + String get(String url) throws IOException, InterruptedException; + + /** POST JSON with a bearer token, returning the body. */ + String postJson(String url, String json, String bearer) + throws IOException, InterruptedException; +} +``` + +**Step 2: Write the real implementation** + +```java +package dev.marcospaulo.canalhandia; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +final class HttpFetcher implements Fetcher { + + /** + * pt.minecraft.wiki answers 403 to a default user agent — MediaWiki policy + * requires callers to identify themselves. + */ + private static final String AGENT = + "Canalhandia-Minecraft-Bot/1.0 (https://marcospaulo.dev.br)"; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + private final int timeoutSeconds; + + HttpFetcher(int timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + @Override + public String get(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("User-Agent", AGENT) + .GET() + .build(); + return body(http.send(request, HttpResponse.BodyHandlers.ofString())); + } + + @Override + public String postJson(String url, String json, String bearer) + throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("Content-Type", "application/json") + .header("User-Agent", AGENT) + .header("Authorization", "Bearer " + bearer) + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + return body(http.send(request, HttpResponse.BodyHandlers.ofString())); + } + + private String body(HttpResponse response) throws IOException { + if (response.statusCode() / 100 != 2) { + throw new IOException("HTTP " + response.statusCode() + ": " + + AiText.forLog(response.body())); + } + return response.body(); + } +} +``` + +**Step 3: Build and commit** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +git add -A +git commit -m "feat: add Fetcher seam with an identifying user agent" +``` + +--- + +### Task 4: Wiki retrieval + +Search for a term, then pull the **full** article text. Not `exintro` — the lead paragraph alone made the model answer "não tenho certeza" to questions it could otherwise answer. + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/Wiki.java` +- Create: `src/test/java/dev/marcospaulo/canalhandia/WikiTest.java` + +**Step 1: Write the failing tests** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class WikiTest { + + /** Returns canned bodies keyed by a substring of the requested URL. */ + private static Fetcher stub(Map byUrlFragment) { + return new Fetcher() { + @Override + public String get(String url) { + for (Map.Entry e : byUrlFragment.entrySet()) { + if (url.contains(e.getKey())) { + return e.getValue(); + } + } + throw new AssertionError("unexpected url: " + url); + } + + @Override + public String postJson(String url, String json, String bearer) { + throw new UnsupportedOperationException(); + } + }; + } + + @Test + void findsArticleAndReturnsFullText() { + Wiki wiki = new Wiki(stub(Map.of( + "list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}", + "prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\"," + + "\"extract\":\"Um camelo pode ser equipado com uma sela.\"}}}}")), + 7000); + + Wiki.Article article = wiki.lookup("Camelo"); + + assertNotNull(article); + assertEquals("Camelo", article.title()); + assertTrue(article.text().contains("sela")); + } + + @Test + void returnsNullWhenNothingMatches() { + Wiki wiki = new Wiki(stub(Map.of( + "list=search", "{\"query\":{\"search\":[]}}")), 7000); + assertNull(wiki.lookup("asdfghjkl")); + } + + @Test + void truncatesLongArticles() { + String longText = "x".repeat(9000); + Wiki wiki = new Wiki(stub(Map.of( + "list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}", + "prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\"," + + "\"extract\":\"" + longText + "\"}}}}")), 100); + assertEquals(100, wiki.lookup("T").text().length()); + } + + @Test + void cachesByTitleSoRepeatQuestionsCostOneFetch() { + int[] calls = {0}; + Fetcher counting = new Fetcher() { + @Override + public String get(String url) { + calls[0]++; + return url.contains("list=search") + ? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}" + : "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}"; + } + + @Override + public String postJson(String url, String json, String bearer) { + throw new UnsupportedOperationException(); + } + }; + Wiki wiki = new Wiki(counting, 7000); + wiki.lookup("Creeper"); + int afterFirst = calls[0]; + wiki.lookup("Creeper"); + assertEquals(afterFirst, calls[0], "second lookup should be served from cache"); + } + + @Test + void networkFailureYieldsNullRatherThanThrowing() { + Wiki wiki = new Wiki(new Fetcher() { + @Override + public String get(String url) throws java.io.IOException { + throw new java.io.IOException("boom"); + } + + @Override + public String postJson(String url, String json, String bearer) { + throw new UnsupportedOperationException(); + } + }, 7000); + assertNull(wiki.lookup("Camelo")); + } +} +``` + +**Step 2: Run and watch it fail** — `cannot find symbol: class Wiki`. + +**Step 3: Implement `Wiki`** + +```java +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Reads articles from the Portuguese Minecraft Wiki. + * + *

Full article text, not {@code exintro}: grounded on lead paragraphs alone + * the model answered "não tenho certeza" to questions it could otherwise get + * right, because the specifics live further down the page. + * + *

Note that {@code explaintext} drops tables, so crafting and brewing + * recipes never appear here. Those come from {@link RecipeBook} instead. + */ +final class Wiki { + + private static final String API = "https://pt.minecraft.wiki/api.php"; + + record Article(String title, String text) { + } + + private final Fetcher fetcher; + private final int maxChars; + /** Newest-last, evicted at 200 entries. Lost on restart, which is fine. */ + private final Map cache = new LinkedHashMap<>(); + + Wiki(Fetcher fetcher, int maxChars) { + this.fetcher = fetcher; + this.maxChars = maxChars; + } + + /** The best article for a search term, or null if there is none. */ + Article lookup(String term) { + Article cached = cache.get(term.toLowerCase()); + if (cached != null) { + return cached; + } + try { + String title = search(term); + if (title == null) { + return null; + } + String text = extract(title); + if (text == null || text.isBlank()) { + return null; + } + Article article = new Article(title, trim(text)); + remember(term, article); + return article; + } catch (Exception e) { + // A wiki outage must not break the answer; the caller falls back + // to answering without a source and says so. + return null; + } + } + + private String search(String term) throws Exception { + String url = API + "?action=query&list=search&format=json&srlimit=1&srsearch=" + + URLEncoder.encode(term, StandardCharsets.UTF_8); + JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject(); + if (!root.has("query")) { + return null; + } + JsonArray hits = root.getAsJsonObject("query").getAsJsonArray("search"); + return hits.isEmpty() ? null : hits.get(0).getAsJsonObject().get("title").getAsString(); + } + + private String extract(String title) throws Exception { + String url = API + "?action=query&prop=extracts&explaintext=1&format=json&titles=" + + URLEncoder.encode(title, StandardCharsets.UTF_8); + JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject(); + if (!root.has("query")) { + return null; + } + JsonObject pages = root.getAsJsonObject("query").getAsJsonObject("pages"); + for (String key : pages.keySet()) { + JsonObject page = pages.getAsJsonObject(key); + if (page.has("extract")) { + return page.get("extract").getAsString(); + } + } + return null; + } + + private String trim(String text) { + String collapsed = text.replaceAll("\n{2,}", "\n").trim(); + return collapsed.length() > maxChars ? collapsed.substring(0, maxChars) : collapsed; + } + + private void remember(String term, Article article) { + cache.put(term.toLowerCase(), article); + while (cache.size() > 200) { + cache.remove(cache.keySet().iterator().next()); + } + } +} +``` + +**Step 4: Run the tests** — expected `Tests run: 5, Failures: 0` for `WikiTest`. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: fetch full articles from the Portuguese Minecraft Wiki" +``` + +--- + +### Task 5: Recipes from the running server + +Wiki text never contains recipes. The server does. + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java` + +There is no unit test here: `Bukkit.recipeIterator()` needs a running server. Verify it in Task 13 instead. + +**Step 1: Implement** + +```java +package dev.marcospaulo.canalhandia; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.ShapelessRecipe; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; + +/** + * Crafting recipes read from the running server. + * + *

The wiki cannot supply these: MediaWiki's {@code explaintext} strips + * tables, and every recipe lives in one. The server's own data is also + * authoritative for this exact version, which the wiki may not be. + */ +final class RecipeBook { + + private RecipeBook() { + } + + /** True if the question looks like it is asking how to make something. */ + static boolean isRecipeQuestion(String question) { + String q = question.toLowerCase(Msg.PT_BR); + return q.contains("receita") || q.contains("como faz") || q.contains("como faço") + || q.contains("como fazer") || q.contains("como criar") + || q.contains("como craft") || q.contains("crafta"); + } + + /** + * A plain-text description of how to make whatever the question names, or + * null if no material in the question has a recipe. + */ + static String describe(String question) { + Material material = guessMaterial(question); + if (material == null) { + return null; + } + List lines = new ArrayList<>(); + Iterator it = Bukkit.recipeIterator(); + while (it.hasNext() && lines.size() < 3) { + Recipe recipe = it.next(); + if (recipe.getResult().getType() != material) { + continue; + } + if (recipe instanceof ShapedRecipe shaped) { + lines.add(describeShaped(shaped)); + } else if (recipe instanceof ShapelessRecipe shapeless) { + lines.add(describeShapeless(shapeless)); + } + } + return lines.isEmpty() ? null + : "Receitas do servidor para " + material.name().toLowerCase(Locale.ROOT) + + ":\n" + String.join("\n", lines); + } + + private static String describeShaped(ShapedRecipe recipe) { + StringBuilder out = new StringBuilder("Bancada, formato "); + for (String row : recipe.getShape()) { + out.append('[').append(row).append(']'); + } + out.append(" onde "); + recipe.getIngredientMap().forEach((symbol, stack) -> { + if (stack != null) { + out.append(symbol).append('=') + .append(stack.getType().name().toLowerCase(Locale.ROOT)).append(' '); + } + }); + return out.toString().trim(); + } + + private static String describeShapeless(ShapelessRecipe recipe) { + List parts = new ArrayList<>(); + for (ItemStack stack : recipe.getIngredientList()) { + parts.add(stack.getType().name().toLowerCase(Locale.ROOT)); + } + return "Sem formato: " + String.join(" + ", parts); + } + + /** Finds a material whose name appears in the question. Longest match wins. */ + private static Material guessMaterial(String question) { + String q = question.toLowerCase(Locale.ROOT).replace('ç', 'c'); + Material best = null; + for (Material material : Material.values()) { + if (!material.isItem()) { + continue; + } + String name = material.name().toLowerCase(Locale.ROOT).replace('_', ' '); + if (name.length() > 3 && q.contains(name) + && (best == null || name.length() > best.name().length())) { + best = material; + } + } + return best; + } +} +``` + +**Step 2: Build and commit** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +git add -A +git commit -m "feat: read crafting recipes from the running server" +``` + +--- + +### Task 6: MiniMax client with a forced tool call + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/MiniMax.java` +- Create: `src/test/java/dev/marcospaulo/canalhandia/MiniMaxTest.java` + +**Step 1: Write the failing tests** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class MiniMaxTest { + + private static Fetcher replying(String body) { + return new Fetcher() { + @Override + public String get(String url) { + throw new UnsupportedOperationException(); + } + + @Override + public String postJson(String url, String json, String bearer) { + return body; + } + }; + } + + @Test + void readsAnswerContent() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"content\":\"pólvora\"}}]}")); + assertEquals("pólvora", api.answer("k", "m", List.of(), 1200, 0.3)); + } + + // MiniMax reports application errors with HTTP 200 and a non-zero base_resp. + @Test + void treatsNonZeroBaseRespAsFailure() { + MiniMax api = new MiniMax(replying( + "{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key\"},\"choices\":[]}")); + assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); + } + + // Hidden reasoning can consume the whole budget, leaving content empty. + @Test + void emptyContentIsNullNotBlank() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"content\":\"\"}}]}")); + assertNull(api.answer("k", "m", List.of(), 1200, 0.3)); + } + + @Test + void readsForcedToolArgument() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":" + + "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\"Camelo\\\"}\"}}]}}]}")); + assertEquals("Camelo", api.searchTerm("k", "m", "como pego um camelo?")); + } + + @Test + void missingToolCallYieldsNull() { + MiniMax api = new MiniMax(replying( + "{\"choices\":[{\"message\":{\"content\":\"sei lá\"}}]}")); + assertNull(api.searchTerm("k", "m", "oi")); + } +} +``` + +**Step 2: Run and watch it fail.** + +**Step 3: Implement `MiniMax`** + +```java +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.List; + +/** + * MiniMax chat-completions client. + * + *

Two calls per grounded question: {@link #searchTerm} makes the model name + * a wiki article, then {@link #answer} answers with that article in context. + */ +final class MiniMax { + + static final String URL = "https://api.minimax.io/v1/text/chatcompletion_v2"; + + /** One message in the request. */ + record Msg(String role, String content) { + } + + private final Fetcher fetcher; + + MiniMax(Fetcher fetcher) { + this.fetcher = fetcher; + } + + /** + * Asks the model which wiki article to read. + * + *

The call is forced with {@code tool_choice} rather than left to + * {@code auto}. Given the choice the model skipped the search on exactly + * the questions it was most likely to get wrong. A tool argument is also + * structured output, so unlike a free-text reply it survives the model's + * hidden reasoning eating the token budget. + */ + String searchTerm(String key, String model, String question) { + JsonObject parameters = new JsonObject(); + parameters.addProperty("type", "object"); + JsonObject termo = new JsonObject(); + termo.addProperty("type", "string"); + termo.addProperty("description", "Termo curto do jogo, ex: Camelo, Creeper"); + JsonObject properties = new JsonObject(); + properties.add("termo", termo); + parameters.add("properties", properties); + JsonArray required = new JsonArray(); + required.add("termo"); + parameters.add("required", required); + + JsonObject function = new JsonObject(); + function.addProperty("name", "buscar_wiki"); + function.addProperty("description", "Busca um artigo na Minecraft Wiki em português."); + function.add("parameters", parameters); + JsonObject tool = new JsonObject(); + tool.addProperty("type", "function"); + tool.add("function", function); + JsonArray tools = new JsonArray(); + tools.add(tool); + + JsonObject chosenName = new JsonObject(); + chosenName.addProperty("name", "buscar_wiki"); + JsonObject choice = new JsonObject(); + choice.addProperty("type", "function"); + choice.add("function", chosenName); + + JsonObject body = base(model, List.of( + new Msg("system", "Você escolhe qual artigo da Minecraft Wiki consultar."), + new Msg("user", question)), 500, 0.0); + body.add("tools", tools); + body.add("tool_choice", choice); + + JsonObject message = message(post(key, body)); + if (message == null || !message.has("tool_calls")) { + return null; + } + JsonArray calls = message.getAsJsonArray("tool_calls"); + if (calls.isEmpty()) { + return null; + } + String arguments = calls.get(0).getAsJsonObject() + .getAsJsonObject("function").get("arguments").getAsString(); + JsonObject parsed = JsonParser.parseString(arguments).getAsJsonObject(); + if (!parsed.has("termo")) { + return null; + } + String term = parsed.get("termo").getAsString().trim(); + return term.isEmpty() ? null : term; + } + + /** Answers a question. Returns null on any failure, including empty content. */ + String answer(String key, String model, List messages, int maxTokens, double temperature) { + JsonObject message = message(post(key, base(model, messages, maxTokens, temperature))); + if (message == null || !message.has("content") || message.get("content").isJsonNull()) { + return null; + } + String content = message.get("content").getAsString(); + return content.isBlank() ? null : content; + } + + private JsonObject base(String model, List messages, int maxTokens, double temperature) { + JsonArray array = new JsonArray(); + for (Msg msg : messages) { + JsonObject object = new JsonObject(); + object.addProperty("role", msg.role()); + object.addProperty("content", msg.content()); + array.add(object); + } + JsonObject body = new JsonObject(); + body.addProperty("model", model); + body.add("messages", array); + body.addProperty("max_tokens", maxTokens); + body.addProperty("temperature", temperature); + return body; + } + + private JsonObject post(String key, JsonObject body) { + try { + return JsonParser.parseString(fetcher.postJson(URL, body.toString(), key)) + .getAsJsonObject(); + } catch (Exception e) { + return null; + } + } + + /** The first choice's message, or null if the response reported a failure. */ + private JsonObject message(JsonObject root) { + if (root == null) { + return null; + } + // HTTP 200 does not mean success here. + if (root.has("base_resp")) { + JsonObject base = root.getAsJsonObject("base_resp"); + if (base.has("status_code") && base.get("status_code").getAsInt() != 0) { + return null; + } + } + if (!root.has("choices")) { + return null; + } + JsonArray choices = root.getAsJsonArray("choices"); + if (choices.isEmpty()) { + return null; + } + JsonObject first = choices.get(0).getAsJsonObject(); + return first.has("message") ? first.getAsJsonObject("message") : null; + } +} +``` + +**Step 4: Run the tests** — expected `Tests run: 5, Failures: 0` for `MiniMaxTest`. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: MiniMax client with forced tool call for wiki term selection" +``` + +--- + +### Task 7: Per-player conversation memory + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/Conversations.java` +- Create: `src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java` + +**Step 1: Write the failing tests** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class ConversationsTest { + + @Test + void keepsRecentExchangesForFollowUps() { + Conversations c = new Conversations(3, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "onde acho diamante?", "abaixo do Y 16"); + assertEquals(2, c.history(id).size()); + assertEquals("onde acho diamante?", c.history(id).get(0).content()); + } + + @Test + void dropsOldestBeyondLimit() { + Conversations c = new Conversations(2, 10); + UUID id = UUID.randomUUID(); + c.remember(id, "q1", "a1"); + c.remember(id, "q2", "a2"); + c.remember(id, "q3", "a3"); + assertEquals(4, c.history(id).size()); + assertEquals("q2", c.history(id).get(0).content()); + } + + @Test + void expiresAfterTheWindow() { + Conversations c = new Conversations(3, 0); + UUID id = UUID.randomUUID(); + c.remember(id, "q", "a"); + assertTrue(c.history(id).isEmpty()); + } + + @Test + void playersDoNotShareHistory() { + Conversations c = new Conversations(3, 10); + UUID a = UUID.randomUUID(); + UUID b = UUID.randomUUID(); + c.remember(a, "q", "resposta de A"); + assertTrue(c.history(b).isEmpty()); + } +} +``` + +**Step 2: Run and watch it fail.** + +**Step 3: Implement** + +```java +package dev.marcospaulo.canalhandia; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Short per-player memory, so a follow-up like "e no nether?" makes sense. + * + *

Deliberately small and forgetful: the point is continuity within one + * exchange, not a transcript. + */ +final class Conversations { + + private record Entry(MiniMax.Msg msg, long at) { + } + + private final int maxExchanges; + private final long windowMillis; + private final Map> byPlayer = new HashMap<>(); + + Conversations(int maxExchanges, int windowMinutes) { + this.maxExchanges = Math.max(0, maxExchanges); + this.windowMillis = windowMinutes * 60_000L; + } + + void remember(UUID player, String question, String answer) { + Deque entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>()); + long now = System.currentTimeMillis(); + entries.addLast(new Entry(new MiniMax.Msg("user", question), now)); + entries.addLast(new Entry(new MiniMax.Msg("assistant", answer), now)); + while (entries.size() > maxExchanges * 2) { + entries.removeFirst(); + } + } + + /** Recent messages still inside the window, oldest first. */ + List history(UUID player) { + Deque entries = byPlayer.get(player); + List out = new ArrayList<>(); + if (entries == null) { + return out; + } + long cutoff = System.currentTimeMillis() - windowMillis; + entries.removeIf(entry -> entry.at() < cutoff); + for (Entry entry : entries) { + out.add(entry.msg()); + } + return out; + } + + void forget(UUID player) { + byPlayer.remove(player); + } +} +``` + +**Step 4: Run the tests** — expected `Tests run: 4, Failures: 0`. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: short per-player conversation memory" +``` + +--- + +### Task 8: Settings, profiles and context files + +**Files:** +- Modify: `src/main/java/dev/marcospaulo/canalhandia/Settings.java` — the `--- IA ---` block +- Modify: `src/main/resources/config.yml` — the `ia:` section +- Create: `src/main/resources/contexto.yml` +- Create: `src/main/java/dev/marcospaulo/canalhandia/AiProfile.java` + +**Step 1: Add the profile enum** + +```java +package dev.marcospaulo.canalhandia; + +/** + * How much work to do per question. + * + *

This trades latency, not money: the plan's token allowance is far beyond + * what a small server can spend, but every added context token makes chat feel + * slower. + */ +enum AiProfile { + + /** Skip the wiki round trip. Fast, ungrounded. */ + ECONOMICO, + /** Consult the wiki. Slower, accurate. */ + PRECISO; + + static AiProfile byKey(String key) { + for (AiProfile profile : values()) { + if (profile.name().equalsIgnoreCase(key)) { + return profile; + } + } + return PRECISO; + } +} +``` + +**Step 2: Extend `Settings`** + +Add to the `--- IA ---` section, and change `aiMaxTokens`'s default from 300 to +1200 — at 300 the model's hidden reasoning consumed the budget and players got +empty answers: + +```java + AiProfile aiProfile() { + return AiProfile.byKey(plugin.getConfig().getString("ia.perfil", "PRECISO")); + } + + void aiProfile(AiProfile profile) { + set("ia.perfil", profile.name()); + } + + /** How much article text to send. Lead paragraphs alone were not enough. */ + int aiWikiChars() { + return Math.max(500, plugin.getConfig().getInt("ia.wiki-caracteres", 7000)); + } + + int aiMemoryExchanges() { + return Math.max(0, plugin.getConfig().getInt("ia.memoria-perguntas", 3)); + } + + int aiMemoryMinutes() { + return Math.max(1, plugin.getConfig().getInt("ia.memoria-minutos", 10)); + } + + String aiServerContext() { + return String.join(" ", plugin.getConfig().getStringList("ia.contexto")); + } +``` + +**Step 3: Extend `config.yml`** + +Append inside the existing `ia:` block: + +```yaml + # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte). + # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com + # /ia perfil . + perfil: PRECISO + + # Quantos caracteres do artigo da wiki enviar. Só a introdução não basta: + # a receita e os detalhes ficam mais abaixo na página. + wiki-caracteres: 7000 + + # Memória curta por jogador, para perguntas de seguimento ("e no nether?"). + memoria-perguntas: 3 + memoria-minutos: 10 + + # Fatos do servidor que a IA nunca teria como saber. Uma linha por fato. + contexto: + - "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)." + - "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto." + - "O servidor tem BlueMap, voice chat e Distant Horizons." +``` + +**Step 4: Build and commit** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +git add -A +git commit -m "feat: IA profiles, server context and a workable token ceiling" +``` + +--- + +### Task 9: Corrections store + +**Files:** +- Create: `src/main/java/dev/marcospaulo/canalhandia/Corrections.java` +- Create: `src/test/java/dev/marcospaulo/canalhandia/CorrectionsTest.java` + +Keep the matching logic pure and testable; file I/O stays in a thin wrapper. + +**Step 1: Write the failing test** + +```java +package dev.marcospaulo.canalhandia; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class CorrectionsTest { + + @Test + void matchesOnSharedSignificantWords() { + List all = List.of( + new Corrections.Entry("como pegar um camelo", + "camelos são mansos, basta pôr uma sela")); + assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size()); + } + + @Test + void ignoresUnrelatedCorrections() { + List all = List.of( + new Corrections.Entry("como pegar um camelo", "…")); + assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty()); + } + + @Test + void shortWordsDoNotCreateMatches() { + List all = List.of( + new Corrections.Entry("o que e um creeper", "explode")); + assertTrue(Corrections.matching(all, "o que e um zumbi").isEmpty()); + } +} +``` + +**Step 2: Run and watch it fail.** + +**Step 3: Implement** + +```java +package dev.marcospaulo.canalhandia; + +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Operator corrections, injected when a new question resembles one that was + * answered wrongly before. + * + *

This is the cheap alternative to fine-tuning: a wrong answer becomes + * context, so the same mistake stops recurring. + */ +final class Corrections { + + record Entry(String question, String answer) { + } + + private final File file; + private final List entries = new ArrayList<>(); + + Corrections(File file) { + this.file = file; + load(); + } + + void load() { + entries.clear(); + if (!file.exists()) { + return; + } + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + for (String key : yaml.getKeys(false)) { + String question = yaml.getString(key + ".pergunta"); + String answer = yaml.getString(key + ".resposta"); + if (question != null && answer != null) { + entries.add(new Entry(question, answer)); + } + } + } + + void add(String question, String answer) { + entries.add(new Entry(question, answer)); + YamlConfiguration yaml = new YamlConfiguration(); + for (int i = 0; i < entries.size(); i++) { + yaml.set("c" + i + ".pergunta", entries.get(i).question()); + yaml.set("c" + i + ".resposta", entries.get(i).answer()); + } + try { + yaml.save(file); + } catch (Exception e) { + throw new IllegalStateException("não consegui gravar " + file, e); + } + } + + List all() { + return List.copyOf(entries); + } + + /** Corrections sharing at least two significant words with the question. */ + static List matching(List all, String question) { + Set asked = significantWords(question); + List out = new ArrayList<>(); + for (Entry entry : all) { + Set known = significantWords(entry.question()); + known.retainAll(asked); + if (known.size() >= 2) { + out.add(entry); + } + } + return out; + } + + private static Set significantWords(String text) { + Set words = new HashSet<>(); + for (String word : text.toLowerCase(Locale.ROOT).split("[^\\p{L}0-9]+")) { + // Short words are almost all articles and prepositions in Portuguese. + if (word.length() > 4) { + words.add(word); + } + } + return words; + } +} +``` + +**Step 4: Run the tests** — expected `Tests run: 3, Failures: 0`. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: operator corrections injected into similar questions" +``` + +--- + +### Task 10: Rewrite `Ai` as the orchestrator + +**Files:** +- Modify: `src/main/java/dev/marcospaulo/canalhandia/Ai.java` (substantial rewrite) + +**Step 1: Replace the body of `ask`'s async section** + +The pipeline, in order. Keep the existing permission, cooldown, daily-cap and +pending-guard checks untouched at the top. + +```java + /** Builds the messages for one question. */ + private java.util.List compose(Player asker, String question, + Settings settings, StringBuilder sourceNote) { + java.util.List messages = new java.util.ArrayList<>(); + messages.add(new MiniMax.Msg("system", settings.aiInstructions())); + + String serverContext = settings.aiServerContext(); + if (!serverContext.isBlank()) { + messages.add(new MiniMax.Msg("system", "Sobre este servidor: " + serverContext)); + } + + for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) { + messages.add(new MiniMax.Msg("system", + "Correção registrada por um operador. Pergunta parecida: \"" + + entry.question() + "\" Resposta correta: " + entry.answer())); + } + + // Recipes never appear in wiki text: explaintext drops tables. + if (RecipeBook.isRecipeQuestion(question)) { + String recipes = RecipeBook.describe(question); + if (recipes != null) { + messages.add(new MiniMax.Msg("system", recipes)); + } + } + + messages.addAll(conversations.history(asker.getUniqueId())); + messages.add(new MiniMax.Msg("user", question)); + return messages; + } +``` + +and the async body: + +```java + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + String answer = null; + try { + StringBuilder note = new StringBuilder(); + java.util.List messages = compose(asker, prompt, settings, note); + + if (settings.aiProfile() == AiProfile.PRECISO) { + String term = api.searchTerm(key, settings.aiModel(), prompt); + Wiki.Article article = term == null ? null : wiki.lookup(term); + if (article != null) { + messages.add(messages.size() - 1, new MiniMax.Msg("system", + "Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n" + + article.text())); + } + } + + answer = api.answer(key, settings.aiModel(), messages, + settings.aiMaxTokens(), settings.aiTemperature()); + + // Hidden reasoning can swallow the budget, and the model + // occasionally drops a foreign word mid-sentence. Both are + // worth one retry before giving up. + if (answer == null || AiText.hasForeignScript(answer)) { + answer = api.answer(key, settings.aiModel(), messages, + settings.aiMaxTokens() * 2, 0.1); + } + if (answer != null && AiText.hasForeignScript(answer)) { + plugin.getLogger().warning("Resposta descartada por idioma estrangeiro."); + answer = null; + } + } catch (Exception e) { + plugin.getLogger().warning("Falha na chamada à IA: " + e); + } + String finalAnswer = answer; + Bukkit.getScheduler().runTask(plugin, () -> { + pending.remove(id); + deliver(id, prompt, finalAnswer, settings, isPrivate); + }); + }); +``` + +**Step 2: Have `deliver` record memory, honour privacy and attach reactions** + +```java + private void deliver(UUID askerId, String question, String answer, + Settings settings, boolean isPrivate) { + Player asker = Bukkit.getPlayer(askerId); + if (answer == null || answer.isBlank()) { + if (asker != null) { + Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes."); + } + return; + } + String clean = AiText.sanitise(answer, settings.aiMaxAnswer()); + conversations.remember(askerId, question, clean); + lastAnswer = new Answered(askerId, question, clean); + + Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + .append(Component.text(clean, NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)); + if (isPrivate || !settings.aiPublic()) { + if (asker != null) { + asker.sendMessage(message); + } + return; + } + Bukkit.broadcast(message); + plugin.openAiReactions(askerId); + } +``` + +Add the field `private Answered lastAnswer;` and +`record Answered(UUID asker, String question, String answer) {}` so `/ia corrigir` +has something to correct. + +**Step 3: Build** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: ground answers in the wiki, recipes, corrections and memory" +``` + +--- + +### Task 11: Commands, reactions and feedback + +**Files:** +- Modify: `src/main/resources/plugin.yml` +- Modify: `src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java` +- Modify: `src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java` + +**Step 1: Register the commands and permissions** + +In `plugin.yml`, under `commands:` + +```yaml + iap: + description: Pergunta para a IA, resposta só para você. + usage: /iap + aliases: [iaprivado] +``` + +and under `permissions:` + +```yaml + canalhandia.ia.privado: + description: Permite perguntar em privado com /iap. + default: true + canalhandia.ia.corrigir: + description: Permite registrar correções para as respostas da IA. + default: op + canalhandia.ia.perfil: + description: Permite trocar o perfil da IA entre economico e preciso. + default: op +``` + +Register `iap` in `Canalhandia.onEnable`'s command list. + +**Step 2: Route the subcommands** + +In `CanalhandiaCommand.onCommand` add `case "iap" -> ia(sender, args, true);` and +give `ia(...)` a `boolean isPrivate` parameter. Inside `ia(...)`, before +treating arguments as a question, handle: + +- `perfil ` — requires `canalhandia.ia.perfil`, calls + `settings.aiProfile(...)`, replies with the new profile. +- `corrigir ` — requires `canalhandia.ia.corrigir`, takes the + last answered question and stores the operator's text via `Corrections.add`. +- `feedback ruim` — any player; flags the last answer so operators see it in + `/canalhandia status`. + +**Step 3: Attach reactions to answers** + +Add to `Canalhandia`: + +```java + /** Opens a reaction set for the answer just broadcast, reusing the chat buttons. */ + void openAiReactions(UUID asker) { + if (!settings.reactionsEnabled()) { + return; + } + Reactions reactions = new Reactions(nextId++, List.of( + new ReactionDef("util", "[👍]", "[UTIL]", "legal"), + new ReactionDef("errado", "[❌]", "[ERRADO]", "errado"))); + liveReactions = reactions; + remember(reactions); + reactions.show(); + broadcastPerPlatform(bedrock -> Component.text(" ") + .append(reactions.buttons(bedrock))); + } +``` + +Add `errado` to the config's `reacoes` block so the typed `/errado` shortcut +resolves, and register `errado` in `plugin.yml`. + +**Step 4: Show IA state in `/canalhandia status`** + +Extend the existing `ia` line with the profile and the count of stored +corrections. + +**Step 5: Build and commit** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +git add -A +git commit -m "feat: /iap, reactions on answers, feedback and /ia corrigir" +``` + +--- + +### Task 12: Full test run and README + +**Files:** +- Modify: `README.md` + +**Step 1: Run everything** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B test +``` + +Expected: all tests pass. Do not proceed with failures. + +**Step 2: Document the module in `README.md`** — commands, permissions, the +profile switch, where the key lives, and the note that `contexto.yml` is the +place to put server facts. + +**Step 3: Commit** + +```bash +git add -A +git commit -m "docs: document the IA module" +``` + +--- + +### Task 13: Deploy dormant, verify nothing restarts + +**The server must NOT be restarted.** The new jar sits beside the running one +and is picked up whenever the server next restarts on its own. + +**Step 1: Build the final jar** + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +``` + +**Step 2: Copy it in, with the ownership the JVM needs** + +The server runs as `crafty` (uid 1000, gid 0). A root-owned file is unreadable +to it. + +```bash +P=crafty-controller-788486f84-9825t +S=6e39a8b2-300b-42d6-8139-f397c23e461b +microk8s kubectl cp target/Canalhandia-1.0.0.jar \ + minecraft/$P:/crafty/servers/$S/plugins/Canalhandia-1.0.0.jar +microk8s kubectl exec -n minecraft $P -- \ + chown 1000:0 /crafty/servers/$S/plugins/Canalhandia-1.0.0.jar +``` + +**Step 3: Merge the new config keys into the live config** + +Append the new `ia:` keys (`perfil`, `wiki-caracteres`, `memoria-*`, `contexto`) +to `plugins/Canalhandia/config.yml` and raise `max-tokens` to 1200, keeping a +`.bak` copy first. Chown the result to `1000:0`. + +**Step 4: Confirm the server was not restarted** + +```bash +microk8s kubectl exec -n minecraft $P -- \ + bash -lc "grep -c 'Done (' /crafty/servers/$S/logs/latest.log" +``` + +Expected: the same count as before the deploy. A higher number means the server +restarted, which this plan forbids. + +**Step 5: Push** + +```bash +T=$(cat ~/.claude/.gitea-skills-token) +git push "http://gitea_admin:$T@100.74.17.70:30000/gitea_admin/canalhandia.git" HEAD:main +``` + +**Step 6: Verify after the next natural restart** — not now. Then: +`/canalhandia status` shows the profile; ask the four questions from the design +doc and check the camel, mob farm and creeper answers contain the facts they +previously got wrong.