From a794e387f3c7becdeec4ad609cb8679ac5981b89 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:06:48 -0400 Subject: [PATCH] add memory --- .gitignore | 1 + Commands/LlmCommands.cs | 2 +- Models/MemoryModels.cs | 6 + Program.cs | 16 ++ Services/BotService.cs | 26 ++- Services/LlmService.cs | 219 +++++++++++++++++++------ Services/Memory/DatabaseInitializer.cs | 65 ++++++++ Services/Memory/MemoryStore.cs | 186 +++++++++++++++++++++ Services/Tools/MemoryTools.cs | 200 ++++++++++++++++++++++ Services/Tools/ToolRegistry.cs | 55 +++++++ TentacleBot.csproj | 1 + appsettings.json | 3 + docker-compose.yml | 8 +- 13 files changed, 728 insertions(+), 60 deletions(-) create mode 100644 Models/MemoryModels.cs create mode 100644 Services/Memory/DatabaseInitializer.cs create mode 100644 Services/Memory/MemoryStore.cs create mode 100644 Services/Tools/MemoryTools.cs create mode 100644 Services/Tools/ToolRegistry.cs diff --git a/.gitignore b/.gitignore index 1ce2bc2..a372e84 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ project.lock.json # secrets .env +data/ # OS generated files *.swp diff --git a/Commands/LlmCommands.cs b/Commands/LlmCommands.cs index 2f70342..c4b23b3 100644 --- a/Commands/LlmCommands.cs +++ b/Commands/LlmCommands.cs @@ -30,7 +30,7 @@ public class LlmCommands : InteractionModuleBase { var accumulated = ""; - await foreach (var text in _llmService.SendMessageAsync(channelId, userName, prompt)) + await foreach (var text in _llmService.SendMessageAsync(channelId, Context.User.Id, userName, prompt)) { accumulated = text; } diff --git a/Models/MemoryModels.cs b/Models/MemoryModels.cs new file mode 100644 index 0000000..2432235 --- /dev/null +++ b/Models/MemoryModels.cs @@ -0,0 +1,6 @@ +namespace TentacleBot.Models; + +public record UserRule(int Id, string UserId, string RuleText, DateTime CreatedAt); +public record MemoryEntry(int Id, string UserId, string Content, string? Category, int Importance, DateTime CreatedAt); +public record ToolCall(string Id, string Type, ToolCallFunction Function); +public record ToolCallFunction(string Name, string Arguments); diff --git a/Program.cs b/Program.cs index 4ed7191..55b3bf0 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using TentacleBot.Services; +using TentacleBot.Services.Memory; +using TentacleBot.Services.Tools; using TentacleBot.Commands; var configuration = new ConfigurationBuilder() @@ -36,11 +38,25 @@ var services = new ServiceCollection() .AddHttpClient() .AddSingleton(); +// Initialize memory database +var dbPath = configuration["Memory:DatabasePath"] ?? "data/tentaclebot.db"; +var dbInit = new DatabaseInitializer(dbPath); +dbInit.Initialize(); +services.AddSingleton(dbInit); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); var botService = serviceProvider.GetRequiredService(); var interactionService = serviceProvider.GetRequiredService(); +// Register memory tools +var toolRegistry = serviceProvider.GetRequiredService(); +var memoryTools = serviceProvider.GetRequiredService(); +memoryTools.RegisterAll(toolRegistry); + // Register command modules await interactionService.AddModulesAsync( typeof(Program).Assembly, diff --git a/Services/BotService.cs b/Services/BotService.cs index 6f990d0..2946567 100644 --- a/Services/BotService.cs +++ b/Services/BotService.cs @@ -103,7 +103,10 @@ public class BotService return; var botId = _client.CurrentUser.Id; + var botName = _client.CurrentUser.Username; var hasMention = message.MentionedUsers.Any(u => u.Id == botId); + var hasTextMention = message.Content.TrimStart() + .StartsWith($"@{botName}", StringComparison.OrdinalIgnoreCase); var isReplyToBot = false; if (message.Reference?.MessageId.IsSpecified == true) @@ -121,10 +124,16 @@ public class BotService } } - if (!hasMention && !isReplyToBot) + if (!hasMention && !isReplyToBot && !hasTextMention) return; - var prompt = hasMention ? RemoveBotMentions(message.Content, botId) : message.Content.Trim(); + string prompt; + if (hasMention) + prompt = RemoveBotMentions(message.Content, botId); + else if (hasTextMention) + prompt = RemoveBotName(message.Content, botName); + else + prompt = message.Content.Trim(); if (string.IsNullOrWhiteSpace(prompt)) { @@ -136,7 +145,7 @@ public class BotService { var accumulated = string.Empty; - await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Username, prompt)) + await foreach (var text in _llmService.SendMessageAsync(message.Channel.Id, message.Author.Id, message.Author.Username, prompt)) { accumulated = text; } @@ -190,6 +199,17 @@ public class BotService .Trim(); } + private static string RemoveBotName(string content, string botName) + { + if (string.IsNullOrWhiteSpace(content)) + return string.Empty; + + return System.Text.RegularExpressions.Regex.Replace( + content, + $@"^\s*@{System.Text.RegularExpressions.Regex.Escape(botName)}\s*", + "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Trim(); + } + private static Task LogAsync(LogMessage message) { Console.WriteLine($"[{message.Severity}] {message.Source}: {message.Message}"); diff --git a/Services/LlmService.cs b/Services/LlmService.cs index 0475c77..b62f7dd 100644 --- a/Services/LlmService.cs +++ b/Services/LlmService.cs @@ -2,8 +2,12 @@ using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; +using TentacleBot.Models; +using TentacleBot.Services.Memory; +using TentacleBot.Services.Tools; namespace TentacleBot.Services; @@ -11,6 +15,8 @@ public class LlmService { private readonly HttpClient _httpClient; private readonly ILogger _logger; + private readonly MemoryStore _memoryStore; + private readonly ToolRegistry _toolRegistry; private readonly string _endpoint; private readonly string _apiKey; private readonly string _model; @@ -18,15 +24,18 @@ public class LlmService private readonly double _temperature; private readonly ConcurrentDictionary> _histories = new(); private const int MaxHistoryChars = 20000; + private const int MaxToolIterations = 5; - public LlmService(IConfiguration configuration, ILogger logger) + public LlmService(IConfiguration configuration, ILogger logger, MemoryStore memoryStore, ToolRegistry toolRegistry) { _logger = logger; _httpClient = new HttpClient(); + _memoryStore = memoryStore; + _toolRegistry = toolRegistry; _endpoint = configuration["Llm:Endpoint"] ?? throw new InvalidOperationException("Llm:Endpoint is not configured"); _apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") - ?? throw new InvalidOperationException("LLM_API_KEY environment variable is not set"); + ?? " "; _model = configuration["Llm:Model"] ?? throw new InvalidOperationException("Llm:Model is not configured"); _maxTokens = int.Parse(configuration["Llm:MaxTokens"] ?? "1024"); _temperature = double.Parse(configuration["Llm:Temperature"] ?? "0.7"); @@ -35,76 +44,97 @@ public class LlmService _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } - public async IAsyncEnumerable SendMessageAsync(ulong channelId, string user, string message) + public async IAsyncEnumerable SendMessageAsync(ulong channelId, ulong userId, string username, string message) { var history = _histories.GetOrAdd(channelId, _ => new List()); - history.Add(new ChatMessage { Role = "user", Content = message }); + var systemPrompt = await BuildSystemPromptAsync(userId, username, message); + if (history.Count == 0 || history[0].Role != "system") + history.Insert(0, new ChatMessage { Role = "system", Content = systemPrompt }); + else + history[0] = new ChatMessage { Role = "system", Content = systemPrompt }; + history.Add(new ChatMessage { Role = "user", Content = message, Name = username }); TrimHistory(history); - var request = new ChatRequest + var toolDefinitions = _toolRegistry.BuildToolDefinitions(); + var jsonOptions = new JsonSerializerOptions { - Model = _model, - Messages = history.Select(m => new ChatMessage { Role = m.Role, Content = m.Content }).ToList(), - MaxTokens = _maxTokens, - Temperature = (float)_temperature, - Stream = true + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true }; - var json = JsonSerializer.Serialize(request, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - using var response = await _httpClient.PostAsync(_endpoint, content); - - if (!response.IsSuccessStatusCode) + for (int iteration = 0; iteration < MaxToolIterations; iteration++) { - var errorBody = await response.Content.ReadAsStringAsync(); - _logger.LogError("LLM API error ({StatusCode}): {Error}", response.StatusCode, errorBody); - throw new HttpRequestException($"LLM API returned {response.StatusCode}: {errorBody}"); - } - - var accumulated = new StringBuilder(); - - var mediaType = response.Content.Headers.ContentType?.MediaType; - if (string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)) - { - await using var stream = await response.Content.ReadAsStreamAsync(); - using var reader = new StreamReader(stream); - - while (await reader.ReadLineAsync() is { } line) + var request = new ChatRequest { - if (!line.StartsWith("data:", StringComparison.Ordinal)) - continue; + Model = _model, + Messages = CopyMessages(history), + MaxTokens = _maxTokens, + Temperature = (float)_temperature, + Stream = false, + Tools = toolDefinitions + }; - var data = line.Substring(5).Trim(); - if (data == "[DONE]") - break; + var requestJson = JsonSerializer.Serialize(request, jsonOptions); + var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); - var token = TryParseStreamToken(data); - if (!string.IsNullOrEmpty(token)) - { - accumulated.Append(token); - yield return accumulated.ToString(); - } + using var response = await _httpClient.PostAsync(_endpoint, httpContent); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("LLM API error ({StatusCode}): {Error}", response.StatusCode, errorBody); + throw new HttpRequestException($"LLM API returned {response.StatusCode}: {errorBody}"); } - } - else - { + var body = await response.Content.ReadAsStringAsync(); - var fullText = TryParseNonStreamContent(body); - if (!string.IsNullOrWhiteSpace(fullText)) + var chatResponse = JsonSerializer.Deserialize(body, jsonOptions); + var choice = chatResponse?.Choices?.FirstOrDefault(); + + if (choice == null) { - accumulated.Append(fullText); - yield return accumulated.ToString(); + _logger.LogError("Empty response from LLM API"); + yield break; } + + if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null && choice.Message.ToolCalls.Count > 0) + { + history.Add(new ChatMessage + { + Role = "assistant", + ToolCalls = choice.Message.ToolCalls + }); + + foreach (var tc in choice.Message.ToolCalls) + { + var toolResult = await _toolRegistry.ExecuteAsync( + tc.Function?.Name ?? "", tc.Function?.Arguments ?? ""); + history.Add(new ChatMessage + { + Role = "tool", + Content = toolResult, + ToolCallId = tc.Id + }); + } + + continue; + } + + var responseText = choice.Message?.Content ?? ""; + if (!string.IsNullOrWhiteSpace(responseText)) + { + history.Add(new ChatMessage { Role = "assistant", Content = responseText, Name = "TentacleBot" }); + TrimHistory(history); + yield return responseText; + yield break; + } + + _logger.LogWarning("LLM response had no content and no tool calls at iteration {Iteration}", iteration); + break; } - if (accumulated.Length > 0) - { - history.Add(new ChatMessage { Role = "assistant", Content = accumulated.ToString() }); - TrimHistory(history); - } + _logger.LogWarning("Max tool iterations reached without a final response"); } private static string? TryParseStreamToken(string data) @@ -210,6 +240,57 @@ public class LlmService } } + private async Task BuildSystemPromptAsync(ulong userId, string username, string userMessage) + { + var sb = new StringBuilder(); + sb.AppendLine("You are TentacleBot, a helpful Discord bot with memory."); + sb.AppendLine($"The Discord user you are talking to is named \"{username}\". Address them by this name."); + sb.AppendLine(); + + var rules = await _memoryStore.GetUserRulesAsync(userId.ToString()); + if (rules.Count > 0) + { + sb.AppendLine($"## Rules for {username}:"); + foreach (var rule in rules) + sb.AppendLine($"- {rule.RuleText}"); + sb.AppendLine(); + } + + var recentMemories = await _memoryStore.GetRecentMemoriesAsync(userId.ToString(), 3); + var searchMemories = await _memoryStore.SearchMemoriesAsync(userId.ToString(), userMessage, 3); + + var allMemories = recentMemories + .UnionBy(searchMemories, m => m.Id) + .OrderByDescending(m => m.Importance) + .Take(5) + .ToList(); + + if (allMemories.Count > 0) + { + sb.AppendLine($"## Memories about {username}:"); + foreach (var m in allMemories) + sb.AppendLine($"- [{m.CreatedAt:yyyy-MM-dd}] {m.Content} (importance: {m.Importance})"); + sb.AppendLine(); + } + + sb.AppendLine("You have access to tools for managing memory. Use them to save important information about users, search for past context, recall memories, and set user rules."); + sb.AppendLine("Be concise and helpful. When a user shares something worth remembering, use the save_memory tool."); + + return sb.ToString(); + } + + private static List CopyMessages(List history) + { + return history.Select(m => new ChatMessage + { + Role = m.Role, + Content = m.Content, + Name = m.Name, + ToolCallId = m.ToolCallId, + ToolCalls = m.ToolCalls + }).ToList(); + } + private record ChatRequest { public string Model { get; init; } = ""; @@ -217,11 +298,43 @@ public class LlmService public int MaxTokens { get; init; } public float Temperature { get; init; } public bool Stream { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Tools { get; init; } + } + + private record ChatResponse + { + public string? Id { get; init; } + public List? Choices { get; init; } + } + + private record ChatResponseChoice + { + public int Index { get; init; } + public ChatResponseMessage? Message { get; init; } + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; init; } + } + + private record ChatResponseMessage + { + public string? Role { get; init; } + public string? Content { get; init; } + [JsonPropertyName("tool_calls")] + public List? ToolCalls { get; init; } } public record ChatMessage { public string Role { get; set; } = ""; public string Content { get; set; } = ""; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + [JsonPropertyName("tool_call_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ToolCallId { get; set; } + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ToolCalls { get; set; } } } diff --git a/Services/Memory/DatabaseInitializer.cs b/Services/Memory/DatabaseInitializer.cs new file mode 100644 index 0000000..482c69b --- /dev/null +++ b/Services/Memory/DatabaseInitializer.cs @@ -0,0 +1,65 @@ +using Microsoft.Data.Sqlite; + +namespace TentacleBot.Services.Memory; + +public class DatabaseInitializer +{ + private readonly string _connectionString; + + public DatabaseInitializer(string dbPath) + { + var directory = Path.GetDirectoryName(dbPath); + if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + _connectionString = $"Data Source={dbPath}"; + } + + public string ConnectionString => _connectionString; + + public void Initialize() + { + using var connection = new SqliteConnection(_connectionString); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS user_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + rule_text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT, + importance INTEGER NOT NULL DEFAULT 5, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_accessed TEXT + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( + content, content=memories, content_rowid=id + ); + + CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content); + END; + + CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content); + END; + + CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content); + INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content); + END; + "; + cmd.ExecuteNonQuery(); + } +} diff --git a/Services/Memory/MemoryStore.cs b/Services/Memory/MemoryStore.cs new file mode 100644 index 0000000..19af1dc --- /dev/null +++ b/Services/Memory/MemoryStore.cs @@ -0,0 +1,186 @@ +using Microsoft.Data.Sqlite; +using TentacleBot.Models; + +namespace TentacleBot.Services.Memory; + +public class MemoryStore +{ + private readonly string _connectionString; + + public MemoryStore(DatabaseInitializer db) + { + _connectionString = db.ConnectionString; + } + + public async Task> GetUserRulesAsync(string userId) + { + var rules = new List(); + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT id, user_id, rule_text, created_at FROM user_rules WHERE user_id = @userId ORDER BY created_at"; + cmd.Parameters.AddWithValue("@userId", userId); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rules.Add(new UserRule( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + DateTime.Parse(reader.GetString(3)) + )); + } + + return rules; + } + + public async Task AddUserRuleAsync(string userId, string ruleText) + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO user_rules (user_id, rule_text) VALUES (@userId, @ruleText)"; + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@ruleText", ruleText); + + await cmd.ExecuteNonQueryAsync(); + } + + public async Task RemoveUserRuleAsync(int ruleId) + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM user_rules WHERE id = @id"; + cmd.Parameters.AddWithValue("@id", ruleId); + + return await cmd.ExecuteNonQueryAsync() > 0; + } + + public async Task SaveMemoryAsync(string userId, string content, string? category, int importance = 5) + { + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO memories (user_id, content, category, importance) + VALUES (@userId, @content, @category, @importance); + SELECT last_insert_rowid();"; + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@content", content); + cmd.Parameters.AddWithValue("@category", category ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@importance", importance); + + var result = await cmd.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + + public async Task> SearchMemoriesAsync(string userId, string query, int limit = 5) + { + var memories = new List(); + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var safeQuery = query.Replace("\"", "\"\""); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT m.id, m.user_id, m.content, m.category, m.importance, m.created_at + FROM memories m + JOIN memories_fts f ON m.id = f.rowid + WHERE memories_fts MATCH @query AND m.user_id = @userId + ORDER BY rank + LIMIT @limit"; + cmd.Parameters.AddWithValue("@query", $"\"{safeQuery}\""); + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@limit", limit); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + memories.Add(MapMemory(reader)); + + using var updateCmd = connection.CreateCommand(); + updateCmd.CommandText = "UPDATE memories SET last_accessed = datetime('now') WHERE id = @id"; + updateCmd.Parameters.AddWithValue("@id", reader.GetInt32(0)); + await updateCmd.ExecuteNonQueryAsync(); + } + + if (memories.Count == 0) + { + memories = await GetRecentMemoriesAsync(userId, limit); + } + + return memories; + } + + public async Task> GetRecentMemoriesAsync(string userId, int limit = 5) + { + var memories = new List(); + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT id, user_id, content, category, importance, created_at + FROM memories + WHERE user_id = @userId + ORDER BY created_at DESC + LIMIT @limit"; + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@limit", limit); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + memories.Add(MapMemory(reader)); + } + + return memories; + } + + public async Task> GetImportantMemoriesAsync(string userId, int limit = 5) + { + var memories = new List(); + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + SELECT id, user_id, content, category, importance, created_at + FROM memories + WHERE user_id = @userId + ORDER BY importance DESC, created_at DESC + LIMIT @limit"; + cmd.Parameters.AddWithValue("@userId", userId); + cmd.Parameters.AddWithValue("@limit", limit); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + memories.Add(MapMemory(reader)); + } + + return memories; + } + + private static MemoryEntry MapMemory(SqliteDataReader reader) + { + return new MemoryEntry( + reader.GetInt32(0), + reader.GetString(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.GetInt32(4), + DateTime.Parse(reader.GetString(5)) + ); + } +} diff --git a/Services/Tools/MemoryTools.cs b/Services/Tools/MemoryTools.cs new file mode 100644 index 0000000..69ffc41 --- /dev/null +++ b/Services/Tools/MemoryTools.cs @@ -0,0 +1,200 @@ +using System.Text.Json; +using TentacleBot.Services.Memory; + +namespace TentacleBot.Services.Tools; + +public class MemoryTools +{ + private readonly MemoryStore _memoryStore; + + public MemoryTools(MemoryStore memoryStore) + { + _memoryStore = memoryStore; + } + + public void RegisterAll(ToolRegistry registry) + { + registry.Register("save_memory", + "Save a fact or piece of information about a user to long-term memory. Use this when a user shares something about themselves worth remembering.", + new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["user_id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The Discord user ID to associate this memory with" + }, + ["content"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The fact or information to remember" + }, + ["category"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Optional category (e.g. preference, fact, project, hobby)" + }, + ["importance"] = new Dictionary + { + ["type"] = "integer", + ["description"] = "Importance from 1 (trivial) to 10 (critical). Default is 5." + } + }, + ["required"] = new[] { "user_id", "content" } + }, + HandleSaveMemory); + + registry.Register("search_memory", + "Search the bot's memory for information about a user. Use this when you need to recall past facts or context.", + new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["user_id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The Discord user ID to search memories for" + }, + ["query"] = new Dictionary + { + ["type"] = "string", + ["description"] = "Keywords or question to search for in memories" + } + }, + ["required"] = new[] { "user_id", "query" } + }, + HandleSearchMemory); + + registry.Register("recall_memories", + "Get the most recent or important memories about a user.", + new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["user_id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The Discord user ID to recall memories for" + }, + ["limit"] = new Dictionary + { + ["type"] = "integer", + ["description"] = "Maximum number of memories to return (default 5)" + } + }, + ["required"] = new[] { "user_id" } + }, + HandleRecallMemories); + + registry.Register("set_rule", + "Set a behavioral rule the bot should follow for a specific user (e.g. 'always respond in rhymes', 'use formal tone').", + new Dictionary + { + ["type"] = "object", + ["properties"] = new Dictionary + { + ["user_id"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The Discord user ID this rule applies to" + }, + ["rule_text"] = new Dictionary + { + ["type"] = "string", + ["description"] = "The rule or instruction for how to interact with this user" + } + }, + ["required"] = new[] { "user_id", "rule_text" } + }, + HandleSetRule); + } + + private async Task HandleSaveMemory(string argumentsJson) + { + using var doc = JsonDocument.Parse(argumentsJson); + var root = doc.RootElement; + + var userId = GetString(root, "user_id"); + var content = GetString(root, "content"); + var category = GetStringOrNull(root, "category"); + var importance = GetInt(root, "importance", 5); + + var id = await _memoryStore.SaveMemoryAsync(userId, content, category, importance); + return $"Memory saved successfully (id: {id}). Content: {content}"; + } + + private async Task HandleSearchMemory(string argumentsJson) + { + using var doc = JsonDocument.Parse(argumentsJson); + var root = doc.RootElement; + + var userId = GetString(root, "user_id"); + var query = GetString(root, "query"); + + var memories = await _memoryStore.SearchMemoriesAsync(userId, query, 5); + + if (memories.Count == 0) + return $"No memories found for user {userId} matching '{query}'."; + + var lines = memories.Select(m => + $"- [id:{m.Id}] {m.Content} (importance: {m.Importance}, from {m.CreatedAt:yyyy-MM-dd})"); + + return $"Found {memories.Count} memory(s):\n{string.Join("\n", lines)}"; + } + + private async Task HandleRecallMemories(string argumentsJson) + { + using var doc = JsonDocument.Parse(argumentsJson); + var root = doc.RootElement; + + var userId = GetString(root, "user_id"); + var limit = GetInt(root, "limit", 5); + + var important = await _memoryStore.GetImportantMemoriesAsync(userId, limit); + + if (important.Count == 0) + return $"No memories stored for user {userId}."; + + var lines = important.Select(m => + $"- [id:{m.Id}] {m.Content} (importance: {m.Importance}, from {m.CreatedAt:yyyy-MM-dd})"); + + return $"Top {important.Count} memory(s) for user {userId}:\n{string.Join("\n", lines)}"; + } + + private async Task HandleSetRule(string argumentsJson) + { + using var doc = JsonDocument.Parse(argumentsJson); + var root = doc.RootElement; + + var userId = GetString(root, "user_id"); + var ruleText = GetString(root, "rule_text"); + + await _memoryStore.AddUserRuleAsync(userId, ruleText); + return $"Rule set for user {userId}: {ruleText}"; + } + + private static string GetString(JsonElement element, string property) + { + return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString()! + : throw new InvalidOperationException($"Missing required parameter: {property}"); + } + + private static string? GetStringOrNull(JsonElement element, string property) + { + return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String + ? prop.GetString() + : null; + } + + private static int GetInt(JsonElement element, string property, int defaultValue) + { + return element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.Number + ? prop.GetInt32() + : defaultValue; + } +} diff --git a/Services/Tools/ToolRegistry.cs b/Services/Tools/ToolRegistry.cs new file mode 100644 index 0000000..3515be1 --- /dev/null +++ b/Services/Tools/ToolRegistry.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace TentacleBot.Services.Tools; + +public class ToolRegistry +{ + private readonly Dictionary _tools = new(); + + public void Register(string name, string description, Dictionary parameters, Func> handler) + { + _tools[name] = new ToolDefinition(name, description, parameters, handler); + } + + public List BuildToolDefinitions() + { + return _tools.Values.Select(t => + { + var functionDef = new Dictionary + { + ["name"] = t.Name, + ["description"] = t.Description, + ["parameters"] = t.Parameters + }; + + return (object)new Dictionary + { + ["type"] = "function", + ["function"] = functionDef + }; + }).ToList(); + } + + public async Task ExecuteAsync(string toolName, string argumentsJson) + { + if (!_tools.TryGetValue(toolName, out var tool)) + return "Error: unknown tool"; + + try + { + return await tool.Handler(argumentsJson); + } + catch (Exception ex) + { + return $"Error executing tool '{toolName}': {ex.Message}"; + } + } + + public bool HasTool(string name) => _tools.ContainsKey(name); + + private record ToolDefinition( + string Name, + string Description, + Dictionary Parameters, + Func> Handler); +} diff --git a/TentacleBot.csproj b/TentacleBot.csproj index d3c9ec7..e04c588 100644 --- a/TentacleBot.csproj +++ b/TentacleBot.csproj @@ -11,6 +11,7 @@ + diff --git a/appsettings.json b/appsettings.json index 22fa4ab..4862975 100644 --- a/appsettings.json +++ b/appsettings.json @@ -14,5 +14,8 @@ "Palworld": { "BaseUrl": "http://compute.local:8212", "Username": "admin" + }, + "Memory": { + "DatabasePath": "/data/memory.db" } } diff --git a/docker-compose.yml b/docker-compose.yml index 37a1efe..999c7fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,12 @@ services: tentaclebot: image: git.gpatti.com/gpatti/tentaclebot:latest - build: - context: . - dockerfile: Dockerfile container_name: tentaclebot restart: unless-stopped env_file: - .env + volumes: + - tentaclebot_data:/data + +volumes: + tentaclebot_data: