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.
pt.minecraft.wikireturns HTTP 403 to a default user agent. Every request must send an identifyingUser-Agent. This is MediaWiki policy, not a bug.- MiniMax returns HTTP 200 on application-level errors. The real outcome is in
base_resp.status_code, where non-zero means failure. - MiniMax models emit hidden reasoning that counts against
max_tokens. Too small a budget yields emptycontent, not a short answer. This is why answers must use 1200 and why free-text term extraction failed. tool_choicemust force the call. Withauto, the model skipped the search on exactly the question it had already answered wrong twice.explaintextstrips tables, so wiki text never contains crafting or brewing recipes. Recipes come fromBukkit.recipeIterator()instead.- 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— deletesanitiseandtrim, callAiTextinstead
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 fail — cannot 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— theia: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>— requirescanalhandia.ia.perfil, callssettings.aiProfile(...), replies with the new profile.corrigir <resposta correta>— requirescanalhandia.ia.corrigir, takes the last answered question and stores the operator's text viaCorrections.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.