From 2825ef71511e8687e0e48b8f76a0b1b196d6548a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:09:32 +0100 Subject: [PATCH] feat(broker): add conversation memory to telegram AI (10-turn window) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/broker/src/telegram-ai.ts | 69 +++++++++++++++++++++++++++++- apps/broker/src/telegram-bridge.ts | 17 +++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/broker/src/telegram-ai.ts b/apps/broker/src/telegram-ai.ts index f1e45f9..13c1fae 100644 --- a/apps/broker/src/telegram-ai.ts +++ b/apps/broker/src/telegram-ai.ts @@ -178,33 +178,99 @@ function getClient(): Anthropic { return client; } +// --------------------------------------------------------------------------- +// Conversation history (per chat, rolling window) +// --------------------------------------------------------------------------- + +const MAX_HISTORY = 10; +const HISTORY_TTL_MS = 30 * 60 * 1000; // 30 min + +interface HistoryEntry { + role: "user" | "assistant"; + content: string; + ts: number; +} + +const chatHistory = new Map(); + +// Clean stale histories every 10 min +setInterval(() => { + const now = Date.now(); + for (const [chatId, entries] of chatHistory) { + const fresh = entries.filter(e => now - e.ts < HISTORY_TTL_MS); + if (fresh.length === 0) chatHistory.delete(chatId); + else chatHistory.set(chatId, fresh); + } +}, 10 * 60 * 1000); + +function getHistory(chatId: number): HistoryEntry[] { + return chatHistory.get(chatId) ?? []; +} + +function pushHistory(chatId: number, role: "user" | "assistant", content: string): void { + const entries = chatHistory.get(chatId) ?? []; + entries.push({ role, content, ts: Date.now() }); + if (entries.length > MAX_HISTORY * 2) entries.splice(0, entries.length - MAX_HISTORY * 2); + chatHistory.set(chatId, entries); +} + +/** + * Record a tool result in conversation history so the AI knows what happened. + */ +export function recordToolResult(chatId: number, toolName: string, resultSummary: string): void { + pushHistory(chatId, "assistant", `[Tool ${toolName} result]: ${resultSummary}`); +} + /** * Process a natural language message through Claude and return the intent. */ export async function processMessage( + chatId: number, userMessage: string, context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] }, ): Promise { try { const anthropic = getClient(); + // Record user message in history + pushHistory(chatId, "user", userMessage); + const contextInfo = [ context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null, context.userName ? `User's name: ${context.userName}` : null, context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null, ].filter(Boolean).join(". "); + // Build message history for multi-turn context + const history = getHistory(chatId); + const messages: Array<{ role: "user" | "assistant"; content: string }> = []; + for (const entry of history) { + // Alternate roles — Claude API requires user/assistant alternation + if (messages.length === 0 || messages[messages.length - 1]!.role !== entry.role) { + messages.push({ role: entry.role, content: entry.content }); + } else { + // Same role consecutive — merge into the last message + messages[messages.length - 1]!.content += "\n" + entry.content; + } + } + + // Ensure messages start with user and alternate + if (messages.length > 0 && messages[0]!.role !== "user") { + messages.shift(); + } + const response = await anthropic.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 500, system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""), tools: TOOLS as Anthropic.Messages.Tool[], - messages: [{ role: "user", content: userMessage }], + messages, }); // Check for tool use for (const block of response.content) { if (block.type === "tool_use") { + pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`); return { type: "tool_call", toolCall: { name: block.name, input: block.input as Record }, @@ -212,6 +278,7 @@ export async function processMessage( }; } if (block.type === "text") { + pushHistory(chatId, "assistant", block.text); return { type: "text", text: block.text }; } } diff --git a/apps/broker/src/telegram-bridge.ts b/apps/broker/src/telegram-bridge.ts index 00ad971..a591e6b 100644 --- a/apps/broker/src/telegram-bridge.ts +++ b/apps/broker/src/telegram-bridge.ts @@ -1204,12 +1204,11 @@ function setupBotCommands( await ctx.answerCallbackQuery({ text: "Executing..." }); try { - const { formatResult } = await import("./telegram-ai"); + const { formatResult, recordToolResult } = await import("./telegram-ai"); const result = await executeAiToolCall(pending.toolCall, pending.meshIds); - await ctx.editMessageText( - formatResult(pending.toolCall.name, result), - { parse_mode: "HTML" }, - ); + const resultText = formatResult(pending.toolCall.name, result); + recordToolResult(chatId, pending.toolCall.name, resultText.replace(/<[^>]+>/g, "").slice(0, 200)); + await ctx.editMessageText(resultText, { parse_mode: "HTML" }); } catch (err) { await ctx.editMessageText(`❌ Failed: ${err instanceof Error ? err.message : String(err)}`); } @@ -1612,7 +1611,7 @@ function setupBotCommands( } catch {} } - const result = await processMessage(text, { + const result = await processMessage(chatId, text, { meshSlug: allMeshSlugs[0], meshSlugs: allMeshSlugs, userName: ctx.from?.first_name, @@ -1656,7 +1655,11 @@ function setupBotCommands( } else { // Read-only action — execute immediately const execResult = await executeAiToolCall(result.toolCall, meshIds); - await ctx.reply(formatResult(result.toolCall.name, execResult), { + const resultText = formatResult(result.toolCall.name, execResult); + // Record in conversation history + const { recordToolResult } = await import("./telegram-ai"); + recordToolResult(chatId, result.toolCall.name, resultText.replace(/<[^>]+>/g, "").slice(0, 200)); + await ctx.reply(resultText, { parse_mode: "HTML", }); }