diff --git a/src/main/java/dev/marcospaulo/canalhandia/Conversations.java b/src/main/java/dev/marcospaulo/canalhandia/Conversations.java
new file mode 100644
index 0000000..8f4b03c
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/Conversations.java
@@ -0,0 +1,141 @@
+package dev.marcospaulo.canalhandia;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Short per-player memory, so a follow-up like "e no nether?" makes sense.
+ *
+ *
Deliberately small and forgetful: the point is continuity within one
+ * exchange, not a transcript. Every way of losing memory here — expiry,
+ * eviction, a restart — costs one player one lost follow-up, so this class
+ * always prefers forgetting to growing.
+ *
+ *
Thread-safe. {@link Ai} calls both methods from
+ * {@code runTaskAsynchronously}, so two players asking at once means two
+ * threads in here at the same time, and {@link #history} writes while
+ * it reads — it prunes expired entries. Unsynchronised that is not a stale
+ * read but a corrupt map: a concurrent resize can lose entries or spin, and
+ * pruning under another thread's write throws {@code ConcurrentModification}
+ * straight up into the player's answer. Guarded by {@link #byPlayer}'s own
+ * monitor, consistently with {@link Wiki}; nothing slow happens under the lock.
+ */
+final class Conversations {
+
+ /**
+ * A hard ceiling on remembered players. Expiry alone does not bound this:
+ * entries are only pruned for the player who asks, so someone who asks once
+ * and logs off would sit in the map until the next restart. Least-recently
+ * used goes first, and at this size a busy server loses nothing that is
+ * still a live conversation.
+ */
+ static final int MAX_PLAYERS = 200;
+
+ private static final long NANOS_PER_MINUTE = 60_000_000_000L;
+
+ /** {@code at} is a {@link System#nanoTime} reading; see {@link #expired}. */
+ private record Entry(MiniMax.Turn turn, long at) {
+ }
+
+ private final int maxTurns;
+ private final long windowNanos;
+ /**
+ * Access-ordered so eviction drops the player who has been quiet longest
+ * rather than whoever happened to ask first.
+ */
+ private final Map> byPlayer = new LinkedHashMap<>(16, 0.75f, true);
+
+ Conversations(int maxExchanges, int windowMinutes) {
+ // Each exchange is two turns, and the product is computed in long
+ // arithmetic: a config of Integer.MAX_VALUE would overflow an int
+ // multiply to a negative bound, which silently disables memory instead
+ // of honouring the (absurd) request.
+ this.maxTurns = (int) Math.min(2L * Math.max(0, maxExchanges), Integer.MAX_VALUE);
+ long minutes = Math.max(0, windowMinutes);
+ // Saturating rather than wrapping: an overflowed window would come out
+ // negative and expire every entry on the spot, turning "remember for a
+ // very long time" into "remember nothing".
+ this.windowNanos = minutes > Long.MAX_VALUE / NANOS_PER_MINUTE
+ ? Long.MAX_VALUE
+ : minutes * NANOS_PER_MINUTE;
+ }
+
+ void remember(UUID player, String question, String answer) {
+ if (maxTurns == 0) {
+ // Short-circuit before touching the map. Falling through would add
+ // two turns, drop both, and leave an empty deque behind — a memory
+ // configured off would still grow one map entry per player.
+ return;
+ }
+ long now = System.nanoTime();
+ synchronized (byPlayer) {
+ Deque entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>());
+ entries.addLast(new Entry(new MiniMax.Turn("user", question), now));
+ entries.addLast(new Entry(new MiniMax.Turn("assistant", answer), now));
+ while (entries.size() > maxTurns) {
+ entries.removeFirst();
+ }
+ while (byPlayer.size() > MAX_PLAYERS) {
+ byPlayer.remove(byPlayer.keySet().iterator().next());
+ }
+ }
+ }
+
+ /** Recent messages still inside the window, oldest first. */
+ List history(UUID player) {
+ long now = System.nanoTime();
+ List out = new ArrayList<>();
+ synchronized (byPlayer) {
+ Deque entries = byPlayer.get(player);
+ if (entries == null) {
+ return out;
+ }
+ entries.removeIf(entry -> expired(entry, now));
+ if (entries.isEmpty()) {
+ // Do not leave the key behind: a player whose memory has run
+ // out is indistinguishable from one who never asked.
+ byPlayer.remove(player);
+ return out;
+ }
+ for (Entry entry : entries) {
+ out.add(entry.turn());
+ }
+ }
+ return out;
+ }
+
+ void forget(UUID player) {
+ synchronized (byPlayer) {
+ byPlayer.remove(player);
+ }
+ }
+
+ /** How many players are currently remembered. For tests. */
+ int size() {
+ synchronized (byPlayer) {
+ return byPlayer.size();
+ }
+ }
+
+ /**
+ * Elapsed time compared as a difference, and against {@link System#nanoTime}
+ * rather than the wall clock. Two reasons. The difference form is the only
+ * correct way to compare nanoTime readings, which are allowed to be negative
+ * and to wrap. And nanoTime is monotonic: an NTP step backwards mid-session
+ * would leave wall-clock entries stamped in the future, so {@code now - at}
+ * would go negative and the entry would never expire — memory that outlives
+ * its window and answers a fresh question with an hour-old one.
+ *
+ * {@code >=} and not {@code >}, so a window of zero expires everything
+ * immediately instead of depending on whether two calls landed in the same
+ * clock tick.
+ */
+ private boolean expired(Entry entry, long now) {
+ return now - entry.at() >= windowNanos;
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java b/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java
new file mode 100644
index 0000000..4d96eab
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/ConversationsTest.java
@@ -0,0 +1,146 @@
+package dev.marcospaulo.canalhandia;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+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());
+ }
+
+ /** Roles alternate user/assistant, which is what the API expects. */
+ @Test
+ void alternatesUserAndAssistantRoles() {
+ Conversations c = new Conversations(3, 10);
+ UUID id = UUID.randomUUID();
+ c.remember(id, "q", "a");
+ List history = c.history(id);
+ assertEquals("user", history.get(0).role());
+ assertEquals("assistant", history.get(1).role());
+ assertEquals("a", history.get(1).content());
+ }
+
+ /** A memory of zero exchanges must remember nothing at all, not even a key. */
+ @Test
+ void zeroExchangesRemembersNothing() {
+ Conversations c = new Conversations(0, 10);
+ UUID id = UUID.randomUUID();
+ c.remember(id, "q", "a");
+ assertTrue(c.history(id).isEmpty());
+ assertEquals(0, c.size());
+ }
+
+ @Test
+ void forgetDropsOnlyThatPlayer() {
+ Conversations c = new Conversations(3, 10);
+ UUID a = UUID.randomUUID();
+ UUID b = UUID.randomUUID();
+ c.remember(a, "q", "a");
+ c.remember(b, "q", "b");
+ c.forget(a);
+ assertTrue(c.history(a).isEmpty());
+ assertEquals(2, c.history(b).size());
+ }
+
+ /** Expired entries must not leave the player behind as a permanent key. */
+ @Test
+ void expiryEvictsThePlayerEntirely() {
+ Conversations c = new Conversations(3, 0);
+ UUID id = UUID.randomUUID();
+ c.remember(id, "q", "a");
+ c.history(id);
+ assertEquals(0, c.size());
+ }
+
+ /** A busy server must not accumulate a map entry per player forever. */
+ @Test
+ void boundsTheNumberOfRememberedPlayers() {
+ Conversations c = new Conversations(3, 10);
+ for (int i = 0; i < Conversations.MAX_PLAYERS + 50; i++) {
+ c.remember(UUID.randomUUID(), "q", "a");
+ }
+ assertEquals(Conversations.MAX_PLAYERS, c.size());
+ }
+
+ /**
+ * {@link Ai} calls this from {@code runTaskAsynchronously}, so several
+ * players asking at once means several threads in here at the same time.
+ * Unsynchronised, this corrupts the map or spins forever on a resize.
+ */
+ @Test
+ void survivesConcurrentUse() throws Exception {
+ Conversations c = new Conversations(3, 10);
+ int threads = 8;
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(threads);
+ AtomicReference failure = new AtomicReference<>();
+ List workers = new ArrayList<>();
+ for (int t = 0; t < threads; t++) {
+ UUID id = UUID.randomUUID();
+ Thread worker = new Thread(() -> {
+ try {
+ start.await();
+ for (int i = 0; i < 400; i++) {
+ c.remember(id, "q" + i, "a" + i);
+ assertFalse(c.history(id).isEmpty());
+ c.history(UUID.randomUUID());
+ }
+ } catch (Throwable e) {
+ failure.compareAndSet(null, e);
+ } finally {
+ done.countDown();
+ }
+ });
+ workers.add(worker);
+ worker.start();
+ }
+ start.countDown();
+ assertTrue(done.await(30, TimeUnit.SECONDS), "threads travaram");
+ for (Thread worker : workers) {
+ worker.join();
+ }
+ assertNull(failure.get(), String.valueOf(failure.get()));
+ }
+}