Files
canalhandia/docs/plans/2026-08-05-ia-grounding.md
T
2026-08-05 14:49:08 +00:00

52 KiB

/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 <dependencies> block with:

  <dependencies>
    <dependency>
      <groupId>io.papermc.paper</groupId>
      <artifactId>paper-api</artifactId>
      <version>26.2.build.92-stable</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.11.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

Step 2: Write a test that proves the harness runs

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

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

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

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

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

package dev.marcospaulo.canalhandia;

import java.util.regex.Pattern;

/**
 * Text guards for model replies.
 *
 * <p>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

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

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

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<String> 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

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

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<String, String> byUrlFragment) {
        return new Fetcher() {
            @Override
            public String get(String url) {
                for (Map.Entry<String, String> 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 failcannot find symbol: class Wiki.

Step 3: Implement Wiki

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.
 *
 * <p>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.
 *
 * <p>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<String, Article> 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

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

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.
 *
 * <p>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<String> lines = new ArrayList<>();
        Iterator<Recipe> 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<String> 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

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

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

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.
 *
 * <p>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.
     *
     * <p>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<Msg> 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<Msg> 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

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

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

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.
 *
 * <p>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<UUID, Deque<Entry>> 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<Entry> 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<MiniMax.Msg> history(UUID player) {
        Deque<Entry> entries = byPlayer.get(player);
        List<MiniMax.Msg> 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

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

package dev.marcospaulo.canalhandia;

/**
 * How much work to do per question.
 *
 * <p>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:

    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:

  # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
  # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
  # /ia perfil <nome>.
  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

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

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<Corrections.Entry> 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<Corrections.Entry> all = List.of(
                new Corrections.Entry("como pegar um camelo", "…"));
        assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty());
    }

    @Test
    void shortWordsDoNotCreateMatches() {
        List<Corrections.Entry> 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

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.
 *
 * <p>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<Entry> 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<Entry> all() {
        return List.copyOf(entries);
    }

    /** Corrections sharing at least two significant words with the question. */
    static List<Entry> matching(List<Entry> all, String question) {
        Set<String> asked = significantWords(question);
        List<Entry> out = new ArrayList<>();
        for (Entry entry : all) {
            Set<String> known = significantWords(entry.question());
            known.retainAll(asked);
            if (known.size() >= 2) {
                out.add(entry);
            }
        }
        return out;
    }

    private static Set<String> significantWords(String text) {
        Set<String> 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

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.

    /** Builds the messages for one question. */
    private java.util.List<MiniMax.Msg> compose(Player asker, String question,
            Settings settings, StringBuilder sourceNote) {
        java.util.List<MiniMax.Msg> 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:

        Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
            String answer = null;
            try {
                StringBuilder note = new StringBuilder();
                java.util.List<MiniMax.Msg> 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

    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

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

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:

  iap:
    description: Pergunta para a IA, resposta só para você.
    usage: /iap <pergunta>
    aliases: [iaprivado]

and under permissions:

  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 <economico|preciso> — requires canalhandia.ia.perfil, calls settings.aiProfile(...), replies with the new profile.
  • corrigir <resposta correta> — 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:

    /** 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

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

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

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

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.

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

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

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.