diff --git a/ClawdDotNet.csproj b/ClawdDotNet.csproj index dde355d..add38ea 100644 --- a/ClawdDotNet.csproj +++ b/ClawdDotNet.csproj @@ -56,6 +56,7 @@ + diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index 5bbb274..2ee4af4 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -14,6 +14,7 @@ + diff --git a/Program.cs b/Program.cs index 23b795c..14281c4 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,8 @@ using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Scheduling; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Memory; using ClawdDotNet.Services; using ClawdDotNet.Tools.FileRW; using ClawdDotNet.Tools.Telegram; @@ -117,6 +119,7 @@ internal static class Program toolRegistry.Register(new SocialMediaManagerTool()); toolRegistry.Register(new AgentSpawnTool()); toolRegistry.Register(new AgentEditorTool()); + toolRegistry.Register(new ClawdDotNet.Tools.Memory.MemoryTool()); // ─── 6a. TelegramClient (MTProto User-API) ─── TelegramClientManager? tgClientManager = null; @@ -150,11 +153,14 @@ internal static class Program // ModelTypeConverter mit dem Client verbinden für PropertyGrid-Dropdown ClawdDotNet.Models.ModelTypeConverter.Client = openRouterClient; - // ─── 6.1. StateStore initialisieren ─── - var stateDbPath = Path.Combine(instancePath, "state.db"); - var stateStore = new SqliteStateStore(stateDbPath); + // ─── 6.1. Speicher initialisieren ─── + // Eine Datenbank je Instanz; StateStore und Gedächtnis teilen sie sich. + var storage = new SqliteStorage(Path.Combine(instancePath, "state.db")); + var stateStore = new SqliteStateStore(storage); + var memoryRepository = new SqliteMemoryRepository(storage); - agentEngine = new AgentEngine(openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory); + agentEngine = new AgentEngine( + openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, memoryRepository); agentEngine.SetAgentConfigProvider( () => instanceConfig.Agents, instanceConfig.InstanceId, diff --git a/docs/Memory-Konzept.md b/docs/Memory-Konzept.md new file mode 100644 index 0000000..67b3ce1 --- /dev/null +++ b/docs/Memory-Konzept.md @@ -0,0 +1,72 @@ +# Memory — Langzeitgedächtnis für Agenten + +Löst K1 aus der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md): Geplante Agenten +begannen bei jedem Cron-Lauf bei null. Ein Agent, der alle 30 Minuten lief, wusste +nichts von seinem letzten Durchgang — er rief dieselben Quellen ab, zog dieselben +Schlüsse und konnte keine Entwicklung über Zeit verfolgen. + +## Warum nicht der vorhandene State-Store + +`IStateStore` ist eine Schlüssel-Wert-Tabelle für kleine Marker („zuletzt gesehene ID"). +Ein Gedächtnis darin abzulegen hieße, JSON in eine `Value`-Spalte zu schreiben — damit +lässt sich nichts filtern, sortieren oder auswerten. + +Deshalb typisierte Spalten in einer eigenen Tabelle. Das Schema ist bewusst schlicht +gehalten, damit eine MySQL-Variante später dieselbe Struktur mit wenigen +Dialektunterschieden bekommen kann. + +## Modell + +| Feld | Zweck | +|---|---| +| `Scope` | `agent` (privat) oder `shared` (alle Agenten der Instanz) | +| `Subject` | Worum es geht — Ticker, Kunde, Projekt | +| `Key` | **Optional.** Erneutes Merken darunter *aktualisiert* statt anzulegen | +| `Category` | `fact`, `decision`, `observation`, `task`, `contact`, `other` | +| `Tags` | Schlagworte zum Wiederfinden | +| `Importance` | 1–5, steuert die Reihenfolge beim Abruf | +| `CreatedBy` | Bleibt auch im geteilten Bereich sichtbar | + +Die Scope-Trennung ist absichtlich dieselbe wie beim `FileRW`-Tool (`personal`/`shared`) — +für Agenten bleibt das Konzept dadurch wiedererkennbar. + +## Der Schlüssel ist das Wichtigste + +Ohne ihn wüchse das Gedächtnis eines alle 30 Minuten laufenden Agenten um 48 Einträge +pro Tag zur selben Sache. Mit `key='kursziel_nvda'` bleibt es **ein** Eintrag, der +sich fortschreibt — das Anlagedatum bleibt erhalten, nur `UpdatedAt` wandert. + +Beobachtungen ohne Schlüssel sammeln sich weiterhin an; das ist gewollt, wenn ein +Verlauf entstehen soll. + +## Abruf + +Sortiert nach Wichtigkeit, dann Aktualität. Das ist wesentlich, weil das Ergebnis +begrenzt wird: Bei einer Kappung muss das Wichtigste überleben. + +Zusätzlich greift eine Zeichenobergrenze (8.000 Zeichen) — ein Abruf darf den Kontext +nicht sprengen, dieselbe Überlegung wie bei der Tool-Ergebnis-Kappung (T2). + +## Speicher-Fundament + +`SqliteStorage` bündelt den Zugang zur Instanz-Datenbank: + +- **WAL** — beliebig viele Leser parallel zu einem Schreiber +- **busy_timeout** — ein Schreiber wartet kurz, statt sofort zu scheitern +- **Connection-Pooling** — kein Verbindungsaufbau je Aufruf +- **Schreib-Warteschlange im Prozess** — macht Fehlerbilder reproduzierbar + +Vorher öffnete jeder Aufruf eine Verbindung ohne diese Einstellungen. Bei mehreren +gleichzeitig schreibenden Agenten gab das `database is locked` — das sah nach einer +Grenze von SQLite aus, war aber nur fehlende Konfiguration. Zwei Tests decken das +gezielt ab (60 gleichzeitige Schreibvorgänge, gemischtes Lesen und Schreiben). + +## Offen + +- **Automatische Einblendung**: Ein Agent muss `recall` derzeit selbst aufrufen. Für + geplante Läufe wäre eine kurze Übersicht der wichtigsten Erinnerungen im Auftrag + hilfreich. Sie gehört in die Nutzernachricht, nicht in den System-Prompt — sonst + verfällt bei jedem Lauf der Prompt-Cache (T1). +- **Verfall**: Alte, unwichtige Beobachtungen könnten nach einer Frist entfallen. +- **MySQL**: Zweite Implementierung von `IMemoryRepository`, wenn mehrere Rechner + dazukommen. diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index d93c067..c5eaefa 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -3,6 +3,7 @@ using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Memory; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; @@ -16,6 +17,7 @@ public sealed class AgentEngine : IAgentMessageRouter private readonly ToolRegistry _toolRegistry; private readonly PermissionGate _permissionGate; private readonly IStateStore _stateStore; + private readonly IMemoryRepository? _memoryRepository; private readonly ILoggerFactory _loggerFactory; private readonly ContextCompactor _compactor; @@ -53,13 +55,15 @@ public sealed class AgentEngine : IAgentMessageRouter ToolRegistry toolRegistry, PermissionGate permissionGate, IStateStore stateStore, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IMemoryRepository? memoryRepository = null) { _client = client; _toolRegistry = toolRegistry; _permissionGate = permissionGate; _stateStore = stateStore; _loggerFactory = loggerFactory; + _memoryRepository = memoryRepository; _compactor = new ContextCompactor(client, loggerFactory); } @@ -796,7 +800,8 @@ public sealed class AgentEngine : IAgentMessageRouter ct, agentConfig.WorkspacePath, agentConfig.SharedWorkspacePath, - this); + this, + _memoryRepository); logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId); diff --git a/src/ClawdDotNet.Core/Memory/MemoryModels.cs b/src/ClawdDotNet.Core/Memory/MemoryModels.cs new file mode 100644 index 0000000..d9b6ad3 --- /dev/null +++ b/src/ClawdDotNet.Core/Memory/MemoryModels.cs @@ -0,0 +1,112 @@ +namespace ClawdDotNet.Core.Memory; + +/// +/// Wem eine Erinnerung gehört. +/// Bewusst dieselbe Trennung wie beim FileRW-Tool, damit sie für Agenten +/// nachvollziehbar bleibt. +/// +public enum MemoryScope +{ + /// Nur für den Agenten selbst sichtbar. + Agent, + + /// Für alle Agenten der Instanz sichtbar. + Shared +} + +/// Grobe Einordnung, damit Recall gezielt filtern kann. +public static class MemoryCategory +{ + public const string Fact = "fact"; // gesicherte Angabe + public const string Decision = "decision"; // getroffene Entscheidung + public const string Observation = "observation"; // Beobachtung, Zwischenstand + public const string Task = "task"; // offener Punkt + public const string Contact = "contact"; // Person, Kanal, Zugang + public const string Other = "other"; + + public static readonly string[] All = + [Fact, Decision, Observation, Task, Contact, Other]; + + public static string Normalize(string? value) + { + var v = value?.Trim().ToLowerInvariant(); + return All.Contains(v) ? v! : Other; + } +} + +public sealed record MemoryEntry +{ + public long Id { get; init; } + public MemoryScope Scope { get; init; } + + /// Agent-Id bei , sonst leer. + public string OwnerId { get; init; } = ""; + + public string Category { get; init; } = MemoryCategory.Other; + + /// Worum es geht — etwa ein Ticker, ein Kundenname, ein Projekt. + public string Subject { get; init; } = ""; + + /// + /// Optionaler eindeutiger Schlüssel. Erneutes Merken unter demselben Schlüssel + /// aktualisiert die Erinnerung, statt eine zweite anzulegen — so bleibt der + /// Bestand über Monate hinweg brauchbar statt zuzuwachsen. + /// + public string? Key { get; init; } + + public string Content { get; init; } = ""; + + public IReadOnlyList Tags { get; init; } = []; + + /// 1 (nebensächlich) bis 5 (zentral). Steuert die Reihenfolge beim Abruf. + public int Importance { get; init; } = 3; + + public DateTime CreatedAt { get; init; } + public DateTime UpdatedAt { get; init; } + + /// Welcher Agent die Erinnerung angelegt hat — auch bei geteiltem Scope. + public string CreatedBy { get; init; } = ""; +} + +/// Suchkriterien für den Abruf. +public sealed record MemoryQuery +{ + public MemoryScope Scope { get; init; } = MemoryScope.Agent; + public string OwnerId { get; init; } = ""; + + /// Freitext — wird gegen Betreff, Inhalt und Schlagworte geprüft. + public string? Search { get; init; } + + public string? Subject { get; init; } + public string? Category { get; init; } + public IReadOnlyList Tags { get; init; } = []; + + public int Limit { get; init; } = 20; + + /// Nur Erinnerungen ab dieser Wichtigkeit. + public int MinImportance { get; init; } = 1; +} + +public interface IMemoryRepository +{ + /// + /// Legt eine Erinnerung an oder aktualisiert sie, wenn ein Schlüssel angegeben ist + /// und darunter bereits eine besteht. + /// + Task RememberAsync(MemoryEntry entry, CancellationToken ct); + + Task> RecallAsync(MemoryQuery query, CancellationToken ct); + + Task GetByKeyAsync(MemoryScope scope, string ownerId, string key, CancellationToken ct); + + /// Löscht eine Erinnerung. Gibt an, ob es etwas zu löschen gab. + Task ForgetAsync(long id, CancellationToken ct); + + Task ForgetBySubjectAsync(MemoryScope scope, string ownerId, string subject, CancellationToken ct); + + /// Betreffs mit Anzahl — für einen Überblick, ohne alle Inhalte zu laden. + Task> ListSubjectsAsync( + MemoryScope scope, string ownerId, int limit, CancellationToken ct); + + Task CountAsync(MemoryScope scope, string ownerId, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs b/src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs new file mode 100644 index 0000000..4db5897 --- /dev/null +++ b/src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs @@ -0,0 +1,307 @@ +using System.Text; +using ClawdDotNet.Core.Storage; +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Memory; + +/// +/// Ablage der Erinnerungen in der Instanz-Datenbank. +/// +/// Bewusst typisierte Spalten statt JSON in einem Wert-Feld: Nur so lassen sich +/// Erinnerungen gezielt filtern, sortieren und später auswerten. Das Schema ist +/// absichtlich einfach gehalten, damit eine MySQL-Variante später dieselbe Struktur +/// mit nur wenigen Dialektunterschieden bekommen kann. +/// +public sealed class SqliteMemoryRepository : IMemoryRepository +{ + private readonly SqliteStorage _storage; + + public SqliteMemoryRepository(SqliteStorage storage) => _storage = storage; + + public Task RememberAsync(MemoryEntry entry, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + var now = DateTime.UtcNow; + var scope = entry.Scope.ToString(); + var owner = OwnerFor(entry.Scope, entry.OwnerId); + var tags = SerializeTags(entry.Tags); + + // Mit Schlüssel: bestehende Erinnerung aktualisieren statt eine zweite anlegen. + if (!string.IsNullOrWhiteSpace(entry.Key)) + { + var existing = await ReadByKeyAsync(conn, entry.Scope, owner, entry.Key!, ct); + if (existing is not null) + { + using var update = conn.CreateCommand(); + update.CommandText = """ + UPDATE Memories + SET Category = @category, Subject = @subject, Content = @content, + Tags = @tags, Importance = @importance, UpdatedAt = @updatedAt + WHERE Id = @id + """; + update.Parameters.AddWithValue("@category", MemoryCategory.Normalize(entry.Category)); + update.Parameters.AddWithValue("@subject", entry.Subject); + update.Parameters.AddWithValue("@content", entry.Content); + update.Parameters.AddWithValue("@tags", tags); + update.Parameters.AddWithValue("@importance", ClampImportance(entry.Importance)); + update.Parameters.AddWithValue("@updatedAt", Format(now)); + update.Parameters.AddWithValue("@id", existing.Id); + await update.ExecuteNonQueryAsync(ct); + + return existing with + { + Category = MemoryCategory.Normalize(entry.Category), + Subject = entry.Subject, + Content = entry.Content, + Tags = entry.Tags, + Importance = ClampImportance(entry.Importance), + UpdatedAt = now + }; + } + } + + using var insert = conn.CreateCommand(); + insert.CommandText = """ + INSERT INTO Memories + (Scope, OwnerId, Category, Subject, MemoryKey, Content, Tags, Importance, + CreatedAt, UpdatedAt, CreatedBy) + VALUES + (@scope, @owner, @category, @subject, @key, @content, @tags, @importance, + @createdAt, @updatedAt, @createdBy); + SELECT last_insert_rowid(); + """; + insert.Parameters.AddWithValue("@scope", scope); + insert.Parameters.AddWithValue("@owner", owner); + insert.Parameters.AddWithValue("@category", MemoryCategory.Normalize(entry.Category)); + insert.Parameters.AddWithValue("@subject", entry.Subject); + insert.Parameters.AddWithValue("@key", (object?)NullIfBlank(entry.Key) ?? DBNull.Value); + insert.Parameters.AddWithValue("@content", entry.Content); + insert.Parameters.AddWithValue("@tags", tags); + insert.Parameters.AddWithValue("@importance", ClampImportance(entry.Importance)); + insert.Parameters.AddWithValue("@createdAt", Format(now)); + insert.Parameters.AddWithValue("@updatedAt", Format(now)); + insert.Parameters.AddWithValue("@createdBy", entry.CreatedBy); + + var id = Convert.ToInt64(await insert.ExecuteScalarAsync(ct)); + + return entry with + { + Id = id, + OwnerId = owner, + Category = MemoryCategory.Normalize(entry.Category), + Importance = ClampImportance(entry.Importance), + CreatedAt = now, + UpdatedAt = now + }; + }, ct); + + public async Task> RecallAsync(MemoryQuery query, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + + var sql = new StringBuilder(""" + SELECT Id, Scope, OwnerId, Category, Subject, MemoryKey, Content, Tags, + Importance, CreatedAt, UpdatedAt, CreatedBy + FROM Memories + WHERE Scope = @scope AND OwnerId = @owner AND Importance >= @minImportance + """); + + cmd.Parameters.AddWithValue("@scope", query.Scope.ToString()); + cmd.Parameters.AddWithValue("@owner", OwnerFor(query.Scope, query.OwnerId)); + cmd.Parameters.AddWithValue("@minImportance", ClampImportance(query.MinImportance)); + + if (NullIfBlank(query.Subject) is { } subject) + { + sql.Append(" AND Subject = @subject COLLATE NOCASE"); + cmd.Parameters.AddWithValue("@subject", subject); + } + + if (NullIfBlank(query.Category) is { } category) + { + sql.Append(" AND Category = @category"); + cmd.Parameters.AddWithValue("@category", MemoryCategory.Normalize(category)); + } + + if (NullIfBlank(query.Search) is { } search) + { + // Freitext über Betreff, Inhalt und Schlagworte. + sql.Append(""" + AND (Subject LIKE @search ESCAPE '\' COLLATE NOCASE + OR Content LIKE @search ESCAPE '\' COLLATE NOCASE + OR Tags LIKE @search ESCAPE '\' COLLATE NOCASE) + """); + cmd.Parameters.AddWithValue("@search", "%" + Escape(search) + "%"); + } + + for (var i = 0; i < query.Tags.Count; i++) + { + var tag = NullIfBlank(query.Tags[i]); + if (tag is null) continue; + + // Schlagworte liegen als "|a|b|c|" — die Begrenzer verhindern Teiltreffer. + sql.Append($" AND Tags LIKE @tag{i} ESCAPE '\\' COLLATE NOCASE"); + cmd.Parameters.AddWithValue($"@tag{i}", $"%|{Escape(tag.ToLowerInvariant())}|%"); + } + + // Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält. + sql.Append(" ORDER BY Importance DESC, UpdatedAt DESC LIMIT @limit"); + cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 200)); + + cmd.CommandText = sql.ToString(); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + + return results; + } + + public async Task GetByKeyAsync( + MemoryScope scope, string ownerId, string key, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + return await ReadByKeyAsync(conn, scope, OwnerFor(scope, ownerId), key, ct); + } + + public Task ForgetAsync(long id, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM Memories WHERE Id = @id"; + cmd.Parameters.AddWithValue("@id", id); + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + + public Task ForgetBySubjectAsync( + MemoryScope scope, string ownerId, string subject, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + DELETE FROM Memories + WHERE Scope = @scope AND OwnerId = @owner AND Subject = @subject COLLATE NOCASE + """; + cmd.Parameters.AddWithValue("@scope", scope.ToString()); + cmd.Parameters.AddWithValue("@owner", OwnerFor(scope, ownerId)); + cmd.Parameters.AddWithValue("@subject", subject); + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public async Task> ListSubjectsAsync( + MemoryScope scope, string ownerId, int limit, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT Subject, COUNT(*) AS Anzahl + FROM Memories + WHERE Scope = @scope AND OwnerId = @owner + GROUP BY Subject COLLATE NOCASE + ORDER BY Anzahl DESC, Subject + LIMIT @limit + """; + cmd.Parameters.AddWithValue("@scope", scope.ToString()); + cmd.Parameters.AddWithValue("@owner", OwnerFor(scope, ownerId)); + cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 500)); + + var results = new List<(string, int)>(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add((reader.GetString(0), reader.GetInt32(1))); + + return results; + } + + public async Task CountAsync(MemoryScope scope, string ownerId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM Memories WHERE Scope = @scope AND OwnerId = @owner"; + cmd.Parameters.AddWithValue("@scope", scope.ToString()); + cmd.Parameters.AddWithValue("@owner", OwnerFor(scope, ownerId)); + + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); + } + + // ─── Hilfsfunktionen ─── + + private static async Task ReadByKeyAsync( + SqliteConnection conn, MemoryScope scope, string owner, string key, CancellationToken ct) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT Id, Scope, OwnerId, Category, Subject, MemoryKey, Content, Tags, + Importance, CreatedAt, UpdatedAt, CreatedBy + FROM Memories + WHERE Scope = @scope AND OwnerId = @owner AND MemoryKey = @key + LIMIT 1 + """; + cmd.Parameters.AddWithValue("@scope", scope.ToString()); + cmd.Parameters.AddWithValue("@owner", owner); + cmd.Parameters.AddWithValue("@key", key); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? Read(reader) : null; + } + + private static MemoryEntry Read(SqliteDataReader reader) => new() + { + Id = reader.GetInt64(0), + Scope = Enum.TryParse(reader.GetString(1), out var s) ? s : MemoryScope.Agent, + OwnerId = reader.GetString(2), + Category = reader.GetString(3), + Subject = reader.GetString(4), + Key = reader.IsDBNull(5) ? null : reader.GetString(5), + Content = reader.GetString(6), + Tags = DeserializeTags(reader.GetString(7)), + Importance = reader.GetInt32(8), + CreatedAt = Parse(reader.GetString(9)), + UpdatedAt = Parse(reader.GetString(10)), + CreatedBy = reader.GetString(11) + }; + + /// + /// Schlagworte als "|a|b|c|". Die Begrenzer erlauben eine Suche nach ganzen + /// Schlagworten, ohne dass "news" auch "newsletter" trifft. + /// + private static string SerializeTags(IReadOnlyList tags) + { + var cleaned = tags + .Select(t => t.Trim().ToLowerInvariant().Replace("|", "")) + .Where(t => t.Length > 0) + .Distinct() + .ToList(); + + return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|"; + } + + private static IReadOnlyList DeserializeTags(string raw) + => raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static string OwnerFor(MemoryScope scope, string ownerId) + => scope == MemoryScope.Shared ? "" : ownerId; + + private static int ClampImportance(int value) => Math.Clamp(value, 1, 5); + + private static string? NullIfBlank(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// + /// Maskiert LIKE-Platzhalter, damit ein Suchtext wörtlich gesucht wird. + /// Werden die Zeichen stattdessen entfernt, würde eine Suche nach "%" zu einem + /// leeren Muster und damit zu einem Treffer auf alles. + /// + private static string Escape(string value) => value + .Replace(@"\", @"\\") + .Replace("%", @"\%") + .Replace("_", @"\_"); + + private static string Format(DateTime value) => value.ToString("O"); + + private static DateTime Parse(string value) + => DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) + ? dt + : DateTime.MinValue; +} diff --git a/src/ClawdDotNet.Core/State/SqliteStateStore.cs b/src/ClawdDotNet.Core/State/SqliteStateStore.cs index c739db8..f952877 100644 --- a/src/ClawdDotNet.Core/State/SqliteStateStore.cs +++ b/src/ClawdDotNet.Core/State/SqliteStateStore.cs @@ -1,63 +1,47 @@ +using ClawdDotNet.Core.Storage; using Microsoft.Data.Sqlite; namespace ClawdDotNet.Core.State; +/// +/// Schlüssel-Wert-Ablage für kleine Tool-Zustände (zuletzt gesehene IDs, Zeitstempel). +/// Nutzt das gemeinsame Speicher-Fundament, damit WAL und Sperr-Wartezeiten greifen. +/// public sealed class SqliteStateStore : IStateStore { - private readonly string _connectionString; + private readonly SqliteStorage _storage; - public SqliteStateStore(string dbPath) - { - _connectionString = new SqliteConnectionStringBuilder - { - DataSource = dbPath, - Mode = SqliteOpenMode.ReadWriteCreate - }.ToString(); + public SqliteStateStore(SqliteStorage storage) => _storage = storage; - InitializeDatabase(); - } - - private void InitializeDatabase() - { - using var conn = new SqliteConnection(_connectionString); - conn.Open(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE TABLE IF NOT EXISTS ToolState (Key TEXT PRIMARY KEY, Value TEXT)"; - cmd.ExecuteNonQuery(); - } + public SqliteStateStore(string dbPath) : this(new SqliteStorage(dbPath)) { } public async Task GetAsync(string key, CancellationToken ct) { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(ct); + await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = @key"; cmd.Parameters.AddWithValue("@key", key); - + var result = await cmd.ExecuteScalarAsync(ct); - return result?.ToString(); + return result is DBNull or null ? null : result.ToString(); } - public async Task SetAsync(string key, string value, CancellationToken ct) - { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(ct); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES (@key, @value)"; - cmd.Parameters.AddWithValue("@key", key); - cmd.Parameters.AddWithValue("@value", value); - - await cmd.ExecuteNonQueryAsync(ct); - } + public Task SetAsync(string key, string value, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES (@key, @value)"; + cmd.Parameters.AddWithValue("@key", key); + cmd.Parameters.AddWithValue("@value", value); + await cmd.ExecuteNonQueryAsync(ct); + }, ct); - public async Task DeleteAsync(string key, CancellationToken ct) - { - using var conn = new SqliteConnection(_connectionString); - await conn.OpenAsync(ct); - using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM ToolState WHERE Key = @key"; - cmd.Parameters.AddWithValue("@key", key); - - await cmd.ExecuteNonQueryAsync(ct); - } + public Task DeleteAsync(string key, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM ToolState WHERE Key = @key"; + cmd.Parameters.AddWithValue("@key", key); + await cmd.ExecuteNonQueryAsync(ct); + }, ct); } diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs new file mode 100644 index 0000000..22209e5 --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -0,0 +1,135 @@ +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Storage; + +/// +/// Zentrale Stelle für den Zugang zur Instanz-Datenbank. +/// +/// Die frühere Fassung öffnete pro Aufruf eine Verbindung ohne weitere Einstellungen — +/// ohne WAL und ohne Wartezeit bei Sperren. Sobald mehrere Agenten gleichzeitig +/// schreiben, quittiert SQLite das mit "database is locked". Das sah nach einer Grenze +/// von SQLite aus, war aber nur fehlende Konfiguration. +/// +/// - WAL erlaubt beliebig viele Leser parallel zu einem Schreiber. +/// - busy_timeout lässt einen Schreiber kurz warten, statt sofort zu scheitern. +/// - Connection-Pooling vermeidet den Aufbau je Aufruf. +/// +public sealed class SqliteStorage +{ + private readonly string _connectionString; + private readonly SemaphoreSlim _writeGate = new(1, 1); + + public string DatabasePath { get; } + + public SqliteStorage(string databasePath) + { + DatabasePath = databasePath; + + var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Shared, + Pooling = true, + DefaultTimeout = 30 + }.ToString(); + + Initialize(); + } + + public SqliteConnection OpenConnection() + { + var connection = new SqliteConnection(_connectionString); + connection.Open(); + ApplyPragmas(connection); + return connection; + } + + public async Task OpenConnectionAsync(CancellationToken ct) + { + var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(ct); + ApplyPragmas(connection); + return connection; + } + + /// + /// Serialisiert Schreibvorgänge im Prozess. SQLite lässt ohnehin nur einen Schreiber + /// zu — die Warteschlange hier ist verlässlicher als das Zurückweisen durch die + /// Datenbank und macht Fehlerbilder reproduzierbar. + /// + public async Task WriteAsync(Func> action, CancellationToken ct) + { + await _writeGate.WaitAsync(ct); + try + { + await using var connection = await OpenConnectionAsync(ct); + return await action(connection); + } + finally + { + _writeGate.Release(); + } + } + + public async Task WriteAsync(Func action, CancellationToken ct) + => await WriteAsync(async conn => { await action(conn); return null; }, ct); + + private static void ApplyPragmas(SqliteConnection connection) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = """ + PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + """; + cmd.ExecuteNonQuery(); + } + + private void Initialize() + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS ToolState ( + Key TEXT PRIMARY KEY, + Value TEXT + ); + + CREATE TABLE IF NOT EXISTS Memories ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Scope TEXT NOT NULL, + OwnerId TEXT NOT NULL, + Category TEXT NOT NULL, + Subject TEXT NOT NULL, + MemoryKey TEXT NULL, + Content TEXT NOT NULL, + Tags TEXT NOT NULL DEFAULT '', + Importance INTEGER NOT NULL DEFAULT 3, + CreatedAt TEXT NOT NULL, + UpdatedAt TEXT NOT NULL, + CreatedBy TEXT NOT NULL DEFAULT '' + ); + + CREATE INDEX IF NOT EXISTS IX_Memories_Lookup + ON Memories (Scope, OwnerId, Subject); + + CREATE INDEX IF NOT EXISTS IX_Memories_Recent + ON Memories (Scope, OwnerId, UpdatedAt DESC); + + -- Ein Schluessel identifiziert eine Erinnerung eindeutig; erneutes Merken + -- unter demselben Schluessel aktualisiert sie, statt eine zweite anzulegen. + CREATE UNIQUE INDEX IF NOT EXISTS UX_Memories_Key + ON Memories (Scope, OwnerId, MemoryKey) + WHERE MemoryKey IS NOT NULL; + """; + + cmd.ExecuteNonQuery(); + } +} diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index 64e3660..bf33aff 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -1,3 +1,4 @@ +using ClawdDotNet.Core.Memory; using ClawdDotNet.Core.State; using Microsoft.Extensions.Logging; @@ -12,5 +13,6 @@ public sealed record AgentToolContext( CancellationToken CancellationToken, string? WorkspacePath = null, string? SharedWorkspacePath = null, - IAgentMessageRouter? MessageRouter = null + IAgentMessageRouter? MessageRouter = null, + IMemoryRepository? Memory = null ); diff --git a/src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj b/src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj new file mode 100644 index 0000000..e07532b --- /dev/null +++ b/src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + ClawdDotNet.Tools.Memory + + + + + + + diff --git a/src/ClawdDotNet.Tools.Memory/MemoryTool.cs b/src/ClawdDotNet.Tools.Memory/MemoryTool.cs new file mode 100644 index 0000000..6d5913d --- /dev/null +++ b/src/ClawdDotNet.Tools.Memory/MemoryTool.cs @@ -0,0 +1,318 @@ +using System.Text; +using System.Text.Json; +using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Tools; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Tools.Memory; + +/// +/// Langzeitgedächtnis für Agenten. +/// +/// Ohne dieses Tool beginnt ein geplanter Agent bei jedem Cron-Lauf bei null: Er ruft +/// dieselben Quellen ab, zieht dieselben Schlüsse und kann keine Entwicklung über die +/// Zeit verfolgen. Das ist zugleich die größte Fähigkeitslücke und eine dauerhafte +/// Token-Verschwendung. +/// +public sealed class MemoryTool : IAgentTool +{ + /// Obergrenze für die Ausgabe, damit ein Abruf den Kontext nicht sprengt. + private const int MaxResultChars = 8_000; + + public string Name => "Memory"; + + public string Description => """ + Dein Langzeitgedächtnis — überdauert einzelne Läufe und Neustarts. + + Nutze es, um Erkenntnisse festzuhalten, die beim nächsten Lauf noch zählen: + getroffene Entscheidungen, gesicherte Fakten, offene Punkte, Beobachtungen + über Zeit. Prüfe zu Beginn eines Laufs mit 'recall', was du bereits weißt, + statt es erneut herzuleiten. + + Aktionen: remember, recall, forget, list_subjects + + Zwei Bereiche: + - scope='agent' (Standard): nur für dich sichtbar + - scope='shared': für alle Agenten der Instanz sichtbar + + Zum 'key': Vergib einen, wenn eine Angabe sich später ändern kann + (z.B. key='kursziel_nvda'). Erneutes Merken unter demselben Schlüssel + aktualisiert den Eintrag, statt einen zweiten anzulegen — so wächst dein + Gedächtnis nicht zu. + """; + + public JsonElement InputSchema { get; } = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["remember", "recall", "forget", "list_subjects"], + "description": "Die auszuführende Aktion." + }, + "scope": { + "type": "string", + "enum": ["agent", "shared"], + "description": "Bereich. Standard ist 'agent' (nur für dich)." + }, + "subject": { + "type": "string", + "description": "Worum es geht — z.B. 'NVDA', 'Kunde Meier', 'Projekt Alpha'. Pflicht bei remember." + }, + "content": { + "type": "string", + "description": "Was du dir merken willst. Pflicht bei remember." + }, + "key": { + "type": "string", + "description": "Optionaler eindeutiger Schlüssel. Erneutes Merken darunter aktualisiert den Eintrag." + }, + "category": { + "type": "string", + "enum": ["fact", "decision", "observation", "task", "contact", "other"], + "description": "Art der Erinnerung. Standard 'observation'." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Schlagworte zum späteren Wiederfinden." + }, + "importance": { + "type": "integer", + "description": "1 (nebensächlich) bis 5 (zentral). Standard 3. Steuert die Reihenfolge beim Abruf." + }, + "query": { + "type": "string", + "description": "Suchtext für recall — wird gegen Betreff, Inhalt und Schlagworte geprüft." + }, + "limit": { + "type": "integer", + "description": "Maximale Anzahl Treffer bei recall. Standard 20." + }, + "minImportance": { + "type": "integer", + "description": "Nur Erinnerungen ab dieser Wichtigkeit abrufen." + }, + "id": { + "type": "integer", + "description": "Id der zu löschenden Erinnerung (forget)." + } + }, + "required": ["action"] + } + """).RootElement.Clone(); + + public async Task ExecuteAsync( + JsonElement input, AgentToolContext context, CancellationToken ct) + { + if (context.Memory is not { } repository) + return ToolResult.Fail("Das Gedächtnis ist für diese Instanz nicht verfügbar."); + + var action = input.TryGetProperty("action", out var a) ? a.GetString() : null; + if (string.IsNullOrWhiteSpace(action)) + return ToolResult.Fail("'action' ist erforderlich."); + + try + { + return action switch + { + "remember" => await RememberAsync(input, repository, context, ct), + "recall" => await RecallAsync(input, repository, context, ct), + "forget" => await ForgetAsync(input, repository, context, ct), + "list_subjects" => await ListSubjectsAsync(input, repository, context, ct), + _ => ToolResult.Fail($"Unbekannte Aktion: {action}") + }; + } + catch (Exception ex) + { + context.Logger.LogError(ex, "Fehler im Memory-Tool bei Aktion {Action}", action); + return ToolResult.Fail($"Fehler: {ex.Message}"); + } + } + + // ─── Aktionen ─── + + private static async Task RememberAsync( + JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct) + { + var subject = GetString(input, "subject"); + if (string.IsNullOrWhiteSpace(subject)) + return ToolResult.Fail("'subject' ist erforderlich — worum geht es?"); + + var content = GetString(input, "content"); + if (string.IsNullOrWhiteSpace(content)) + return ToolResult.Fail("'content' ist erforderlich — was soll gemerkt werden?"); + + var scope = GetScope(input); + + var entry = new MemoryEntry + { + Scope = scope, + OwnerId = context.AgentId, + Subject = subject.Trim(), + Content = content.Trim(), + Key = GetString(input, "key"), + Category = MemoryCategory.Normalize(GetString(input, "category") ?? MemoryCategory.Observation), + Tags = GetTags(input), + Importance = GetInt(input, "importance") ?? 3, + CreatedBy = context.AgentId + }; + + var saved = await repository.RememberAsync(entry, ct); + + var scopeLabel = scope == MemoryScope.Shared ? "geteilt" : "privat"; + var keyHint = string.IsNullOrWhiteSpace(saved.Key) ? "" : $" | Schlüssel: {saved.Key}"; + + return ToolResult.Ok( + $"Gemerkt (#{saved.Id}, {scopeLabel}){keyHint}\n" + + $"Betreff: {saved.Subject} | Art: {saved.Category} | Wichtigkeit: {saved.Importance}"); + } + + private static async Task RecallAsync( + JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct) + { + var scope = GetScope(input); + + var query = new MemoryQuery + { + Scope = scope, + OwnerId = context.AgentId, + Search = GetString(input, "query"), + Subject = GetString(input, "subject"), + Category = GetString(input, "category"), + Tags = GetTags(input), + Limit = GetInt(input, "limit") ?? 20, + MinImportance = GetInt(input, "minImportance") ?? 1 + }; + + var entries = await repository.RecallAsync(query, ct); + + if (entries.Count == 0) + { + var total = await repository.CountAsync(scope, context.AgentId, ct); + return ToolResult.Ok(total == 0 + ? "Noch keine Erinnerungen in diesem Bereich." + : $"Keine Treffer. Der Bereich enthält {total} Erinnerung(en) — " + + "versuche einen anderen Suchbegriff oder 'list_subjects' für einen Überblick."); + } + + return ToolResult.Ok(Render(entries, scope)); + } + + private static async Task ForgetAsync( + JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct) + { + if (GetInt(input, "id") is { } id) + { + var removed = await repository.ForgetAsync(id, ct); + return ToolResult.Ok(removed + ? $"Erinnerung #{id} gelöscht." + : $"Keine Erinnerung mit der Id {id} gefunden."); + } + + var subject = GetString(input, "subject"); + if (string.IsNullOrWhiteSpace(subject)) + return ToolResult.Fail("Für 'forget' wird entweder 'id' oder 'subject' benötigt."); + + var count = await repository.ForgetBySubjectAsync(GetScope(input), context.AgentId, subject, ct); + return ToolResult.Ok($"{count} Erinnerung(en) zum Betreff '{subject}' gelöscht."); + } + + private static async Task ListSubjectsAsync( + JsonElement input, IMemoryRepository repository, AgentToolContext context, CancellationToken ct) + { + var scope = GetScope(input); + var subjects = await repository.ListSubjectsAsync( + scope, context.AgentId, GetInt(input, "limit") ?? 50, ct); + + if (subjects.Count == 0) + return ToolResult.Ok("Noch keine Erinnerungen in diesem Bereich."); + + var sb = new StringBuilder(); + sb.AppendLine($"Betreffs im Bereich '{(scope == MemoryScope.Shared ? "shared" : "agent")}':"); + foreach (var (subject, count) in subjects) + sb.AppendLine($" • {subject} ({count})"); + + return ToolResult.Ok(sb.ToString().TrimEnd()); + } + + // ─── Darstellung ─── + + private static string Render(IReadOnlyList entries, MemoryScope scope) + { + var sb = new StringBuilder(); + sb.AppendLine($"{entries.Count} Erinnerung(en), wichtigste zuerst:"); + sb.AppendLine(); + + var shown = 0; + foreach (var entry in entries) + { + var block = FormatEntry(entry, scope); + + // Lieber weniger Treffer als ein gesprengter Kontext. + if (sb.Length + block.Length > MaxResultChars) + { + sb.AppendLine($"[… {entries.Count - shown} weitere Treffer ausgelassen. " + + "Grenze die Suche ein, um sie zu sehen.]"); + break; + } + + sb.Append(block); + shown++; + } + + return sb.ToString().TrimEnd(); + } + + private static string FormatEntry(MemoryEntry entry, MemoryScope scope) + { + var sb = new StringBuilder(); + + sb.Append($"#{entry.Id} [{entry.Subject}] {entry.Category}, Wichtigkeit {entry.Importance}"); + if (!string.IsNullOrWhiteSpace(entry.Key)) + sb.Append($", Schlüssel: {entry.Key}"); + sb.AppendLine(); + + sb.AppendLine($" {entry.Content}"); + + var meta = new List { $"aktualisiert {entry.UpdatedAt:yyyy-MM-dd HH:mm} UTC" }; + if (entry.Tags.Count > 0) + meta.Add("Schlagworte: " + string.Join(", ", entry.Tags)); + if (scope == MemoryScope.Shared && !string.IsNullOrWhiteSpace(entry.CreatedBy)) + meta.Add($"von {entry.CreatedBy}"); + + sb.AppendLine($" ({string.Join(" | ", meta)})"); + sb.AppendLine(); + + return sb.ToString(); + } + + // ─── Eingabe lesen ─── + + private static string? GetString(JsonElement input, string name) + => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static int? GetInt(JsonElement input, string name) + => input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt32() + : null; + + private static MemoryScope GetScope(JsonElement input) + => string.Equals(GetString(input, "scope"), "shared", StringComparison.OrdinalIgnoreCase) + ? MemoryScope.Shared + : MemoryScope.Agent; + + private static IReadOnlyList GetTags(JsonElement input) + { + if (!input.TryGetProperty("tags", out var tags) || tags.ValueKind != JsonValueKind.Array) + return []; + + return tags.EnumerateArray() + .Where(t => t.ValueKind == JsonValueKind.String) + .Select(t => t.GetString()!) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToList(); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs new file mode 100644 index 0000000..e7aede1 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Memory/MemoryRepositoryTests.cs @@ -0,0 +1,417 @@ +using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Memory; + +/// +/// K1 aus der Bestandsaufnahme: Geplante Agenten begannen bei jedem Cron-Lauf bei null. +/// +/// Getestet wird gegen eine echte SQLite-Datei, nicht gegen eine Attrappe — nur so +/// werden Schema, Indizes, Sperrverhalten und die Behandlung gleichzeitiger Zugriffe +/// tatsächlich geprüft. +/// +public sealed class MemoryRepositoryTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteMemoryRepository _repository; + + private const string AgentA = "agent-a"; + private const string AgentB = "agent-b"; + + public MemoryRepositoryTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repository = new SqliteMemoryRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private Task Remember( + string subject, string content, string owner = AgentA, + MemoryScope scope = MemoryScope.Agent, string? key = null, + int importance = 3, string? category = null, params string[] tags) + => _repository.RememberAsync(new MemoryEntry + { + Scope = scope, + OwnerId = owner, + Subject = subject, + Content = content, + Key = key, + Importance = importance, + Category = category ?? MemoryCategory.Observation, + Tags = tags, + CreatedBy = owner + }, default); + + private Task> Recall( + string? search = null, string owner = AgentA, MemoryScope scope = MemoryScope.Agent, + string? subject = null, int limit = 20, int minImportance = 1, params string[] tags) + => _repository.RecallAsync(new MemoryQuery + { + Scope = scope, + OwnerId = owner, + Search = search, + Subject = subject, + Tags = tags, + Limit = limit, + MinImportance = minImportance + }, default); + + // ═══════════════════════════════════════════════════════════ + // Grundfunktionen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Eine_Erinnerung_ueberdauert_und_wird_wiedergefunden() + { + await Remember("NVDA", "Quartalszahlen am 20.11. erwartet"); + + var found = await Recall("Quartalszahlen"); + + found.Count.ShouldBe(1); + found[0].Subject.ShouldBe("NVDA"); + found[0].Content.ShouldContain("20.11."); + } + + [Fact] + public async Task Die_Suche_greift_auf_Betreff_Inhalt_und_Schlagworte() + { + await Remember("NVDA", "Kursziel angehoben", tags: ["analyse", "quartal"]); + + (await Recall("NVDA")).Count.ShouldBe(1); + (await Recall("Kursziel")).Count.ShouldBe(1); + (await Recall("analyse")).Count.ShouldBe(1); + (await Recall("voellig anderes")).ShouldBeEmpty(); + } + + [Fact] + public async Task Die_Suche_beachtet_Gross_und_Kleinschreibung_nicht() + { + await Remember("NVDA", "Kursziel angehoben"); + + (await Recall("kursZIEL")).Count.ShouldBe(1); + } + + [Fact] + public async Task Nach_Betreff_laesst_sich_gezielt_filtern() + { + await Remember("NVDA", "Erste Notiz"); + await Remember("TSLA", "Zweite Notiz"); + + var found = await Recall(subject: "NVDA"); + + found.Count.ShouldBe(1); + found[0].Content.ShouldBe("Erste Notiz"); + } + + // ═══════════════════════════════════════════════════════════ + // Schlüssel: aktualisieren statt zuwachsen + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ein_Schluessel_aktualisiert_statt_eine_zweite_Erinnerung_anzulegen() + { + // Sonst wüchse das Gedächtnis bei einem alle 30 Minuten laufenden Agenten + // binnen eines Tages um 48 Einträge zur selben Sache. + await Remember("NVDA", "Kursziel 120", key: "kursziel_nvda"); + await Remember("NVDA", "Kursziel 145", key: "kursziel_nvda"); + + var found = await Recall(subject: "NVDA"); + + found.Count.ShouldBe(1); + found[0].Content.ShouldBe("Kursziel 145"); + } + + [Fact] + public async Task Beim_Aktualisieren_bleibt_das_Anlagedatum_erhalten() + { + var first = await Remember("NVDA", "Erste Fassung", key: "k"); + await Task.Delay(10); + var second = await Remember("NVDA", "Zweite Fassung", key: "k"); + + second.Id.ShouldBe(first.Id); + second.CreatedAt.ShouldBe(first.CreatedAt); + second.UpdatedAt.ShouldBeGreaterThan(first.CreatedAt); + } + + [Fact] + public async Task Ohne_Schluessel_entstehen_mehrere_Eintraege() + { + await Remember("NVDA", "Beobachtung eins"); + await Remember("NVDA", "Beobachtung zwei"); + + (await Recall(subject: "NVDA")).Count.ShouldBe(2); + } + + [Fact] + public async Task Derselbe_Schluessel_bei_verschiedenen_Agenten_kollidiert_nicht() + { + await Remember("NVDA", "Sicht von A", owner: AgentA, key: "einschaetzung"); + await Remember("NVDA", "Sicht von B", owner: AgentB, key: "einschaetzung"); + + (await Recall(owner: AgentA))[0].Content.ShouldBe("Sicht von A"); + (await Recall(owner: AgentB))[0].Content.ShouldBe("Sicht von B"); + } + + // ═══════════════════════════════════════════════════════════ + // Bereiche + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Private_Erinnerungen_sind_fuer_andere_Agenten_unsichtbar() + { + await Remember("Geheim", "Nur für A", owner: AgentA); + + (await Recall(owner: AgentB)).ShouldBeEmpty(); + } + + [Fact] + public async Task Geteilte_Erinnerungen_sehen_alle_Agenten() + { + await Remember("Team", "Gemeinsame Erkenntnis", owner: AgentA, scope: MemoryScope.Shared); + + var fromB = await Recall(owner: AgentB, scope: MemoryScope.Shared); + + fromB.Count.ShouldBe(1); + fromB[0].CreatedBy.ShouldBe(AgentA, "auch im geteilten Bereich bleibt die Herkunft sichtbar"); + } + + [Fact] + public async Task Geteilte_und_private_Bereiche_sind_getrennt() + { + await Remember("X", "privat", owner: AgentA); + await Remember("X", "geteilt", owner: AgentA, scope: MemoryScope.Shared); + + (await Recall(owner: AgentA)).Count.ShouldBe(1); + (await Recall(owner: AgentA, scope: MemoryScope.Shared)).Count.ShouldBe(1); + } + + // ═══════════════════════════════════════════════════════════ + // Reihenfolge und Kappung + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Wichtiges_kommt_zuerst() + { + // Entscheidend, weil der Abruf begrenzt wird — das Wichtigste muss überleben. + await Remember("A", "nebensächlich", importance: 1); + await Remember("B", "zentral", importance: 5); + await Remember("C", "mittel", importance: 3); + + var found = await Recall(); + + found[0].Content.ShouldBe("zentral"); + found[^1].Content.ShouldBe("nebensächlich"); + } + + [Fact] + public async Task Bei_gleicher_Wichtigkeit_kommt_das_Aktuellste_zuerst() + { + await Remember("A", "aelter", importance: 3); + await Task.Delay(10); + await Remember("B", "neuer", importance: 3); + + (await Recall())[0].Content.ShouldBe("neuer"); + } + + [Fact] + public async Task Unwichtiges_laesst_sich_ausblenden() + { + await Remember("A", "nebensächlich", importance: 1); + await Remember("B", "zentral", importance: 5); + + var found = await Recall(minImportance: 4); + + found.Count.ShouldBe(1); + found[0].Content.ShouldBe("zentral"); + } + + [Fact] + public async Task Die_Trefferzahl_ist_begrenzt() + { + for (var i = 0; i < 50; i++) + await Remember("Massen", $"Notiz {i}"); + + (await Recall(limit: 5)).Count.ShouldBe(5); + } + + [Fact] + public async Task Die_Wichtigkeit_bleibt_im_gueltigen_Bereich() + { + var tooHigh = await Remember("A", "x", importance: 99); + var tooLow = await Remember("B", "y", importance: -5); + + tooHigh.Importance.ShouldBe(5); + tooLow.Importance.ShouldBe(1); + } + + // ═══════════════════════════════════════════════════════════ + // Schlagworte + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Nach_Schlagworten_laesst_sich_filtern() + { + await Remember("A", "eins", tags: ["dringend", "kunde"]); + await Remember("B", "zwei", tags: ["kunde"]); + await Remember("C", "drei"); + + (await Recall(tags: "kunde")).Count.ShouldBe(2); + (await Recall(tags: "dringend")).Count.ShouldBe(1); + } + + [Fact] + public async Task Ein_Schlagwort_trifft_kein_laengeres_mit_gleichem_Anfang() + { + // "news" darf nicht "newsletter" treffen. + await Remember("A", "eins", tags: ["newsletter"]); + + (await Recall(tags: "news")).ShouldBeEmpty(); + (await Recall(tags: "newsletter")).Count.ShouldBe(1); + } + + [Fact] + public async Task Schlagworte_ueberstehen_den_Rundlauf() + { + await Remember("A", "eins", tags: ["Alpha", "Beta"]); + + var found = await Recall(); + + found[0].Tags.ShouldBe(["alpha", "beta"]); + } + + // ═══════════════════════════════════════════════════════════ + // Löschen und Überblick + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Eine_Erinnerung_laesst_sich_loeschen() + { + var entry = await Remember("A", "vergänglich"); + + (await _repository.ForgetAsync(entry.Id, default)).ShouldBeTrue(); + (await Recall()).ShouldBeEmpty(); + } + + [Fact] + public async Task Das_Loeschen_einer_unbekannten_Id_meldet_das() + { + (await _repository.ForgetAsync(99_999, default)).ShouldBeFalse(); + } + + [Fact] + public async Task Alle_Erinnerungen_zu_einem_Betreff_lassen_sich_loeschen() + { + await Remember("NVDA", "eins"); + await Remember("NVDA", "zwei"); + await Remember("TSLA", "drei"); + + var removed = await _repository.ForgetBySubjectAsync(MemoryScope.Agent, AgentA, "NVDA", default); + + removed.ShouldBe(2); + (await Recall()).Count.ShouldBe(1); + } + + [Fact] + public async Task Der_Ueberblick_zeigt_Betreffs_mit_Anzahl() + { + await Remember("NVDA", "eins"); + await Remember("NVDA", "zwei"); + await Remember("TSLA", "drei"); + + var subjects = await _repository.ListSubjectsAsync(MemoryScope.Agent, AgentA, 10, default); + + subjects.Count.ShouldBe(2); + subjects[0].ShouldBe(("NVDA", 2)); + } + + // ═══════════════════════════════════════════════════════════ + // Nebenläufigkeit — der eigentliche Grund für WAL und busy_timeout + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Viele_Agenten_koennen_gleichzeitig_schreiben() + { + // Ohne WAL und Sperr-Wartezeit quittiert SQLite das mit "database is locked". + var tasks = Enumerable.Range(0, 60) + .Select(i => Remember($"Betreff {i % 5}", $"Notiz {i}", owner: $"agent-{i % 6}")); + + await Task.WhenAll(tasks); + + var total = 0; + for (var i = 0; i < 6; i++) + total += await _repository.CountAsync(MemoryScope.Agent, $"agent-{i}", default); + + total.ShouldBe(60); + } + + [Fact] + public async Task Gleichzeitiges_Schreiben_und_Lesen_stoert_sich_nicht() + { + await Remember("Basis", "vorhanden"); + + var writes = Enumerable.Range(0, 20).Select(i => Remember("Neu", $"Notiz {i}")); + var reads = Enumerable.Range(0, 20).Select(_ => Recall()); + + await Task.WhenAll(writes.Cast().Concat(reads)); + + (await _repository.CountAsync(MemoryScope.Agent, AgentA, default)).ShouldBe(21); + } + + // ═══════════════════════════════════════════════════════════ + // Sonderfälle + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ein_Prozentzeichen_im_Suchtext_wirkt_nicht_als_Platzhalter() + { + await Remember("A", "Erster Eintrag"); + await Remember("B", "Zweiter Eintrag"); + + // Als LIKE-Muster würde "%" alles treffen. + (await Recall("%")).ShouldBeEmpty(); + } + + [Fact] + public async Task Umlaute_und_Emoji_ueberstehen_die_Ablage() + { + await Remember("Größe", "Prüfung mit Umlauten und 🦀"); + + var found = await Recall("Prüfung"); + + found[0].Content.ShouldBe("Prüfung mit Umlauten und 🦀"); + found[0].Subject.ShouldBe("Größe"); + } + + [Fact] + public async Task Eine_unbekannte_Kategorie_faellt_auf_other_zurueck() + { + var entry = await Remember("A", "x", category: "phantasie"); + + entry.Category.ShouldBe(MemoryCategory.Other); + } + + [Fact] + public async Task Erinnerungen_ueberdauern_das_Schliessen_der_Datenbank() + { + // Der eigentliche Zweck: Ein Neustart der Anwendung darf nichts kosten. + await Remember("Dauerhaft", "muss einen Neustart überleben"); + + var wiederEroeffnet = new SqliteMemoryRepository( + new SqliteStorage(Path.Combine(_directory, "state.db"))); + + var found = await wiederEroeffnet.RecallAsync( + new MemoryQuery { Scope = MemoryScope.Agent, OwnerId = AgentA }, default); + + found.Count.ShouldBe(1); + found[0].Content.ShouldBe("muss einen Neustart überleben"); + } +}