From 47a1820f994a32a153c386f151553b95e4be86ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 05:22:03 +0000 Subject: [PATCH 01/15] feat: summarize conversation message history every 5 messages Automatically condenses older conversation messages into a summary when 5 new conversational messages accumulate. This keeps the AI context window manageable for long conversations while preserving important context through summarization. - Add messagesSinceLastSummary counter to Conversation - Add replaceHistoryWithSummary() to swap old messages with a summary - Add checkAndSummarizeHistory() to ConversationManager - Trigger summarization after player messages and NPC responses - Add message_history_summary prompt to prompts.yml - Add getMessageHistorySummaryPrompt() to PromptService https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../com/canefe/story/config/PromptService.kt | 3 + .../canefe/story/conversation/Conversation.kt | 18 +++++ .../story/conversation/ConversationManager.kt | 72 +++++++++++++++++++ src/main/resources/prompts.yml | 14 ++++ 4 files changed, 107 insertions(+) diff --git a/src/main/kotlin/com/canefe/story/config/PromptService.kt b/src/main/kotlin/com/canefe/story/config/PromptService.kt index b05b174..74fa4d5 100644 --- a/src/main/kotlin/com/canefe/story/config/PromptService.kt +++ b/src/main/kotlin/com/canefe/story/config/PromptService.kt @@ -248,6 +248,9 @@ class PromptService( return getPrompt("location_context_generation", variables) } + /** Gets the message history summary prompt for mid-conversation summarization */ + fun getMessageHistorySummaryPrompt(): String = getPrompt("message_history_summary") + /** Gets the session history summary prompt */ fun getSessionHistorySummaryPrompt(): String = getPrompt("session_history_summary") diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index 5a599b6..bbddc15 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -14,6 +14,10 @@ class Conversation( private val _npcs: MutableSet = HashSet(initialNPCs) private val _history: MutableList = ArrayList() + // Track the number of non-system messages added since the last history summarization + var messagesSinceLastSummary: Int = 0 + private set + // Public properties var active: Boolean = true var chatEnabled: Boolean = true @@ -157,6 +161,9 @@ class Conversation( message, ) _history.add(userMessage) + if (message != "...") { + messagesSinceLastSummary++ + } } private fun addAssistantMessage(message: String) { @@ -166,6 +173,17 @@ class Conversation( message, ) _history.add(assistantMessage) + messagesSinceLastSummary++ + } + + fun replaceHistoryWithSummary( + summary: String, + recentMessages: List, + ) { + _history.clear() + _history.add(ConversationMessage("system", summary)) + _history.addAll(recentMessages) + messagesSinceLastSummary = 0 } fun clearHistory() { diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 45ed7cf..1df3f20 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -649,6 +649,9 @@ class ConversationManager private constructor( conversation.addPlayerMessage(player, message) handleHolograms(conversation, player.name) + // Check if message history needs summarization + checkAndSummarizeHistory(conversation) + // Skip response generation if chat is disabled if (!conversation.chatEnabled) { return @@ -841,6 +844,71 @@ class ConversationManager private constructor( return npc.entity } + /** + * Checks if the conversation history needs summarization and triggers it if so. + * Summarization occurs every 5 conversational messages to keep the context window manageable. + */ + private fun checkAndSummarizeHistory(conversation: Conversation) { + val summarizationThreshold = 5 + val recentMessagesToKeep = 4 + + if (conversation.messagesSinceLastSummary < summarizationThreshold) { + return + } + + val history = conversation.history + val nonSystemMessages = history.filter { it.role != "system" } + + // Need enough messages to make summarization worthwhile + if (nonSystemMessages.size <= recentMessagesToKeep) { + return + } + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Summarizing message history for conversation ${conversation.id} " + + "(${history.size} messages, ${conversation.messagesSinceLastSummary} since last summary)", + ) + } + + // Split history: messages to summarize vs recent messages to keep + val splitIndex = history.size - recentMessagesToKeep + val messagesToSummarize = history.subList(0, splitIndex) + val recentMessages = history.subList(splitIndex, history.size).toList() + + // Build the summarization prompt + val summaryPrompt = plugin.promptService.getMessageHistorySummaryPrompt() + val conversationText = + messagesToSummarize + .filter { it.role != "system" || it.content.startsWith("Summary of conversation") } + .joinToString("\n") { "${it.role}: ${it.content}" } + + val prompts = + listOf( + ConversationMessage("system", summaryPrompt), + ConversationMessage("user", conversationText), + ) + + // Run summarization asynchronously to avoid blocking the main thread + plugin.getAIResponse(prompts, lowCost = true).thenAccept { summary -> + if (!summary.isNullOrBlank()) { + conversation.replaceHistoryWithSummary(summary, recentMessages) + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Message history summarized for conversation ${conversation.id}. " + + "New history size: ${conversation.history.size}", + ) + } + } + }.exceptionally { e -> + plugin.logger.warning( + "Failed to summarize message history for conversation ${conversation.id}: ${e.message}", + ) + null + } + } + // NPC conversation coordination fun generateResponses( conversation: Conversation, @@ -966,6 +1034,9 @@ class ConversationManager private constructor( // Add the NPC's response to the conversation history conversation.addNPCMessage(npcEntity, npcResponse) + // Check if message history needs summarization + checkAndSummarizeHistory(conversation) + // Hologram cleanup cleanupHolograms(conversation) @@ -1058,6 +1129,7 @@ class ConversationManager private constructor( .thenAccept { npcResponse -> // Rest of processing code conversation.addNPCMessage(npcEntity, npcResponse) + checkAndSummarizeHistory(conversation) cleanupHolograms(conversation) // Process action intents... result.complete(Unit) diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index eeb991c..5bd5615 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -380,6 +380,20 @@ recent_events_generation: Respond with a JSON object containing only a 'recent_events' field. +message_history_summary: + system_prompt: | + You are a concise conversation summarizer. Condense the following conversation history + into a brief summary that preserves all important context needed to continue the conversation naturally. + + Focus on: + - Key topics discussed and decisions made + - Each character's stance, mood, and attitude + - Important information revealed (names, places, events, promises) + - The current direction of the conversation + + Keep the summary under 200 words. Write in third person, past tense. + Format: "Summary of conversation so far: [your summary]" + session_history_summary: system_prompt: | You are a concise summarizer for a medieval fantasy role-playing session. From 3cfb3b6287ccbc0ef069c057e814e23c6cab1b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 05:24:16 +0000 Subject: [PATCH 02/15] feat: make summarization threshold configurable via config Add conversation.summarizationThreshold to ConfigService and config.yml so server admins can tune how often message history gets summarized. Defaults to 5 messages. https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- src/main/kotlin/com/canefe/story/config/ConfigService.kt | 8 ++++++++ .../com/canefe/story/conversation/ConversationManager.kt | 2 +- src/main/resources/config.yml | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/canefe/story/config/ConfigService.kt b/src/main/kotlin/com/canefe/story/config/ConfigService.kt index a5cf6a2..ade6644 100644 --- a/src/main/kotlin/com/canefe/story/config/ConfigService.kt +++ b/src/main/kotlin/com/canefe/story/config/ConfigService.kt @@ -51,6 +51,8 @@ class ConfigService( false // Whether to enable dialogue path selection for DMs var delayedPlayerMessageProcessing: Boolean = false // Whether to delay player message processing like /g command + var summarizationThreshold: Int = + 5 // Summarize conversation history every N messages /* NPC Behavior settings @@ -196,6 +198,11 @@ class ConfigService( "conversation.delayedPlayerMessageProcessing", false, ) // Whether to delay player message processing like /g command + summarizationThreshold = + config.getInt( + "conversation.summarizationThreshold", + 5, + ) // Summarize conversation history every N messages // NPC Behavior Settings headRotationDelay = config.getInt("npc.headRotationDelay", 2) @@ -290,6 +297,7 @@ class ConfigService( config.set("conversation.behavioralDirectivesEnabled", behavioralDirectivesEnabled) config.set("conversation.dialoguePathSelectionEnabled", dialoguePathSelectionEnabled) config.set("conversation.delayedPlayerMessageProcessing", delayedPlayerMessageProcessing) + config.set("conversation.summarizationThreshold", summarizationThreshold) // NPC Behavior settings config.set("npc.headRotationDelay", headRotationDelay) diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 1df3f20..7c847fd 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -849,7 +849,7 @@ class ConversationManager private constructor( * Summarization occurs every 5 conversational messages to keep the context window manageable. */ private fun checkAndSummarizeHistory(conversation: Conversation) { - val summarizationThreshold = 5 + val summarizationThreshold = plugin.config.summarizationThreshold val recentMessagesToKeep = 4 if (conversation.messagesSinceLastSummary < summarizationThreshold) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 599a4db..8587a0f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -41,6 +41,7 @@ conversation: behavioralDirectivesEnabled: false dialoguePathSelectionEnabled: false delayedPlayerMessageProcessing: false + summarizationThreshold: 5 # Summarize conversation history every N messages # NPC Behavior npc: headRotationDelay: 2 From a7a6c3ad2d0183c2a06e62c574c20b79995cf487 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 05:27:49 +0000 Subject: [PATCH 03/15] test: add tests for message history summarization Tests cover: - Message counter increments for player/NPC messages - Counter ignores system messages and "..." placeholders - replaceHistoryWithSummary resets counter and replaces history - Summarization triggers at configurable threshold - Summarization keeps recent messages and replaces old ones - Configurable threshold is respected - Graceful handling of null/failed AI responses - Counter resets after summarization allowing re-trigger https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../story/MessageHistorySummarizationTest.kt | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt new file mode 100644 index 0000000..f01d2b9 --- /dev/null +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -0,0 +1,376 @@ +package com.canefe.story + +import com.canefe.story.command.base.CommandManager +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationManager +import com.canefe.story.conversation.ConversationMessage +import com.canefe.story.testutils.makeNpc +import io.mockk.* +import net.citizensnpcs.api.CitizensAPI +import net.citizensnpcs.api.npc.NPCRegistry +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.mockbukkit.mockbukkit.MockBukkit +import org.mockbukkit.mockbukkit.ServerMock +import java.util.concurrent.CompletableFuture + +class MessageHistorySummarizationTest { + private lateinit var server: ServerMock + private lateinit var plugin: Story + + @BeforeEach + fun setUp() { + System.setProperty("mockbukkit", "true") + server = MockBukkit.mock() + + mockkConstructor(CommandManager::class) + every { anyConstructed().registerCommands() } just Runs + + plugin = MockBukkit.load(Story::class.java) + + val mockRegistry = mockk() + every { mockRegistry.isNPC(any()) } returns false + CitizensAPI.setNPCRegistry(mockRegistry) + + plugin.commandManager = mockk(relaxed = true) + ConversationManager.reset() + plugin.npcResponseService = mockk(relaxed = true) + plugin.worldInformationManager = mockk(relaxed = true) + plugin.npcContextGenerator = mockk(relaxed = true) + plugin.sessionManager = mockk(relaxed = true) + + plugin.conversationManager = + ConversationManager.getInstance( + plugin, + plugin.npcContextGenerator, + plugin.npcResponseService, + plugin.worldInformationManager, + ) + } + + @AfterEach + fun teardown() { + MockBukkit.unmock() + } + + @Nested + inner class ConversationMessageCounterTests { + @Test + fun `messagesSinceLastSummary increments on player messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + assertEquals(0, conversation.messagesSinceLastSummary) + + conversation.addPlayerMessage(player, "hello") + assertEquals(1, conversation.messagesSinceLastSummary) + + conversation.addPlayerMessage(player, "how are you") + assertEquals(2, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary increments on NPC messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + assertEquals(0, conversation.messagesSinceLastSummary) + + // addNPCMessage adds an assistant message (+1) and a "..." user message (not counted) + conversation.addNPCMessage(npc, "Greetings traveler") + assertEquals(1, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary does not increment on system messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + conversation.addSystemMessage("A new player has joined") + assertEquals(0, conversation.messagesSinceLastSummary) + } + + @Test + fun `messagesSinceLastSummary does not count ellipsis user messages`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Simulate the pattern from addNPCMessage: assistant + "..." + conversation.addNPCMessage(npc, "Hello") + // assistant message counts as 1, "..." does not count + assertEquals(1, conversation.messagesSinceLastSummary) + + // A real player message should count + conversation.addPlayerMessage(player, "Hi back") + assertEquals(2, conversation.messagesSinceLastSummary) + } + + @Test + fun `replaceHistoryWithSummary resets counter and replaces history`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Add several messages + conversation.addPlayerMessage(player, "msg1") + conversation.addPlayerMessage(player, "msg2") + conversation.addNPCMessage(npc, "response1") + conversation.addPlayerMessage(player, "msg3") + conversation.addNPCMessage(npc, "response2") + + assertEquals(5, conversation.messagesSinceLastSummary) + + val recentMessages = listOf( + ConversationMessage("user", "Alice: msg3"), + ConversationMessage("assistant", "Guard: response2"), + ) + + conversation.replaceHistoryWithSummary( + "Summary of conversation so far: Alice and Guard discussed things.", + recentMessages, + ) + + // Counter should be reset + assertEquals(0, conversation.messagesSinceLastSummary) + + // History should contain summary + recent messages + assertEquals(3, conversation.history.size) + assertEquals("system", conversation.history[0].role) + assertTrue(conversation.history[0].content.contains("Summary of conversation")) + assertEquals("user", conversation.history[1].role) + assertEquals("assistant", conversation.history[2].role) + } + } + + @Nested + inner class SummarizationTriggerTests { + @Test + fun `summarization is not triggered below threshold`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + // Set threshold to 5 (default) + plugin.configService.summarizationThreshold = 5 + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + // Disable chat to prevent response generation + conversation.chatEnabled = false + + // Add 4 messages (below threshold of 5) + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + + // getAIResponse should NOT have been called for summarization + verify(exactly = 0) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization is triggered at threshold`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Mock the AI response for summarization + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: things happened") + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 5 messages to hit the threshold + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg5") + + // getAIResponseAsync should have been called for summarization + verify(atLeast = 1) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization replaces old messages and keeps recent ones`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + val summaryText = "Summary of conversation so far: Alice talked to Guard about the kingdom." + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture(summaryText) + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 6 messages to trigger summarization with enough to split + plugin.conversationManager.addPlayerMessage(player, conversation, "msg1") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg2") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg3") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg4") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg5") + plugin.conversationManager.addPlayerMessage(player, conversation, "msg6") + + // After summarization completes, history should be condensed + // 1 summary system message + 4 recent messages kept + assertTrue(conversation.history.size <= 6) + assertEquals(0, conversation.messagesSinceLastSummary) + + // First message should be the summary + val firstMessage = conversation.history[0] + assertEquals("system", firstMessage.role) + assertEquals(summaryText, firstMessage.content) + } + + @Test + fun `configurable threshold is respected`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + // Set a higher threshold + plugin.configService.summarizationThreshold = 10 + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add 5 messages (below threshold of 10) + repeat(5) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // Should NOT trigger summarization at 5 with threshold of 10 + verify(exactly = 0) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + + // Now mock AI response and add more to reach 10 + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary text") + + repeat(5) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 6}") + } + + // Should now trigger summarization + verify(atLeast = 1) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + } + + @Test + fun `summarization handles null AI response gracefully`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Return null response + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture(null) + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // Add enough messages to trigger summarization + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // History should remain unchanged since AI returned null + assertTrue(conversation.history.size >= 6) + // Counter should still be at the accumulated value since no summary was applied + assertTrue(conversation.messagesSinceLastSummary >= 5) + } + + @Test + fun `summarization handles AI exception gracefully`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + // Return a failed future + val failedFuture = CompletableFuture() + failedFuture.completeExceptionally(RuntimeException("API error")) + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns failedFuture + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + val historyBefore = conversation.history.size + + // Add enough messages to trigger summarization - should not throw + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "msg${i + 1}") + } + + // Conversation should still be functional + assertTrue(conversation.history.isNotEmpty()) + assertTrue(conversation.active) + } + + @Test + fun `counter resets after successful summarization allowing re-trigger`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + + plugin.configService.summarizationThreshold = 5 + + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: round 1") + + val conversation = plugin.conversationManager.startConversation(player, listOf(npc)) + conversation.chatEnabled = false + + // First round: trigger summarization + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "round1_msg${i + 1}") + } + + // Counter should be reset after summarization + assertEquals(0, conversation.messagesSinceLastSummary) + + // Update mock for second round + every { + plugin.aiResponseService.getAIResponseAsync(any(), any()) + } returns CompletableFuture.completedFuture("Summary of conversation so far: round 2") + + // Second round: add more messages to trigger again + repeat(6) { i -> + plugin.conversationManager.addPlayerMessage(player, conversation, "round2_msg${i + 1}") + } + + // Should have been called twice total (once per round) + verify(atLeast = 2) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } + assertEquals(0, conversation.messagesSinceLastSummary) + } + } +} From 3745ddfbcc22858a66875b661b607e72f8e77020 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 17:37:03 +0000 Subject: [PATCH 04/15] Add conversation theme system implementation plan Draft architecture for a theme system that supports stackable, registry-based conversation themes with compatibility checks. https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- plan.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..05fbe59 --- /dev/null +++ b/plan.md @@ -0,0 +1,69 @@ +# Conversation Theme System - Implementation Plan + +## Architecture + +### Core abstractions (new package: `conversation/theme/`) + +1. **`ConversationTheme`** - Abstract base class that defines a theme + - `name: String` - unique identifier (e.g., "chat", "violence") + - `displayName: String` - human-readable name + - `compatibleWith: Set` - theme names this can stack with + - `onActivate(conversation: Conversation)` - callback when theme becomes active + - `onDeactivate(conversation: Conversation)` - callback when theme is removed + - `onMessage(conversation: Conversation, message: ConversationMessage)` - called on each message + +2. **`ConversationThemeFactory`** - Interface for creating theme instances + - `fun create(): ConversationTheme` + +3. **`ConversationThemeRegistry`** - Singleton registry + - `register(name: String, factory: ConversationThemeFactory)` - register a theme type + - `unregister(name: String)` - remove a theme type + - `create(name: String): ConversationTheme` - instantiate from factory + - `getRegisteredThemes(): Set` - list available themes + +4. **`ConversationThemeManager`** - Manages active themes per conversation (separate manager, observes conversations) + - `activateTheme(conversation: Conversation, themeName: String): Boolean` - activate a theme (checks compatibility with existing active themes) + - `deactivateTheme(conversation: Conversation, themeName: String): Boolean` + - `getActiveThemes(conversation: Conversation): List` + - `hasTheme(conversation: Conversation, themeName: String): Boolean` + - `onConversationEnd(conversation: Conversation)` - cleanup + - `onMessage(conversation: Conversation, message: ConversationMessage)` - propagate to active themes + +5. **`ConversationThemeData`** - Data class held within `Conversation` + - `activeThemeNames: List` (read-only view of active theme names) + - Kept minimal - just state, no logic + +### Example Themes + +6. **`ChatTheme`** - Default theme, compatible with everything +7. **`ViolenceTheme`** - Activates on combat events, not compatible with "trade" + +### Integration Points + +- Add `themeData: ConversationThemeData` field to `Conversation` +- `ConversationThemeManager` initialized in `Story.onEnable()` and stored on plugin +- Theme manager listens to `ConversationStartEvent` to apply default "chat" theme +- Theme manager listens to conversation messages via `ConversationManager` + +### File list +``` +src/main/kotlin/com/canefe/story/conversation/theme/ +├── ConversationTheme.kt # Abstract base +├── ConversationThemeFactory.kt # Factory interface +├── ConversationThemeRegistry.kt # Singleton registry +├── ConversationThemeManager.kt # Per-conversation manager +├── ConversationThemeData.kt # Data class for Conversation +├── ChatTheme.kt # Example: default chat +└── ViolenceTheme.kt # Example: violence/combat + +Modified files: +- Conversation.kt # Add themeData field +- Story.kt # Initialize theme manager + register defaults +``` + +### Tests +``` +src/test/kotlin/com/canefe/story/conversation/theme/ +├── ConversationThemeRegistryTest.kt +├── ConversationThemeManagerTest.kt +``` From 25aa16c0cf4b83c91f2eaddb65746d21eeab18df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 17:40:13 +0000 Subject: [PATCH 05/15] Fix race condition in conversation history summarization The async summarization could lose messages added while the AI was generating a summary. replaceHistoryWithSummary previously cleared the entire history and replaced it with a stale snapshot, wiping any messages added during the async window. Changes: - Conversation.replaceHistoryWithSummary now takes a count of summarized messages and removes only those from the front of the history, preserving any messages appended concurrently. - checkAndSummarizeHistory now dispatches the history replacement back to the main server thread via Bukkit.getScheduler().runTask() to ensure thread safety. - Updated and added tests covering the race condition scenario. https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../canefe/story/conversation/Conversation.kt | 13 ++-- .../story/conversation/ConversationManager.kt | 21 ++++--- .../story/MessageHistorySummarizationTest.kt | 63 ++++++++++++++----- 3 files changed, 69 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index bbddc15..391ab80 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -178,11 +178,16 @@ class Conversation( fun replaceHistoryWithSummary( summary: String, - recentMessages: List, + summarizedMessageCount: Int, ) { - _history.clear() - _history.add(ConversationMessage("system", summary)) - _history.addAll(recentMessages) + // Remove only the messages that were actually summarized, + // preserving any messages added after the snapshot was taken. + val removeCount = summarizedMessageCount.coerceAtMost(_history.size) + repeat(removeCount) { + _history.removeAt(0) + } + // Insert the summary at the beginning + _history.add(0, ConversationMessage("system", summary)) messagesSinceLastSummary = 0 } diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 7c847fd..7bc50f1 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -874,7 +874,6 @@ class ConversationManager private constructor( // Split history: messages to summarize vs recent messages to keep val splitIndex = history.size - recentMessagesToKeep val messagesToSummarize = history.subList(0, splitIndex) - val recentMessages = history.subList(splitIndex, history.size).toList() // Build the summarization prompt val summaryPrompt = plugin.promptService.getMessageHistorySummaryPrompt() @@ -890,16 +889,20 @@ class ConversationManager private constructor( ) // Run summarization asynchronously to avoid blocking the main thread + val messagesToSummarizeCount = messagesToSummarize.size plugin.getAIResponse(prompts, lowCost = true).thenAccept { summary -> if (!summary.isNullOrBlank()) { - conversation.replaceHistoryWithSummary(summary, recentMessages) - - if (plugin.config.debugMessages) { - plugin.logger.info( - "Message history summarized for conversation ${conversation.id}. " + - "New history size: ${conversation.history.size}", - ) - } + // Safely modify conversation state on the main server thread + Bukkit.getScheduler().runTask(plugin, Runnable { + conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount) + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Message history summarized for conversation ${conversation.id}. " + + "New history size: ${conversation.history.size}", + ) + } + }) } }.exceptionally { e -> plugin.logger.warning( diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt index f01d2b9..8be24ae 100644 --- a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -126,7 +126,7 @@ class MessageHistorySummarizationTest { } @Test - fun `replaceHistoryWithSummary resets counter and replaces history`() { + fun `replaceHistoryWithSummary resets counter and removes summarized messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") val conversation = Conversation( @@ -135,33 +135,66 @@ class MessageHistorySummarizationTest { ) // Add several messages - conversation.addPlayerMessage(player, "msg1") - conversation.addPlayerMessage(player, "msg2") - conversation.addNPCMessage(npc, "response1") - conversation.addPlayerMessage(player, "msg3") - conversation.addNPCMessage(npc, "response2") + conversation.addPlayerMessage(player, "msg1") // user + conversation.addPlayerMessage(player, "msg2") // user + conversation.addNPCMessage(npc, "response1") // assistant + "..." + conversation.addPlayerMessage(player, "msg3") // user + conversation.addNPCMessage(npc, "response2") // assistant + "..." assertEquals(5, conversation.messagesSinceLastSummary) - val recentMessages = listOf( - ConversationMessage("user", "Alice: msg3"), - ConversationMessage("assistant", "Guard: response2"), - ) + val totalMessages = conversation.history.size + // Summarize all but the last 3 messages + val messagesToSummarizeCount = totalMessages - 3 conversation.replaceHistoryWithSummary( "Summary of conversation so far: Alice and Guard discussed things.", - recentMessages, + messagesToSummarizeCount, ) // Counter should be reset assertEquals(0, conversation.messagesSinceLastSummary) - // History should contain summary + recent messages - assertEquals(3, conversation.history.size) + // History should contain 1 summary + 3 remaining recent messages + assertEquals(4, conversation.history.size) assertEquals("system", conversation.history[0].role) assertTrue(conversation.history[0].content.contains("Summary of conversation")) - assertEquals("user", conversation.history[1].role) - assertEquals("assistant", conversation.history[2].role) + } + + @Test + fun `replaceHistoryWithSummary preserves messages added during async summarization`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + // Add initial messages + conversation.addPlayerMessage(player, "msg1") // index 0 + conversation.addPlayerMessage(player, "msg2") // index 1 + conversation.addPlayerMessage(player, "msg3") // index 2 + + // Snapshot: summarize first 2 messages + val messagesToSummarizeCount = 2 + + // Simulate new messages arriving during async summarization + conversation.addPlayerMessage(player, "msg4") // index 3 - added "during" async + conversation.addPlayerMessage(player, "msg5") // index 4 - added "during" async + + // Now apply the summary (as if the async call completed) + conversation.replaceHistoryWithSummary( + "Summary: discussed msg1 and msg2.", + messagesToSummarizeCount, + ) + + // Should have: 1 summary + msg3 + msg4 + msg5 = 4 messages + assertEquals(4, conversation.history.size) + assertEquals("system", conversation.history[0].role) + assertTrue(conversation.history[0].content.contains("Summary")) + // msg4 and msg5 (added during async) should be preserved + assertTrue(conversation.history.any { it.content.contains("msg4") }) + assertTrue(conversation.history.any { it.content.contains("msg5") }) } } From 41888123433a733c1d73fffb6f4d6c3a32c46340 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 17:43:00 +0000 Subject: [PATCH 06/15] Improve summarization fix: use subList.clear() and decrement counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use subList(0, count).clear() instead of repeat/removeAt(0) for O(n) instead of O(n²) removal. - Decrement messagesSinceLastSummary by summarized count instead of resetting to 0, so messages added during the async window are accurately reflected in the counter. - Add guard clause: no-op when count <= 0 or exceeds history size. - Remove private set from messagesSinceLastSummary to allow decrement. - Add test for no-op boundary condition and update counter assertions. https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../canefe/story/conversation/Conversation.kt | 22 ++++--- .../story/MessageHistorySummarizationTest.kt | 65 +++++++++++++------ 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index 391ab80..05eb392 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -16,7 +16,6 @@ class Conversation( // Track the number of non-system messages added since the last history summarization var messagesSinceLastSummary: Int = 0 - private set // Public properties var active: Boolean = true @@ -178,17 +177,22 @@ class Conversation( fun replaceHistoryWithSummary( summary: String, - summarizedMessageCount: Int, + summarizedMessagesCount: Int, ) { - // Remove only the messages that were actually summarized, - // preserving any messages added after the snapshot was taken. - val removeCount = summarizedMessageCount.coerceAtMost(_history.size) - repeat(removeCount) { - _history.removeAt(0) + if (summarizedMessagesCount <= 0 || _history.size < summarizedMessagesCount) { + return } - // Insert the summary at the beginning + // Remove only the messages that were actually summarized + _history.subList(0, summarizedMessagesCount).clear() + // Prepend the new summary _history.add(0, ConversationMessage("system", summary)) - messagesSinceLastSummary = 0 + + // Decrement rather than reset — messages added during the async + // window will have incremented the counter and must be preserved. + messagesSinceLastSummary -= summarizedMessagesCount + if (messagesSinceLastSummary < 0) { + messagesSinceLastSummary = 0 + } } fun clearHistory() { diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt index 8be24ae..6aa57b8 100644 --- a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -126,7 +126,7 @@ class MessageHistorySummarizationTest { } @Test - fun `replaceHistoryWithSummary resets counter and removes summarized messages`() { + fun `replaceHistoryWithSummary decrements counter and removes summarized messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") val conversation = Conversation( @@ -135,11 +135,11 @@ class MessageHistorySummarizationTest { ) // Add several messages - conversation.addPlayerMessage(player, "msg1") // user - conversation.addPlayerMessage(player, "msg2") // user - conversation.addNPCMessage(npc, "response1") // assistant + "..." - conversation.addPlayerMessage(player, "msg3") // user - conversation.addNPCMessage(npc, "response2") // assistant + "..." + conversation.addPlayerMessage(player, "msg1") // user (+1) + conversation.addPlayerMessage(player, "msg2") // user (+1) + conversation.addNPCMessage(npc, "response1") // assistant (+1) + "..." + conversation.addPlayerMessage(player, "msg3") // user (+1) + conversation.addNPCMessage(npc, "response2") // assistant (+1) + "..." assertEquals(5, conversation.messagesSinceLastSummary) @@ -152,8 +152,8 @@ class MessageHistorySummarizationTest { messagesToSummarizeCount, ) - // Counter should be reset - assertEquals(0, conversation.messagesSinceLastSummary) + // Counter should be decremented by the number of summarized messages + assertEquals(5 - messagesToSummarizeCount, conversation.messagesSinceLastSummary) // History should contain 1 summary + 3 remaining recent messages assertEquals(4, conversation.history.size) @@ -171,16 +171,19 @@ class MessageHistorySummarizationTest { ) // Add initial messages - conversation.addPlayerMessage(player, "msg1") // index 0 - conversation.addPlayerMessage(player, "msg2") // index 1 - conversation.addPlayerMessage(player, "msg3") // index 2 + conversation.addPlayerMessage(player, "msg1") // index 0 (+1) + conversation.addPlayerMessage(player, "msg2") // index 1 (+1) + conversation.addPlayerMessage(player, "msg3") // index 2 (+1) // Snapshot: summarize first 2 messages val messagesToSummarizeCount = 2 // Simulate new messages arriving during async summarization - conversation.addPlayerMessage(player, "msg4") // index 3 - added "during" async - conversation.addPlayerMessage(player, "msg5") // index 4 - added "during" async + conversation.addPlayerMessage(player, "msg4") // index 3 (+1) + conversation.addPlayerMessage(player, "msg5") // index 4 (+1) + + // Counter is 5 (all 5 player messages counted) + assertEquals(5, conversation.messagesSinceLastSummary) // Now apply the summary (as if the async call completed) conversation.replaceHistoryWithSummary( @@ -195,6 +198,28 @@ class MessageHistorySummarizationTest { // msg4 and msg5 (added during async) should be preserved assertTrue(conversation.history.any { it.content.contains("msg4") }) assertTrue(conversation.history.any { it.content.contains("msg5") }) + // Counter should reflect only unsummarized messages (5 - 2 = 3) + assertEquals(3, conversation.messagesSinceLastSummary) + } + + @Test + fun `replaceHistoryWithSummary is a no-op when count exceeds history size`() { + val player = server.addPlayer("Alice") + val npc = makeNpc("Guard") + val conversation = Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) + + conversation.addPlayerMessage(player, "msg1") + val originalSize = conversation.history.size + + // Attempt to summarize more messages than exist + conversation.replaceHistoryWithSummary("Bad summary", originalSize + 5) + + // History should be unchanged + assertEquals(originalSize, conversation.history.size) + assertEquals(1, conversation.messagesSinceLastSummary) } } @@ -272,9 +297,10 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "msg6") // After summarization completes, history should be condensed - // 1 summary system message + 4 recent messages kept + // 1 summary system message + recent messages kept assertTrue(conversation.history.size <= 6) - assertEquals(0, conversation.messagesSinceLastSummary) + // Counter is decremented by summarized count, not reset to 0 + assertTrue(conversation.messagesSinceLastSummary < 6) // First message should be the summary val firstMessage = conversation.history[0] @@ -370,7 +396,7 @@ class MessageHistorySummarizationTest { } @Test - fun `counter resets after successful summarization allowing re-trigger`() { + fun `counter decrements after successful summarization allowing re-trigger`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") @@ -388,8 +414,8 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "round1_msg${i + 1}") } - // Counter should be reset after summarization - assertEquals(0, conversation.messagesSinceLastSummary) + // Counter should be decremented (not necessarily 0) after summarization + assertTrue(conversation.messagesSinceLastSummary < 6) // Update mock for second round every { @@ -401,9 +427,8 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "round2_msg${i + 1}") } - // Should have been called twice total (once per round) + // Should have been called at least twice total (once per round) verify(atLeast = 2) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } - assertEquals(0, conversation.messagesSinceLastSummary) } } } From 2795a4e8656736867232a7a68947137c066898f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 17:43:54 +0000 Subject: [PATCH 07/15] Extract magic number 4 into RECENT_MESSAGES_TO_KEEP constant https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../com/canefe/story/conversation/ConversationManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 7bc50f1..4a9b060 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -850,7 +850,7 @@ class ConversationManager private constructor( */ private fun checkAndSummarizeHistory(conversation: Conversation) { val summarizationThreshold = plugin.config.summarizationThreshold - val recentMessagesToKeep = 4 + val recentMessagesToKeep = RECENT_MESSAGES_TO_KEEP if (conversation.messagesSinceLastSummary < summarizationThreshold) { return @@ -1309,6 +1309,7 @@ class ConversationManager private constructor( } companion object { + private const val RECENT_MESSAGES_TO_KEEP = 4 private var instance: ConversationManager? = null @JvmStatic From ee72178e6759cfc0bc78f2bfce7d97af768fb360 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 18:22:48 +0000 Subject: [PATCH 08/15] Add conversation theme system Implement a pluggable theme architecture for conversations: - ConversationTheme abstract base with lifecycle hooks - ConversationThemeFactory for theme instantiation - ConversationThemeRegistry for registering available themes - ConversationThemeManager for per-conversation theme activation with compatibility checks - ConversationThemeData on Conversation for tracking active themes - ChatTheme (default, compatible with all) and ViolenceTheme (combat, compatible with chat only) - Tests for registry and manager https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- src/main/kotlin/com/canefe/story/Story.kt | 13 ++ .../canefe/story/conversation/Conversation.kt | 4 + .../story/conversation/theme/ChatTheme.kt | 11 ++ .../conversation/theme/ConversationTheme.kt | 16 +++ .../theme/ConversationThemeData.kt | 15 +++ .../theme/ConversationThemeFactory.kt | 5 + .../theme/ConversationThemeManager.kt | 72 +++++++++++ .../theme/ConversationThemeRegistry.kt | 21 +++ .../story/conversation/theme/ViolenceTheme.kt | 11 ++ .../theme/ConversationThemeManagerTest.kt | 122 ++++++++++++++++++ .../theme/ConversationThemeRegistryTest.kt | 53 ++++++++ 11 files changed, 343 insertions(+) create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt create mode 100644 src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt create mode 100644 src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt diff --git a/src/main/kotlin/com/canefe/story/Story.kt b/src/main/kotlin/com/canefe/story/Story.kt index c577358..8e28875 100644 --- a/src/main/kotlin/com/canefe/story/Story.kt +++ b/src/main/kotlin/com/canefe/story/Story.kt @@ -12,6 +12,7 @@ import com.canefe.story.context.ContextExtractor import com.canefe.story.conversation.ConversationManager import com.canefe.story.conversation.ConversationMessage import com.canefe.story.conversation.radiant.RadiantConversationService +import com.canefe.story.conversation.theme.* import com.canefe.story.event.EventManager import com.canefe.story.information.WorldInformationManager import com.canefe.story.location.LocationManager @@ -143,6 +144,12 @@ open class Story : lateinit var voiceManager: VoiceManager + // Theme system + lateinit var themeRegistry: ConversationThemeRegistry + private set + lateinit var themeManager: ConversationThemeManager + private set + // NPC Name Aliasing System lateinit var npcNameManager: com.canefe.story.npc.name.NPCNameManager private set @@ -289,6 +296,12 @@ open class Story : npcNameManager = NPCNameManager(this) npcNameResolver = NPCNameResolver(this) + // Initialize theme system + themeRegistry = ConversationThemeRegistry() + themeRegistry.register(ChatTheme.NAME) { ChatTheme() } + themeRegistry.register(ViolenceTheme.NAME) { ViolenceTheme() } + themeManager = ConversationThemeManager(themeRegistry) + eventManager = EventManager(this) eventManager.registerEvents() diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index 05eb392..f068def 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -1,5 +1,6 @@ package com.canefe.story.conversation +import com.canefe.story.conversation.theme.ConversationThemeData import com.canefe.story.util.EssentialsUtils import net.citizensnpcs.api.npc.NPC import org.bukkit.entity.Player @@ -17,6 +18,9 @@ class Conversation( // Track the number of non-system messages added since the last history summarization var messagesSinceLastSummary: Int = 0 + // Theme data for this conversation + val themeData: ConversationThemeData = ConversationThemeData() + // Public properties var active: Boolean = true var chatEnabled: Boolean = true diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt new file mode 100644 index 0000000..8621f28 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt @@ -0,0 +1,11 @@ +package com.canefe.story.conversation.theme + +class ChatTheme : ConversationTheme() { + override val name: String = NAME + override val displayName: String = "Chat" + override val compatibleWith: Set = emptySet() // compatible with everything + + companion object { + const val NAME = "chat" + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt new file mode 100644 index 0000000..3e72b8d --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt @@ -0,0 +1,16 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage + +abstract class ConversationTheme { + abstract val name: String + abstract val displayName: String + abstract val compatibleWith: Set + + open fun onActivate(conversation: Conversation) {} + + open fun onDeactivate(conversation: Conversation) {} + + open fun onMessage(conversation: Conversation, message: ConversationMessage) {} +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt new file mode 100644 index 0000000..4cb0080 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt @@ -0,0 +1,15 @@ +package com.canefe.story.conversation.theme + +data class ConversationThemeData( + private val _activeThemeNames: MutableList = mutableListOf(), +) { + val activeThemeNames: List get() = _activeThemeNames.toList() + + fun addThemeName(name: String) { + if (!_activeThemeNames.contains(name)) { + _activeThemeNames.add(name) + } + } + + fun removeThemeName(name: String): Boolean = _activeThemeNames.remove(name) +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt new file mode 100644 index 0000000..2badea6 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeFactory.kt @@ -0,0 +1,5 @@ +package com.canefe.story.conversation.theme + +fun interface ConversationThemeFactory { + fun create(): ConversationTheme +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt new file mode 100644 index 0000000..5ef001a --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt @@ -0,0 +1,72 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage + +class ConversationThemeManager( + private val registry: ConversationThemeRegistry, +) { + private val activeThemes = mutableMapOf>() + + fun activateTheme(conversation: Conversation, themeName: String): Boolean { + if (!registry.isRegistered(themeName)) return false + + val themes = activeThemes.getOrPut(conversation.id) { mutableListOf() } + + // Don't activate if already active + if (themes.any { it.name == themeName }) return false + + val newTheme = registry.create(themeName) + + // Check compatibility with all currently active themes + for (existing in themes) { + val newCompatible = newTheme.compatibleWith.isEmpty() || newTheme.compatibleWith.contains(existing.name) + val existingCompatible = existing.compatibleWith.isEmpty() || existing.compatibleWith.contains(themeName) + if (!newCompatible || !existingCompatible) return false + } + + themes.add(newTheme) + conversation.themeData.addThemeName(themeName) + newTheme.onActivate(conversation) + return true + } + + fun deactivateTheme(conversation: Conversation, themeName: String): Boolean { + val themes = activeThemes[conversation.id] ?: return false + val theme = themes.find { it.name == themeName } ?: return false + + theme.onDeactivate(conversation) + themes.remove(theme) + conversation.themeData.removeThemeName(themeName) + + if (themes.isEmpty()) { + activeThemes.remove(conversation.id) + } + return true + } + + fun getActiveThemes(conversation: Conversation): List = + activeThemes[conversation.id]?.toList() ?: emptyList() + + fun hasTheme(conversation: Conversation, themeName: String): Boolean = + activeThemes[conversation.id]?.any { it.name == themeName } ?: false + + fun onConversationEnd(conversation: Conversation) { + val themes = activeThemes.remove(conversation.id) ?: return + for (theme in themes) { + theme.onDeactivate(conversation) + } + conversation.themeData.let { + for (name in it.activeThemeNames) { + it.removeThemeName(name) + } + } + } + + fun onMessage(conversation: Conversation, message: ConversationMessage) { + val themes = activeThemes[conversation.id] ?: return + for (theme in themes) { + theme.onMessage(conversation, message) + } + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt new file mode 100644 index 0000000..5b854da --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt @@ -0,0 +1,21 @@ +package com.canefe.story.conversation.theme + +class ConversationThemeRegistry { + private val factories = mutableMapOf() + + fun register(name: String, factory: ConversationThemeFactory) { + factories[name] = factory + } + + fun unregister(name: String) { + factories.remove(name) + } + + fun create(name: String): ConversationTheme = + factories[name]?.create() + ?: throw IllegalArgumentException("No theme registered with name: $name") + + fun getRegisteredThemes(): Set = factories.keys.toSet() + + fun isRegistered(name: String): Boolean = factories.containsKey(name) +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt new file mode 100644 index 0000000..097ecd1 --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt @@ -0,0 +1,11 @@ +package com.canefe.story.conversation.theme + +class ViolenceTheme : ConversationTheme() { + override val name: String = NAME + override val displayName: String = "Violence" + override val compatibleWith: Set = setOf(ChatTheme.NAME) + + companion object { + const val NAME = "violence" + } +} diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt new file mode 100644 index 0000000..f7edbef --- /dev/null +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt @@ -0,0 +1,122 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage +import com.canefe.story.testutils.makeNpc +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ConversationThemeManagerTest { + private lateinit var registry: ConversationThemeRegistry + private lateinit var manager: ConversationThemeManager + private lateinit var conversation: Conversation + + @BeforeEach + fun setUp() { + registry = ConversationThemeRegistry() + registry.register(ChatTheme.NAME) { ChatTheme() } + registry.register(ViolenceTheme.NAME) { ViolenceTheme() } + manager = ConversationThemeManager(registry) + + val npc = makeNpc("Guard") + conversation = Conversation( + id = 1, + _players = mutableListOf(), + initialNPCs = listOf(npc), + ) + } + + @Test + fun `activate theme adds it to conversation`() { + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertTrue(manager.hasTheme(conversation, ChatTheme.NAME)) + assertEquals(listOf(ChatTheme.NAME), conversation.themeData.activeThemeNames) + } + + @Test + fun `activate same theme twice returns false`() { + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertFalse(manager.activateTheme(conversation, ChatTheme.NAME)) + } + + @Test + fun `activate unregistered theme returns false`() { + assertFalse(manager.activateTheme(conversation, "nonexistent")) + } + + @Test + fun `compatible themes can stack`() { + // ChatTheme has empty compatibleWith (compatible with everything) + // ViolenceTheme is compatible with ChatTheme + assertTrue(manager.activateTheme(conversation, ChatTheme.NAME)) + assertTrue(manager.activateTheme(conversation, ViolenceTheme.NAME)) + + assertEquals(2, manager.getActiveThemes(conversation).size) + } + + @Test + fun `incompatible themes cannot stack`() { + // Register a "trade" theme that is incompatible with violence + registry.register("trade") { + object : ConversationTheme() { + override val name = "trade" + override val displayName = "Trade" + override val compatibleWith = setOf(ChatTheme.NAME) + } + } + + assertTrue(manager.activateTheme(conversation, ViolenceTheme.NAME)) + // trade is compatible with chat only, not violence; violence is compatible with chat only, not trade + assertFalse(manager.activateTheme(conversation, "trade")) + } + + @Test + fun `deactivate theme removes it`() { + manager.activateTheme(conversation, ChatTheme.NAME) + assertTrue(manager.deactivateTheme(conversation, ChatTheme.NAME)) + assertFalse(manager.hasTheme(conversation, ChatTheme.NAME)) + assertTrue(conversation.themeData.activeThemeNames.isEmpty()) + } + + @Test + fun `deactivate nonexistent theme returns false`() { + assertFalse(manager.deactivateTheme(conversation, ChatTheme.NAME)) + } + + @Test + fun `onConversationEnd cleans up all themes`() { + manager.activateTheme(conversation, ChatTheme.NAME) + manager.activateTheme(conversation, ViolenceTheme.NAME) + + manager.onConversationEnd(conversation) + + assertTrue(manager.getActiveThemes(conversation).isEmpty()) + } + + @Test + fun `onMessage propagates to active themes`() { + var messageReceived = false + registry.register("spy") { + object : ConversationTheme() { + override val name = "spy" + override val displayName = "Spy" + override val compatibleWith = emptySet() + + override fun onMessage(conversation: Conversation, message: ConversationMessage) { + messageReceived = true + } + } + } + + manager.activateTheme(conversation, "spy") + manager.onMessage(conversation, ConversationMessage("user", "hello")) + + assertTrue(messageReceived) + } + + @Test + fun `getActiveThemes returns empty list for conversation with no themes`() { + assertTrue(manager.getActiveThemes(conversation).isEmpty()) + } +} diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt new file mode 100644 index 0000000..ccbf9bb --- /dev/null +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistryTest.kt @@ -0,0 +1,53 @@ +package com.canefe.story.conversation.theme + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ConversationThemeRegistryTest { + private lateinit var registry: ConversationThemeRegistry + + @BeforeEach + fun setUp() { + registry = ConversationThemeRegistry() + } + + @Test + fun `register and create theme`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + + val theme = registry.create(ChatTheme.NAME) + assertEquals(ChatTheme.NAME, theme.name) + assertEquals("Chat", theme.displayName) + } + + @Test + fun `create unregistered theme throws`() { + assertThrows(IllegalArgumentException::class.java) { + registry.create("nonexistent") + } + } + + @Test + fun `getRegisteredThemes returns all registered names`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + registry.register(ViolenceTheme.NAME) { ViolenceTheme() } + + val themes = registry.getRegisteredThemes() + assertEquals(setOf(ChatTheme.NAME, ViolenceTheme.NAME), themes) + } + + @Test + fun `unregister removes theme`() { + registry.register(ChatTheme.NAME) { ChatTheme() } + assertTrue(registry.isRegistered(ChatTheme.NAME)) + + registry.unregister(ChatTheme.NAME) + assertFalse(registry.isRegistered(ChatTheme.NAME)) + } + + @Test + fun `isRegistered returns false for unknown theme`() { + assertFalse(registry.isRegistered("unknown")) + } +} From 2345a23edbceed81e9d4b394b8e9c2775c126c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 19:02:45 +0000 Subject: [PATCH 09/15] Fix theme memory leak, summarization race condition, and input sanitization - Call themeManager.onConversationEnd in completeEndConversation to prevent theme state from leaking for ended conversations - Add clearThemeNames() to ConversationThemeData for cleaner cleanup - Guard checkAndSummarizeHistory against concurrent summarization requests using a summarizingConversations set - Add history.size <= recentMessagesToKeep guard to prevent negative splitIndex causing IllegalArgumentException - Sanitize role prefixes in summarization input using bracketed labels and stripping newlines to prevent role spoofing - Fix messagesSinceLastSummary decrement to only count non-system, non-placeholder messages that were actually counted toward the threshold - Prefix summaries with "Summary of conversation so far:" to distinguish AI-generated summaries from system instructions https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../canefe/story/conversation/Conversation.kt | 11 +++--- .../story/conversation/ConversationManager.kt | 34 +++++++++++++++++-- .../theme/ConversationThemeData.kt | 4 +++ .../theme/ConversationThemeManager.kt | 6 +--- .../story/MessageHistorySummarizationTest.kt | 2 +- .../theme/ConversationThemeManagerTest.kt | 1 + 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt index f068def..df24296 100644 --- a/src/main/kotlin/com/canefe/story/conversation/Conversation.kt +++ b/src/main/kotlin/com/canefe/story/conversation/Conversation.kt @@ -182,6 +182,7 @@ class Conversation( fun replaceHistoryWithSummary( summary: String, summarizedMessagesCount: Int, + countedMessages: Int = summarizedMessagesCount, ) { if (summarizedMessagesCount <= 0 || _history.size < summarizedMessagesCount) { return @@ -189,11 +190,13 @@ class Conversation( // Remove only the messages that were actually summarized _history.subList(0, summarizedMessagesCount).clear() // Prepend the new summary - _history.add(0, ConversationMessage("system", summary)) + _history.add(0, ConversationMessage("system", "Summary of conversation so far: $summary")) - // Decrement rather than reset — messages added during the async - // window will have incremented the counter and must be preserved. - messagesSinceLastSummary -= summarizedMessagesCount + // Decrement only by the number of messages that were actually counted + // toward the summarization threshold (excludes system messages and "..." + // placeholders). Messages added during the async window will have + // incremented the counter and must be preserved. + messagesSinceLastSummary -= countedMessages if (messagesSinceLastSummary < 0) { messagesSinceLastSummary = 0 } diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 4a9b060..838681e 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -35,6 +35,7 @@ class ConversationManager private constructor( private val responseTimers = mutableMapOf() private val endingConversations = Collections.synchronizedSet(mutableSetOf()) + private val summarizingConversations = mutableSetOf() // Getter for scheduled tasks fun getScheduledTasks(): MutableMap = scheduledTasks @@ -337,6 +338,9 @@ class ConversationManager private constructor( cleanupHolograms(conversation) + // Cleanup theme state to prevent memory leak + plugin.themeManager.onConversationEnd(conversation) + // Remove from repository repository.removeConversation(conversation) } @@ -856,11 +860,23 @@ class ConversationManager private constructor( return } + // Prevent concurrent summarization requests for the same conversation + if (!summarizingConversations.add(conversation.id)) { + return + } + val history = conversation.history val nonSystemMessages = history.filter { it.role != "system" } // Need enough messages to make summarization worthwhile if (nonSystemMessages.size <= recentMessagesToKeep) { + summarizingConversations.remove(conversation.id) + return + } + + // Guard against negative splitIndex when history has many system messages + if (history.size <= recentMessagesToKeep) { + summarizingConversations.remove(conversation.id) return } @@ -875,12 +891,20 @@ class ConversationManager private constructor( val splitIndex = history.size - recentMessagesToKeep val messagesToSummarize = history.subList(0, splitIndex) - // Build the summarization prompt + // Count only non-system, non-placeholder messages that were actually + // counted toward the summarization threshold, so replaceHistoryWithSummary + // decrements the counter accurately. + val countedMessages = messagesToSummarize.count { + it.role != "system" && it.content != "..." + } + + // Build the summarization prompt — use bracketed labels instead of raw + // "role: content" to prevent user-supplied newlines from spoofing roles. val summaryPrompt = plugin.promptService.getMessageHistorySummaryPrompt() val conversationText = messagesToSummarize .filter { it.role != "system" || it.content.startsWith("Summary of conversation") } - .joinToString("\n") { "${it.role}: ${it.content}" } + .joinToString("\n") { "[${it.role}] ${it.content.replace("\n", " ")}" } val prompts = listOf( @@ -894,7 +918,7 @@ class ConversationManager private constructor( if (!summary.isNullOrBlank()) { // Safely modify conversation state on the main server thread Bukkit.getScheduler().runTask(plugin, Runnable { - conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount) + conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount, countedMessages) if (plugin.config.debugMessages) { plugin.logger.info( @@ -902,12 +926,16 @@ class ConversationManager private constructor( "New history size: ${conversation.history.size}", ) } + summarizingConversations.remove(conversation.id) }) + } else { + summarizingConversations.remove(conversation.id) } }.exceptionally { e -> plugin.logger.warning( "Failed to summarize message history for conversation ${conversation.id}: ${e.message}", ) + summarizingConversations.remove(conversation.id) null } } diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt index 4cb0080..265dfc4 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeData.kt @@ -12,4 +12,8 @@ data class ConversationThemeData( } fun removeThemeName(name: String): Boolean = _activeThemeNames.remove(name) + + fun clearThemeNames() { + _activeThemeNames.clear() + } } diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt index 5ef001a..9e4ff0b 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt @@ -56,11 +56,7 @@ class ConversationThemeManager( for (theme in themes) { theme.onDeactivate(conversation) } - conversation.themeData.let { - for (name in it.activeThemeNames) { - it.removeThemeName(name) - } - } + conversation.themeData.clearThemeNames() } fun onMessage(conversation: Conversation, message: ConversationMessage) { diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt index 6aa57b8..36d0ff2 100644 --- a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -148,7 +148,7 @@ class MessageHistorySummarizationTest { val messagesToSummarizeCount = totalMessages - 3 conversation.replaceHistoryWithSummary( - "Summary of conversation so far: Alice and Guard discussed things.", + "Alice and Guard discussed things.", messagesToSummarizeCount, ) diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt index f7edbef..cedcdbf 100644 --- a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt @@ -92,6 +92,7 @@ class ConversationThemeManagerTest { manager.onConversationEnd(conversation) assertTrue(manager.getActiveThemes(conversation).isEmpty()) + assertTrue(conversation.themeData.activeThemeNames.isEmpty()) } @Test From 912ff1ad5a0458bf6583e22d5d6e41052fd7c91d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 05:04:37 +0000 Subject: [PATCH 10/15] Make summarizingConversations and activeThemes thread-safe - Use Collections.synchronizedSet for summarizingConversations, matching the pattern used by endingConversations - Use ConcurrentHashMap + CopyOnWriteArrayList for activeThemes in ConversationThemeManager to handle concurrent access safely https://claude.ai/code/session_015f6QcQHuGmUtxHLS1mGahA --- .../com/canefe/story/conversation/ConversationManager.kt | 2 +- .../story/conversation/theme/ConversationThemeManager.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index 838681e..b49f71d 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -35,7 +35,7 @@ class ConversationManager private constructor( private val responseTimers = mutableMapOf() private val endingConversations = Collections.synchronizedSet(mutableSetOf()) - private val summarizingConversations = mutableSetOf() + private val summarizingConversations = Collections.synchronizedSet(mutableSetOf()) // Getter for scheduled tasks fun getScheduledTasks(): MutableMap = scheduledTasks diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt index 9e4ff0b..d393741 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt @@ -2,16 +2,18 @@ package com.canefe.story.conversation.theme import com.canefe.story.conversation.Conversation import com.canefe.story.conversation.ConversationMessage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList class ConversationThemeManager( private val registry: ConversationThemeRegistry, ) { - private val activeThemes = mutableMapOf>() + private val activeThemes = ConcurrentHashMap>() fun activateTheme(conversation: Conversation, themeName: String): Boolean { if (!registry.isRegistered(themeName)) return false - val themes = activeThemes.getOrPut(conversation.id) { mutableListOf() } + val themes = activeThemes.getOrPut(conversation.id) { CopyOnWriteArrayList() } // Don't activate if already active if (themes.any { it.name == themeName }) return false From 508749c533394607fec3b979bb41a3cc5b84533d Mon Sep 17 00:00:00 2001 From: canefe <8518141+canefe@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:07:32 +0400 Subject: [PATCH 11/15] test: fix tests --- .../story/MessageHistorySummarizationTest.kt | 104 +++++++++++------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt index 36d0ff2..43db316 100644 --- a/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt +++ b/src/test/kotlin/com/canefe/story/MessageHistorySummarizationTest.kt @@ -3,8 +3,8 @@ package com.canefe.story import com.canefe.story.command.base.CommandManager import com.canefe.story.conversation.Conversation import com.canefe.story.conversation.ConversationManager -import com.canefe.story.conversation.ConversationMessage import com.canefe.story.testutils.makeNpc +import dev.jorel.commandapi.CommandAPI import io.mockk.* import net.citizensnpcs.api.CitizensAPI import net.citizensnpcs.api.npc.NPCRegistry @@ -27,6 +27,11 @@ class MessageHistorySummarizationTest { System.setProperty("mockbukkit", "true") server = MockBukkit.mock() + mockkStatic(CommandAPI::class) + every { CommandAPI.onLoad(any()) } just Runs + every { CommandAPI.onEnable() } just Runs + every { CommandAPI.onDisable() } just Runs + mockkConstructor(CommandManager::class) every { anyConstructed().registerCommands() } just Runs @@ -42,6 +47,7 @@ class MessageHistorySummarizationTest { plugin.worldInformationManager = mockk(relaxed = true) plugin.npcContextGenerator = mockk(relaxed = true) plugin.sessionManager = mockk(relaxed = true) + plugin.aiResponseService = mockk(relaxed = true) plugin.conversationManager = ConversationManager.getInstance( @@ -63,10 +69,11 @@ class MessageHistorySummarizationTest { fun `messagesSinceLastSummary increments on player messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) assertEquals(0, conversation.messagesSinceLastSummary) @@ -81,10 +88,11 @@ class MessageHistorySummarizationTest { fun `messagesSinceLastSummary increments on NPC messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) assertEquals(0, conversation.messagesSinceLastSummary) @@ -97,10 +105,11 @@ class MessageHistorySummarizationTest { fun `messagesSinceLastSummary does not increment on system messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) conversation.addSystemMessage("A new player has joined") assertEquals(0, conversation.messagesSinceLastSummary) @@ -110,10 +119,11 @@ class MessageHistorySummarizationTest { fun `messagesSinceLastSummary does not count ellipsis user messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) // Simulate the pattern from addNPCMessage: assistant + "..." conversation.addNPCMessage(npc, "Hello") @@ -129,17 +139,18 @@ class MessageHistorySummarizationTest { fun `replaceHistoryWithSummary decrements counter and removes summarized messages`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) // Add several messages - conversation.addPlayerMessage(player, "msg1") // user (+1) - conversation.addPlayerMessage(player, "msg2") // user (+1) - conversation.addNPCMessage(npc, "response1") // assistant (+1) + "..." - conversation.addPlayerMessage(player, "msg3") // user (+1) - conversation.addNPCMessage(npc, "response2") // assistant (+1) + "..." + conversation.addPlayerMessage(player, "msg1") // user (+1) + conversation.addPlayerMessage(player, "msg2") // user (+1) + conversation.addNPCMessage(npc, "response1") // assistant (+1) + "..." + conversation.addPlayerMessage(player, "msg3") // user (+1) + conversation.addNPCMessage(npc, "response2") // assistant (+1) + "..." assertEquals(5, conversation.messagesSinceLastSummary) @@ -165,22 +176,23 @@ class MessageHistorySummarizationTest { fun `replaceHistoryWithSummary preserves messages added during async summarization`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) // Add initial messages - conversation.addPlayerMessage(player, "msg1") // index 0 (+1) - conversation.addPlayerMessage(player, "msg2") // index 1 (+1) - conversation.addPlayerMessage(player, "msg3") // index 2 (+1) + conversation.addPlayerMessage(player, "msg1") // index 0 (+1) + conversation.addPlayerMessage(player, "msg2") // index 1 (+1) + conversation.addPlayerMessage(player, "msg3") // index 2 (+1) // Snapshot: summarize first 2 messages val messagesToSummarizeCount = 2 // Simulate new messages arriving during async summarization - conversation.addPlayerMessage(player, "msg4") // index 3 (+1) - conversation.addPlayerMessage(player, "msg5") // index 4 (+1) + conversation.addPlayerMessage(player, "msg4") // index 3 (+1) + conversation.addPlayerMessage(player, "msg5") // index 4 (+1) // Counter is 5 (all 5 player messages counted) assertEquals(5, conversation.messagesSinceLastSummary) @@ -206,10 +218,11 @@ class MessageHistorySummarizationTest { fun `replaceHistoryWithSummary is a no-op when count exceeds history size`() { val player = server.addPlayer("Alice") val npc = makeNpc("Guard") - val conversation = Conversation( - _players = mutableListOf(player.uniqueId), - initialNPCs = listOf(npc), - ) + val conversation = + Conversation( + _players = mutableListOf(player.uniqueId), + initialNPCs = listOf(npc), + ) conversation.addPlayerMessage(player, "msg1") val originalSize = conversation.history.size @@ -280,7 +293,7 @@ class MessageHistorySummarizationTest { plugin.configService.summarizationThreshold = 5 - val summaryText = "Summary of conversation so far: Alice talked to Guard about the kingdom." + val summaryText = "Alice talked to Guard about the kingdom." every { plugin.aiResponseService.getAIResponseAsync(any(), any()) } returns CompletableFuture.completedFuture(summaryText) @@ -296,6 +309,9 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "msg5") plugin.conversationManager.addPlayerMessage(player, conversation, "msg6") + // Tick the scheduler so the runTask callback from thenAccept executes + server.scheduler.performOneTick() + // After summarization completes, history should be condensed // 1 summary system message + recent messages kept assertTrue(conversation.history.size <= 6) @@ -305,7 +321,7 @@ class MessageHistorySummarizationTest { // First message should be the summary val firstMessage = conversation.history[0] assertEquals("system", firstMessage.role) - assertEquals(summaryText, firstMessage.content) + assertEquals("Summary of conversation so far: $summaryText", firstMessage.content) } @Test @@ -414,6 +430,9 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "round1_msg${i + 1}") } + // Tick the scheduler so the runTask callback from thenAccept executes + server.scheduler.performOneTick() + // Counter should be decremented (not necessarily 0) after summarization assertTrue(conversation.messagesSinceLastSummary < 6) @@ -427,6 +446,9 @@ class MessageHistorySummarizationTest { plugin.conversationManager.addPlayerMessage(player, conversation, "round2_msg${i + 1}") } + // Tick the scheduler so the second round's runTask executes + server.scheduler.performOneTick() + // Should have been called at least twice total (once per round) verify(atLeast = 2) { plugin.aiResponseService.getAIResponseAsync(any(), any()) } } From 51a8b098a7a083c8acdf9423699cf1bcba959a38 Mon Sep 17 00:00:00 2001 From: canefe <8518141+canefe@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:08:48 +0400 Subject: [PATCH 12/15] refactor: non-low-cost ai model summariser --- .../story/conversation/ConversationManager.kt | 85 ++++++++++++------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index b49f71d..f2a6e4a 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -894,50 +894,75 @@ class ConversationManager private constructor( // Count only non-system, non-placeholder messages that were actually // counted toward the summarization threshold, so replaceHistoryWithSummary // decrements the counter accurately. - val countedMessages = messagesToSummarize.count { - it.role != "system" && it.content != "..." - } + val countedMessages = + messagesToSummarize.count { + it.role != "system" && it.content != "..." + } - // Build the summarization prompt — use bracketed labels instead of raw - // "role: content" to prevent user-supplied newlines from spoofing roles. + // Build the summarization prompt — character names are already in the + // message content (e.g. "DrJonas: hello"), so we only need the text. + // Newlines within messages are flattened to prevent injection. val summaryPrompt = plugin.promptService.getMessageHistorySummaryPrompt() - val conversationText = + + // Extract existing summary (if any) and new messages separately + val existingSummary = + messagesToSummarize + .firstOrNull { it.role == "system" && it.content.startsWith("Summary of conversation") } + ?.content + + val newMessages = messagesToSummarize - .filter { it.role != "system" || it.content.startsWith("Summary of conversation") } - .joinToString("\n") { "[${it.role}] ${it.content.replace("\n", " ")}" } + .filter { it.content != "..." } + .filter { it.role != "system" } + .joinToString("\n") { it.content.replace("\n", " ") } + + val userMessage = + if (existingSummary != null) { + "Here is the existing summary of earlier events:\n---\n$existingSummary\n---\n\n" + + "Here are the new messages that happened after that summary:\n---\n$newMessages\n---\n\n" + + "Write a single combined summary that incorporates ALL details from the existing summary " + + "and the new messages. Do not drop any information from the existing summary." + } else { + "Summarize the following conversation transcript:\n---\n$newMessages\n---" + } val prompts = listOf( ConversationMessage("system", summaryPrompt), - ConversationMessage("user", conversationText), + ConversationMessage("user", userMessage), ) // Run summarization asynchronously to avoid blocking the main thread val messagesToSummarizeCount = messagesToSummarize.size - plugin.getAIResponse(prompts, lowCost = true).thenAccept { summary -> - if (!summary.isNullOrBlank()) { - // Safely modify conversation state on the main server thread - Bukkit.getScheduler().runTask(plugin, Runnable { - conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount, countedMessages) - - if (plugin.config.debugMessages) { - plugin.logger.info( - "Message history summarized for conversation ${conversation.id}. " + - "New history size: ${conversation.history.size}", - ) - } + plugin + .getAIResponse(prompts, lowCost = false) + .thenAccept { summary -> + if (!summary.isNullOrBlank()) { + // Safely modify conversation state on the main server thread + Bukkit.getScheduler().runTask( + plugin, + Runnable { + conversation.replaceHistoryWithSummary(summary, messagesToSummarizeCount, countedMessages) + + if (plugin.config.debugMessages) { + plugin.logger.info( + "Message history summarized for conversation ${conversation.id}. " + + "New history size: ${conversation.history.size}", + ) + } + summarizingConversations.remove(conversation.id) + }, + ) + } else { summarizingConversations.remove(conversation.id) - }) - } else { + } + }.exceptionally { e -> + plugin.logger.warning( + "Failed to summarize message history for conversation ${conversation.id}: ${e.message}", + ) summarizingConversations.remove(conversation.id) + null } - }.exceptionally { e -> - plugin.logger.warning( - "Failed to summarize message history for conversation ${conversation.id}: ${e.message}", - ) - summarizingConversations.remove(conversation.id) - null - } } // NPC conversation coordination From df0fccb2ed809848a8de10177eae79504e64b3fc Mon Sep 17 00:00:00 2001 From: canefe <8518141+canefe@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:09:16 +0400 Subject: [PATCH 13/15] refactor: prompt for conversation summariser --- src/main/resources/prompts.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index 5bd5615..908b05c 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -382,17 +382,17 @@ recent_events_generation: message_history_summary: system_prompt: | - You are a concise conversation summarizer. Condense the following conversation history - into a brief summary that preserves all important context needed to continue the conversation naturally. + IMPORTANT: You are NOT roleplaying. You are a narrative summarizer for an ongoing roleplay conversation. + Do NOT continue the conversation. Do NOT write new dialogue or actions. + Do NOT use asterisks, emojis, bold text, markdown, or any formatting. + Do NOT include meta-commentary about "the user" or "the assistant" — use character names only. - Focus on: - - Key topics discussed and decisions made - - Each character's stance, mood, and attitude - - Important information revealed (names, places, events, promises) - - The current direction of the conversation + Write a concise narrative summary of everything that happened in the conversation below. + Use third person, past tense. Narrate it like a story recap — describe what each character + said, did, and felt. Do not omit any significant events, actions, threats, or revelations. - Keep the summary under 200 words. Write in third person, past tense. - Format: "Summary of conversation so far: [your summary]" + Keep the summary under 300 words but include all important details. + Output ONLY the narrative summary — no prefixes, no labels, no section headers. session_history_summary: system_prompt: | From 5b30cb70a1f6047ec0eed2a5d058dc96a62d7bd9 Mon Sep 17 00:00:00 2001 From: canefe <8518141+canefe@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:14:52 +0400 Subject: [PATCH 14/15] feat: conversation themes --- build.gradle.kts | 2 +- src/main/kotlin/com/canefe/story/Story.kt | 5 + .../command/conversation/ConvListCommand.kt | 13 +- .../com/canefe/story/config/PromptService.kt | 13 ++ .../story/conversation/ConversationManager.kt | 3 + .../story/conversation/theme/ChatTheme.kt | 1 + .../conversation/theme/ConversationTheme.kt | 6 +- .../theme/ConversationThemeAgent.kt | 168 ++++++++++++++++++ .../theme/ConversationThemeManager.kt | 21 ++- .../theme/ConversationThemeRegistry.kt | 5 +- .../story/conversation/theme/ViolenceTheme.kt | 52 ++++++ src/main/resources/prompts.yml | 15 ++ .../com/canefe/story/PromptIntegrationTest.kt | 2 +- .../theme/ConversationThemeManagerTest.kt | 18 +- 14 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4ffb10d..113ac38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -357,7 +357,7 @@ tasks.register("deployToSSH") { remotePath ?: throw GradleException("REMOTE_PATH not set. Set it as environment variable or in local.properties") - commandLine("scp", localFile, "$user@$host:$path") + commandLine("scp", "-v", localFile, "$user@$host:$path") doLast { println("✅ Deployed plugin JAR to $user@$host:$path") diff --git a/src/main/kotlin/com/canefe/story/Story.kt b/src/main/kotlin/com/canefe/story/Story.kt index 8e28875..7feb568 100644 --- a/src/main/kotlin/com/canefe/story/Story.kt +++ b/src/main/kotlin/com/canefe/story/Story.kt @@ -149,6 +149,8 @@ open class Story : private set lateinit var themeManager: ConversationThemeManager private set + lateinit var themeAgent: com.canefe.story.conversation.theme.ConversationThemeAgent + private set // NPC Name Aliasing System lateinit var npcNameManager: com.canefe.story.npc.name.NPCNameManager @@ -301,6 +303,9 @@ open class Story : themeRegistry.register(ChatTheme.NAME) { ChatTheme() } themeRegistry.register(ViolenceTheme.NAME) { ViolenceTheme() } themeManager = ConversationThemeManager(themeRegistry) + themeAgent = + com.canefe.story.conversation.theme + .ConversationThemeAgent(this, themeManager, themeRegistry) eventManager = EventManager(this) eventManager.registerEvents() diff --git a/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt b/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt index 8f04872..66715f7 100644 --- a/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt +++ b/src/main/kotlin/com/canefe/story/command/conversation/ConvListCommand.kt @@ -77,10 +77,19 @@ class ConvListCommand( npcNames: List, playerNames: List, ): List { - // Build the prefix with conversation ID + // Build the prefix with conversation ID and active themes val miniMessage = commandUtils.mm val componentList = mutableListOf() - val prefix = miniMessage.deserialize("=====[$id]=====") + + val conversation = commandUtils.conversationManager.getConversationById(id) + val activeThemes = conversation?.themeData?.activeThemeNames ?: emptyList() + val themeSuffix = + if (activeThemes.isNotEmpty()) { + " [${activeThemes.joinToString(", ")}]" + } else { + "" + } + val prefix = miniMessage.deserialize("=====[$id]=====$themeSuffix") // Append clickable NPC names val clickableNpcNames = createClickableNpcNames(id, npcNames) diff --git a/src/main/kotlin/com/canefe/story/config/PromptService.kt b/src/main/kotlin/com/canefe/story/config/PromptService.kt index 74fa4d5..721922b 100644 --- a/src/main/kotlin/com/canefe/story/config/PromptService.kt +++ b/src/main/kotlin/com/canefe/story/config/PromptService.kt @@ -43,6 +43,19 @@ class PromptService( return prompt } + /** Gets the theme analysis prompt */ + fun getThemeAnalysisPrompt( + availableThemes: String, + activeThemes: String, + ): String { + val variables = + mapOf( + "available_themes" to availableThemes, + "active_themes" to activeThemes, + ) + return getPrompt("theme_analysis", variables) + } + /** Gets the behavioral directive prompt with context */ fun getBehavioralDirectivePrompt( recentMessages: String, diff --git a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt index f2a6e4a..c931816 100644 --- a/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/ConversationManager.kt @@ -653,6 +653,9 @@ class ConversationManager private constructor( conversation.addPlayerMessage(player, message) handleHolograms(conversation, player.name) + // Analyze and update conversation themes + plugin.themeAgent.analyzeAndUpdateThemes(conversation) + // Check if message history needs summarization checkAndSummarizeHistory(conversation) diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt index 8621f28..1482725 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ChatTheme.kt @@ -3,6 +3,7 @@ package com.canefe.story.conversation.theme class ChatTheme : ConversationTheme() { override val name: String = NAME override val displayName: String = "Chat" + override val description: String = "Casual, friendly conversation. The default theme for normal dialogue." override val compatibleWith: Set = emptySet() // compatible with everything companion object { diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt index 3e72b8d..1bfe04d 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationTheme.kt @@ -6,11 +6,15 @@ import com.canefe.story.conversation.ConversationMessage abstract class ConversationTheme { abstract val name: String abstract val displayName: String + abstract val description: String abstract val compatibleWith: Set open fun onActivate(conversation: Conversation) {} open fun onDeactivate(conversation: Conversation) {} - open fun onMessage(conversation: Conversation, message: ConversationMessage) {} + open fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) {} } diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt new file mode 100644 index 0000000..7fa948b --- /dev/null +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeAgent.kt @@ -0,0 +1,168 @@ +package com.canefe.story.conversation.theme + +import com.canefe.story.Story +import com.canefe.story.conversation.Conversation +import com.canefe.story.conversation.ConversationMessage +import java.util.Collections + +class ConversationThemeAgent( + private val plugin: Story, + private val themeManager: ConversationThemeManager, + private val registry: ConversationThemeRegistry, +) { + private val analyzingConversations = Collections.synchronizedSet(mutableSetOf()) + + fun analyzeAndUpdateThemes(conversation: Conversation) { + val debug = plugin.config.debugMessages + + // Skip if chat/AI is disabled + if (!conversation.chatEnabled) { + if (debug) plugin.logger.info("[ThemeAgent] Skipping conversation ${conversation.id}: chat disabled") + return + } + + // Prevent concurrent analysis for the same conversation + if (!analyzingConversations.add(conversation.id)) { + if (debug) { + plugin.logger.info( + "[ThemeAgent] Skipping conversation ${conversation.id}: analysis already in progress", + ) + } + return + } + + val recentMessages = + conversation.history + .filter { it.role != "system" && it.content != "..." } + .takeLast(RECENT_MESSAGES_TO_ANALYZE) + + if (recentMessages.isEmpty()) { + if (debug) { + plugin.logger.info( + "[ThemeAgent] Skipping conversation ${conversation.id}: no messages to analyze", + ) + } + analyzingConversations.remove(conversation.id) + return + } + + val availableThemes = buildThemeDescriptions() + val activeThemeNames = themeManager.getActiveThemes(conversation).map { it.name } + + if (debug) { + plugin.logger.info( + "[ThemeAgent] Analyzing conversation ${conversation.id} (${recentMessages.size} messages, active themes: ${activeThemeNames.ifEmpty { + listOf( + "none", + ) + }})", + ) + } + + val prompt = + plugin.promptService.getThemeAnalysisPrompt( + availableThemes = availableThemes, + activeThemes = activeThemeNames.joinToString(", ").ifEmpty { "none" }, + ) + + val transcript = + recentMessages.joinToString("\n") { + it.content.replace("\n", " ") + } + + val prompts = + listOf( + ConversationMessage("system", prompt), + ConversationMessage("user", transcript), + ) + + try { + plugin + .getAIResponse(prompts, lowCost = false) + .thenAccept { response -> + if (debug) { + plugin.logger.info( + "[ThemeAgent] AI response for conversation ${conversation.id}: '$response'", + ) + } + if (!response.isNullOrBlank() && conversation.active) { + applyThemeChanges(conversation, response.trim(), activeThemeNames) + } else if (debug) { + plugin.logger.info( + "[ThemeAgent] No action for conversation ${conversation.id}: response blank=${response.isNullOrBlank()}, active=${conversation.active}", + ) + } + analyzingConversations.remove(conversation.id) + }.exceptionally { ex -> + plugin.logger.warning( + "[ThemeAgent] Analysis failed for conversation ${conversation.id}: ${ex.message}", + ) + analyzingConversations.remove(conversation.id) + null + } + } catch (e: java.util.concurrent.RejectedExecutionException) { + if (debug) plugin.logger.info("[ThemeAgent] Executor rejected for conversation ${conversation.id}") + analyzingConversations.remove(conversation.id) + } + } + + private fun applyThemeChanges( + conversation: Conversation, + response: String, + previousThemes: List, + ) { + val debug = plugin.config.debugMessages + val desiredThemes = + response + .split(",") + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() && it != "none" && registry.isRegistered(it) } + .toSet() + + if (debug) { + plugin.logger.info( + "[ThemeAgent] Desired themes for conversation ${conversation.id}: $desiredThemes (previous: $previousThemes)", + ) + } + + // Deactivate themes that are no longer needed + for (themeName in previousThemes) { + if (themeName !in desiredThemes) { + themeManager.deactivateTheme(conversation, themeName) + if (debug) { + plugin.logger.info( + "[ThemeAgent] Deactivated '$themeName' for conversation ${conversation.id}", + ) + } + } + } + + // Activate new themes + for (themeName in desiredThemes) { + if (themeName !in previousThemes) { + val activated = themeManager.activateTheme(conversation, themeName) + if (debug) { + if (activated) { + plugin.logger.info("[ThemeAgent] Activated '$themeName' for conversation ${conversation.id}") + } else { + plugin.logger.info( + "[ThemeAgent] Failed to activate '$themeName' for conversation ${conversation.id} (incompatible?)", + ) + } + } + } + } + } + + private fun buildThemeDescriptions(): String { + val themes = registry.getRegisteredThemes() + return themes.joinToString("\n") { name -> + val theme = registry.create(name) + "- ${theme.name}: ${theme.description}" + } + } + + companion object { + private const val RECENT_MESSAGES_TO_ANALYZE = 6 + } +} diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt index d393741..6e45f3b 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeManager.kt @@ -10,7 +10,10 @@ class ConversationThemeManager( ) { private val activeThemes = ConcurrentHashMap>() - fun activateTheme(conversation: Conversation, themeName: String): Boolean { + fun activateTheme( + conversation: Conversation, + themeName: String, + ): Boolean { if (!registry.isRegistered(themeName)) return false val themes = activeThemes.getOrPut(conversation.id) { CopyOnWriteArrayList() } @@ -33,7 +36,10 @@ class ConversationThemeManager( return true } - fun deactivateTheme(conversation: Conversation, themeName: String): Boolean { + fun deactivateTheme( + conversation: Conversation, + themeName: String, + ): Boolean { val themes = activeThemes[conversation.id] ?: return false val theme = themes.find { it.name == themeName } ?: return false @@ -50,8 +56,10 @@ class ConversationThemeManager( fun getActiveThemes(conversation: Conversation): List = activeThemes[conversation.id]?.toList() ?: emptyList() - fun hasTheme(conversation: Conversation, themeName: String): Boolean = - activeThemes[conversation.id]?.any { it.name == themeName } ?: false + fun hasTheme( + conversation: Conversation, + themeName: String, + ): Boolean = activeThemes[conversation.id]?.any { it.name == themeName } ?: false fun onConversationEnd(conversation: Conversation) { val themes = activeThemes.remove(conversation.id) ?: return @@ -61,7 +69,10 @@ class ConversationThemeManager( conversation.themeData.clearThemeNames() } - fun onMessage(conversation: Conversation, message: ConversationMessage) { + fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) { val themes = activeThemes[conversation.id] ?: return for (theme in themes) { theme.onMessage(conversation, message) diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt index 5b854da..3210de8 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ConversationThemeRegistry.kt @@ -3,7 +3,10 @@ package com.canefe.story.conversation.theme class ConversationThemeRegistry { private val factories = mutableMapOf() - fun register(name: String, factory: ConversationThemeFactory) { + fun register( + name: String, + factory: ConversationThemeFactory, + ) { factories[name] = factory } diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt index 097ecd1..d111c99 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt @@ -1,10 +1,62 @@ package com.canefe.story.conversation.theme +import com.canefe.story.Story +import com.canefe.story.conversation.Conversation +import org.bukkit.Bukkit +import org.mcmonkey.sentinel.SentinelTrait + class ViolenceTheme : ConversationTheme() { override val name: String = NAME override val displayName: String = "Violence" + override val description: String = "Combat, fighting, threats, or physical aggression. Activate when violence occurs or is imminent." override val compatibleWith: Set = setOf(ChatTheme.NAME) + override fun onActivate(conversation: Conversation) { + val plugin = Story.instance + val npcs = conversation.npcs + val playerUUIDs = conversation.players + + if (npcs.isEmpty() || playerUUIDs.isEmpty()) return + + // Make each NPC attack each player in the conversation + for (npc in npcs) { + for (playerUUID in playerUUIDs) { + val player = Bukkit.getPlayer(playerUUID) ?: continue + plugin.askForPermission( + "Violence theme activated — make ${npc.name} attack ${player.name}?", + onAccept = { + val sentinel = npc.getOrAddTrait(SentinelTrait::class.java) + sentinel.addTarget("player:${player.name}") + if (plugin.config.debugMessages) { + plugin.logger.info("[ViolenceTheme] ${npc.name} is now attacking ${player.name}") + } + }, + onRefuse = {}, + ) + } + } + } + + override fun onDeactivate(conversation: Conversation) { + val plugin = Story.instance + val npcs = conversation.npcs + val playerUUIDs = conversation.players + + // Stop NPCs from attacking players when violence theme ends + for (npc in npcs) { + if (!npc.hasTrait(SentinelTrait::class.java)) continue + val sentinel = npc.getOrAddTrait(SentinelTrait::class.java) + for (playerUUID in playerUUIDs) { + val player = Bukkit.getPlayer(playerUUID) ?: continue + sentinel.removeTarget("player:${player.name}") + } + sentinel.tryUpdateChaseTarget(null) + if (plugin.config.debugMessages) { + plugin.logger.info("[ViolenceTheme] ${npc.name} stopped attacking") + } + } + } + companion object { const val NAME = "violence" } diff --git a/src/main/resources/prompts.yml b/src/main/resources/prompts.yml index 908b05c..b2ffdae 100644 --- a/src/main/resources/prompts.yml +++ b/src/main/resources/prompts.yml @@ -1,6 +1,21 @@ # AI Prompts Configuration # This file contains all AI prompts used throughout the Story plugin +theme_analysis: + system_prompt: | + You are a conversation theme classifier. Analyze the conversation transcript and determine which themes should be active. + + Available themes: + {available_themes} + + Currently active themes: {active_themes} + + RULES: + - Respond ONLY with a comma-separated list of theme names that should be active (e.g. "chat, violence"). + - Respond with "none" if no themes apply. + - Only change themes when the conversation tone has clearly shifted. Do not flip themes on a single ambiguous message. + - Consider the overall direction of the conversation, not just the last message. + behavioral_directive: system_prompt: | You are a behavioral analysis agent responsible for generating actionable roleplay directives. diff --git a/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt b/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt index 2b26022..c752b21 100644 --- a/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt +++ b/src/test/kotlin/com/canefe/story/PromptIntegrationTest.kt @@ -40,7 +40,7 @@ class PromptIntegrationTest { val envKey = System.getenv("OPENROUTER_API_KEY") ?: System.getenv("OPENAI_API_KEY") ?: "" if (envKey.isNotBlank()) { - plugin.config.openAIKey = envKey + // plugin.config.openAIKey = envKey } } diff --git a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt index cedcdbf..00577f8 100644 --- a/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt +++ b/src/test/kotlin/com/canefe/story/conversation/theme/ConversationThemeManagerTest.kt @@ -20,11 +20,12 @@ class ConversationThemeManagerTest { manager = ConversationThemeManager(registry) val npc = makeNpc("Guard") - conversation = Conversation( - id = 1, - _players = mutableListOf(), - initialNPCs = listOf(npc), - ) + conversation = + Conversation( + id = 1, + _players = mutableListOf(), + initialNPCs = listOf(npc), + ) } @Test @@ -62,6 +63,7 @@ class ConversationThemeManagerTest { object : ConversationTheme() { override val name = "trade" override val displayName = "Trade" + override val description = "Trading and bartering" override val compatibleWith = setOf(ChatTheme.NAME) } } @@ -102,9 +104,13 @@ class ConversationThemeManagerTest { object : ConversationTheme() { override val name = "spy" override val displayName = "Spy" + override val description = "Espionage and stealth" override val compatibleWith = emptySet() - override fun onMessage(conversation: Conversation, message: ConversationMessage) { + override fun onMessage( + conversation: Conversation, + message: ConversationMessage, + ) { messageReceived = true } } From d3cec0d581039371daba3bb1503833d740750e92 Mon Sep 17 00:00:00 2001 From: canefe <8518141+canefe@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:10:33 +0400 Subject: [PATCH 15/15] fix: add sentinel check to ViolenceTheme.kt --- .../story/conversation/theme/ViolenceTheme.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt index d111c99..c3049f9 100644 --- a/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt +++ b/src/main/kotlin/com/canefe/story/conversation/theme/ViolenceTheme.kt @@ -11,7 +11,18 @@ class ViolenceTheme : ConversationTheme() { override val description: String = "Combat, fighting, threats, or physical aggression. Activate when violence occurs or is imminent." override val compatibleWith: Set = setOf(ChatTheme.NAME) + private val sentinelAvailable: Boolean by lazy { + try { + Class.forName("org.mcmonkey.sentinel.SentinelTrait") + true + } catch (_: ClassNotFoundException) { + false + } + } + override fun onActivate(conversation: Conversation) { + if (!sentinelAvailable) return + val plugin = Story.instance val npcs = conversation.npcs val playerUUIDs = conversation.players @@ -38,6 +49,8 @@ class ViolenceTheme : ConversationTheme() { } override fun onDeactivate(conversation: Conversation) { + if (!sentinelAvailable) return + val plugin = Story.instance val npcs = conversation.npcs val playerUUIDs = conversation.players