feat(broker): add conversation memory to telegram AI (10-turn window)
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-13 21:09:32 +01:00
parent a9858ef876
commit 2825ef7151
2 changed files with 78 additions and 8 deletions

View File

@@ -178,33 +178,99 @@ function getClient(): Anthropic {
return client; 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<number, HistoryEntry[]>();
// 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. * Process a natural language message through Claude and return the intent.
*/ */
export async function processMessage( export async function processMessage(
chatId: number,
userMessage: string, userMessage: string,
context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] }, context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] },
): Promise<AiResult> { ): Promise<AiResult> {
try { try {
const anthropic = getClient(); const anthropic = getClient();
// Record user message in history
pushHistory(chatId, "user", userMessage);
const contextInfo = [ const contextInfo = [
context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null, context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null,
context.userName ? `User's name: ${context.userName}` : null, context.userName ? `User's name: ${context.userName}` : null,
context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null, context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null,
].filter(Boolean).join(". "); ].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({ const response = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001", model: "claude-haiku-4-5-20251001",
max_tokens: 500, max_tokens: 500,
system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""), system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""),
tools: TOOLS as Anthropic.Messages.Tool[], tools: TOOLS as Anthropic.Messages.Tool[],
messages: [{ role: "user", content: userMessage }], messages,
}); });
// Check for tool use // Check for tool use
for (const block of response.content) { for (const block of response.content) {
if (block.type === "tool_use") { if (block.type === "tool_use") {
pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`);
return { return {
type: "tool_call", type: "tool_call",
toolCall: { name: block.name, input: block.input as Record<string, unknown> }, toolCall: { name: block.name, input: block.input as Record<string, unknown> },
@@ -212,6 +278,7 @@ export async function processMessage(
}; };
} }
if (block.type === "text") { if (block.type === "text") {
pushHistory(chatId, "assistant", block.text);
return { type: "text", text: block.text }; return { type: "text", text: block.text };
} }
} }

View File

@@ -1204,12 +1204,11 @@ function setupBotCommands(
await ctx.answerCallbackQuery({ text: "Executing..." }); await ctx.answerCallbackQuery({ text: "Executing..." });
try { try {
const { formatResult } = await import("./telegram-ai"); const { formatResult, recordToolResult } = await import("./telegram-ai");
const result = await executeAiToolCall(pending.toolCall, pending.meshIds); const result = await executeAiToolCall(pending.toolCall, pending.meshIds);
await ctx.editMessageText( const resultText = formatResult(pending.toolCall.name, result);
formatResult(pending.toolCall.name, result), recordToolResult(chatId, pending.toolCall.name, resultText.replace(/<[^>]+>/g, "").slice(0, 200));
{ parse_mode: "HTML" }, await ctx.editMessageText(resultText, { parse_mode: "HTML" });
);
} catch (err) { } catch (err) {
await ctx.editMessageText(`❌ Failed: ${err instanceof Error ? err.message : String(err)}`); await ctx.editMessageText(`❌ Failed: ${err instanceof Error ? err.message : String(err)}`);
} }
@@ -1612,7 +1611,7 @@ function setupBotCommands(
} catch {} } catch {}
} }
const result = await processMessage(text, { const result = await processMessage(chatId, text, {
meshSlug: allMeshSlugs[0], meshSlug: allMeshSlugs[0],
meshSlugs: allMeshSlugs, meshSlugs: allMeshSlugs,
userName: ctx.from?.first_name, userName: ctx.from?.first_name,
@@ -1656,7 +1655,11 @@ function setupBotCommands(
} else { } else {
// Read-only action — execute immediately // Read-only action — execute immediately
const execResult = await executeAiToolCall(result.toolCall, meshIds); 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", parse_mode: "HTML",
}); });
} }