Skip to content

Implement AI-Powered Message Squashing with SquashOperation Tracking #49

Description

@Lewik

Overview

Implement AI-powered message squashing to replace simple concatenation with semantic compression. The infrastructure (SquashOperation entity, repository, database schema) is already complete and waiting for integration.

Current State

What exists:

  • SquashOperation domain model with provenance tracking
  • SquashOperationRepository interface + Exposed implementation
  • ✅ Database schema (squash_operations table)
  • ConversationEngineService with Spring AI ChatModel integration
  • ✅ UI with message selection and squash button

What's broken:

  • ConversationService.squashMessages() uses deprecated originalIds instead of squashOperationId
  • TabViewModel.squashSelectedMessages() does simple concatenation (joinToString("\n\n"))
  • ❌ No AI semantic compression - just string joining
  • ❌ No SquashOperation records created - audit trail missing

Architecture Design

5-Layer Architecture

Layer 1: Domain Model (✅ Complete)
```kotlin
data class SquashOperation(
val id: Id,
val conversationId: Conversation.Id,
val sourceMessageIds: List<Message.Id>,
val resultMessageId: Message.Id,
val prompt: String? = null, // AI prompt used (null for manual)
val model: String? = null, // "sonnet" (null for manual)
val performedByAgent: Boolean, // true=AI, false=manual
val createdAt: Instant,
)
```

Layer 2: Repository (✅ Complete)

  • SquashOperationRepository with save/find methods
  • Already wired in DI config

Layer 3: AI Engine Service (🔄 Add method)
```kotlin
// ConversationEngineService.kt
suspend fun squashMessages(
conversationId: Conversation.Id,
messageIds: List<Message.Id>,
customPrompt: String? = null
): SquashResult {
// 1. Load messages
// 2. Build AI squash prompt (XML format)
// 3. Call ChatModel with no tools, low temperature
// 4. Return SquashResult(squashedText, prompt, model)
}

data class SquashResult(
val squashedText: String,
val prompt: String,
val model: String
)
```

Layer 4: Conversation Service (🔄 Update existing + add new method)
```kotlin
// Update existing method
suspend fun squashMessages(
conversationId: Conversation.Id,
messageIds: List<Message.Id>,
squashedContent: List
): Conversation? {
// CREATE SquashOperation with performedByAgent=false
// SET Message.squashOperationId (NOT originalIds)
// ... rest of existing logic
}

// New method for AI squash
suspend fun squashMessagesWithAI(
conversationId: Conversation.Id,
messageIds: List<Message.Id>,
squashedText: String,
prompt: String,
model: String
): Conversation? {
// CREATE SquashOperation with performedByAgent=true
// SAVE prompt + model for reproducibility
// ... same Thread creation logic
}
```

Layer 5: UI (🔄 Add AI option with loading state)

  • Replace single "Squash" button with dropdown menu
  • Options: "Manual (Concatenate)" and "AI Squash"
  • Add loading indicator during AI call
  • Add isSquashing state to TabViewModel.UIState

Prompt Engineering Strategy

XML-Structured Prompt for Claude

```xml

Compress the following messages into a single, concise message that preserves essential meaning and intent.

- Preserve all key information and intent from the original messages - Remove redundancy, verbosity, and unnecessary elaboration - Maintain the original semantic meaning - do not add interpretations - Use clear, direct language appropriate for technical communication - For tool calls and results: summarize the outcome (what was achieved), not the mechanism - Keep technical details only if they are essential to understanding - If messages contain errors or corrections, include only the final corrected state - Output ONLY the compressed message text - no preamble, no meta-commentary

<messages_to_squash>

Original message 1 text...


Original message 2 text...

</messages_to_squash>
```

Content Type Handling

Content Type Extraction Strategy Example Output
`UserMessage` Direct text "Find all TypeScript files"
`AssistantMessage` `structured.fullText` "I found 42 TypeScript files..."
`ToolCall` Summarize intent "Searched for TypeScript files" (not raw JSON)
`ToolResult` Summarize outcome "Found 42 matches" (not full output)
`Thinking` Include reasoning "Reasoning: Need to check both src/ and test/"
`System` Context info "Warning: Large file ignored"

Model Configuration

```kotlin
ClaudeCodeChatOptions.builder()
.model("sonnet") // Current default
.maxTokens(4096) // Enough for squashed result
.temperature(0.3) // Low temp for consistency
.thinkingBudgetTokens(0) // No extended thinking needed
.toolNames(emptySet()) // No tools for squash
.build()
```

Why temperature 0.3?

  • Deterministic compression (same input → similar output)
  • Consistent quality
  • Still allows natural language flow

Implementation Phases

Phase 1: SquashOperation Infrastructure Integration (1-2h)

Goal: Wire existing infrastructure without AI.

Tasks:

  • Update `ConversationService.squashMessages()`:
    • Pre-generate `SquashOperation.Id` before creating Message
    • Create `Message` with `squashOperationId` (not `originalIds`)
    • Save `SquashOperation` with `performedByAgent=false`, `prompt=null`, `model=null`
  • Add `squashOperationRepo: SquashOperationRepository` to constructor
  • Test manual squash through UI - verify DB records

Success Metrics:

  • ✅ Every manual squash creates `SquashOperation` row in database
  • ✅ `Message.squashOperationId` correctly references operation
  • ✅ No regression - existing squash flow works identically

Risk: Low - just wiring existing components


Phase 2: AI Squash Engine (2-3h)

Goal: Implement AI semantic compression.

Tasks:

  • Add `ConversationEngineService.squashMessages()`:
    • Load messages by IDs from conversation
    • Build XML squash prompt with all content types
    • Extract text from `UserMessage`, `AssistantMessage`, `ToolCall`, `ToolResult`, `Thinking`
    • Call `chatModel.call()` (blocking, no streaming needed)
    • Return `SquashResult(squashedText, prompt, model)`
  • Add `ConversationService.squashMessagesWithAI()`:
    • Create `SquashOperation` with `performedByAgent=true`
    • Save prompt + model for reproducibility
    • Reuse Thread creation logic from manual squash
  • Unit tests for prompt building logic

Success Metrics:

  • ✅ AI call completes successfully
  • ✅ Squashed text is shorter than concatenated original
  • ✅ Key information preserved (manual review of 5+ test cases)
  • ✅ `SquashOperation` saved with non-null prompt + model

Risk: Medium - prompt quality affects results


Phase 3: UI Integration (2-3h)

Goal: Add AI squash option with loading states.

Tasks:

  • Add `isSquashing: Boolean` to `TabViewModel.UIState`
  • Add `TabViewModel.squashSelectedMessagesWithAI()`:
    • Set `isSquashing = true`
    • Call `conversationEngineService.squashMessages()`
    • Call `conversationService.squashMessagesWithAI()`
    • Handle exceptions with user-visible errors
    • Clear loading state in finally block
  • Update `SessionScreen.kt`:
    • Replace single "Squash" button with dropdown menu
    • Menu options: "Manual (Concatenate)" and "AI Squash"
    • Show `CircularProgressIndicator` during AI call
    • Disable "AI Squash" option while `isSquashing = true`

Success Metrics:

  • ✅ User can choose between Manual and AI squash
  • ✅ Loading indicator appears during AI call (2-5 seconds)
  • ✅ Error messages shown to user if AI fails
  • ✅ UI remains responsive during squash

Risk: Low - straightforward UI changes


Phase 4: Custom Prompts (Optional Enhancement) (1-2h)

Goal: Allow user-customized squash behavior.

Tasks:

  • Add "Custom AI Squash..." menu item to dropdown
  • Show dialog with text field for custom instructions
  • Pass `customPrompt` parameter to engine service
  • Append to `<additional_instructions>` section in prompt

Use Cases:

  • "Squash into bullet points"
  • "Keep only technical details, remove explanations"
  • "Translate result to Russian"

Success Metrics:

  • ✅ Custom instructions visibly affect squash result
  • ✅ Custom prompt stored in `SquashOperation` for audit trail

Risk: Low - optional feature, doesn't block MVP


Phase 5: Cleanup & Deprecation (Later) (1-2h)

Goal: Remove deprecated `originalIds` field after stabilization.

Tasks:

  • Mark `Message.originalIds` with `@Deprecated(level = ERROR)`
  • Optional migration script:
    • Backfill `SquashOperation` for old squashed messages
    • Parse `originalIds` from existing messages
    • Create operations with `performedByAgent=false`, `prompt=null`
  • Remove `originalIds` field from domain model (breaking change)
  • Update docs/domain-model.md

Success Metrics:

  • ✅ No code references `originalIds`
  • ✅ All squash operations tracked via `SquashOperation`
  • ✅ Old data either migrated or documented as legacy

Risk: Medium - breaking change requires careful rollout


Effort Estimate

Phase Time Complexity Priority
Phase 1 1-2h Low High
Phase 2 2-3h Medium High
Phase 3 2-3h Low High
Phase 4 1-2h Low Medium (optional)
Phase 5 1-2h Medium Low (later)
Total MVP (1-3) 5-8h
Full Implementation 7-12h

MVP = Phases 1-3: Working AI squash with provenance tracking.

Edge Cases & Error Handling

Edge Case: Non-contiguous messages

User selects messages [1, 3, 5] (skipping 2, 4).

Handling:

  • Sort by original position before building prompt
  • AI sees messages in conversation order
  • Result replaces first selected message position

Edge Case: Very long messages

10 messages × 5K tokens = 50K tokens input.

Handling:

  • Phase 1: No limit, let Claude CLI handle context window
  • If exceeds context: Clear error message to user
  • Future: Batch squash or reject upfront with size estimate

Edge Case: Tool calls only

Messages contain only `ToolCall` + `ToolResult`, no user/assistant text.

Handling:

  • Extract tool names + summarize results
  • Example: "Used grep to find 42 TypeScript files, then read main.ts and found 3 type errors"

Risk: AI produces incoherent result

Squash result loses meaning or introduces errors.

Mitigation:

  • Low temperature (0.3) for consistency
  • Clear, tested prompt instructions
  • User can undo via Thread switching - no data loss
  • Manual squash always available as fallback

Risk: Latency

AI call takes 2-5 seconds, user waits.

Mitigation:

  • Loading indicator with clear state
  • Async operation - UI remains responsive
  • User expectation: "AI Squash" label implies processing

Success Criteria

Phase 1 Complete:

  • ✅ Manual squash creates `SquashOperation` in database
  • ✅ No UI regression

Phase 2 Complete:

  • ✅ AI squash engine returns coherent, compressed text
  • ✅ Provenance tracking (prompt + model) works
  • ✅ 5+ manual test cases pass review

Phase 3 Complete:

  • ✅ UI offers Manual vs AI choice in dropdown
  • ✅ Loading indicator works
  • ✅ Error handling with user feedback

MVP Complete (Phases 1-3):

  • ✅ User can AI squash 2+ messages from UI
  • ✅ Result appears in conversation correctly
  • ✅ Undo works via Thread switching
  • ✅ `SquashOperation` audit trail complete for all squashes

Technical Notes

Why Not Streaming for Squash?

Use `chatModel.call()` (blocking) instead of `stream()`:

  • Squash result needed in full before saving
  • No intermediate UI updates required
  • Simpler error handling
  • Faster completion (no chunk processing overhead)

Thread Safety

All squash operations are Copy-on-Write:

  • New Thread created for result
  • Original messages immutable
  • Safe for concurrent reads
  • Undo via Thread switching

Database Schema (Already Exists)

```sql
CREATE TABLE squash_operations (
id VARCHAR(255) PRIMARY KEY,
conversation_id VARCHAR(255) REFERENCES conversations(id) ON DELETE CASCADE,
source_message_ids TEXT, -- JSON array of message IDs
result_message_id VARCHAR(255) REFERENCES messages(id),
prompt TEXT NULL, -- AI prompt used (null for manual)
model VARCHAR(255) NULL, -- Model name (null for manual)
performed_by_agent BOOLEAN,
created_at TIMESTAMP
);
```

References

  • Domain Model Docs: `docs/domain-model.md` (lines 44-69 cover SquashOperation)
  • Current Implementation:
    • `shared/src/commonMain/kotlin/com/gromozeka/shared/services/ConversationService.kt:231` (manual squash)
    • `bot/src/jvmMain/kotlin/com/gromozeka/bot/ui/viewmodel/TabViewModel.kt:377` (UI caller)
  • TODO Markers:
    • `ConversationService.kt:255` - "TODO: Implement AI-powered squash with SquashOperation tracking"
    • `ConversationService.kt:262` - "TODO: migrate to squashOperationId after AI squash implementation"
    • `TabViewModel.kt:387` - "TODO: Replace with AI-powered squash using ConversationEngineService"

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions