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