K1: Langzeitgedaechtnis fuer Agenten

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 Schluesse und konnte keine Entwicklung ueber Zeit
verfolgen. Das war zugleich die groesste Faehigkeitsluecke und eine dauerhafte
Token-Verschwendung.

Speicher-Fundament

SqliteStorage buendelt den Zugang zur Instanz-Datenbank und aktiviert WAL,
busy_timeout und Connection-Pooling. Vorher oeffnete 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.

Gedaechtnis

Typisierte Tabelle statt JSON in einer Wert-Spalte — nur so laesst sich filtern,
sortieren und spaeter auswerten. Das Schema ist schlicht gehalten, damit eine
MySQL-Variante spaeter dieselbe Struktur mit wenigen Dialektunterschieden
bekommen kann.

Der wichtigste Teil ist der optionale Schluessel: Erneutes Merken darunter
aktualisiert den Eintrag, statt einen zweiten anzulegen. Ohne das wuechse das
Gedaechtnis eines halbstuendlich laufenden Agenten um 48 Eintraege pro Tag zur
selben Sache. Beobachtungen ohne Schluessel sammeln sich weiterhin an, wenn ein
Verlauf entstehen soll.

Der Abruf sortiert nach Wichtigkeit, dann Aktualitaet — wesentlich, weil das
Ergebnis begrenzt wird und bei einer Kappung das Wichtigste ueberleben muss.
Zusaetzlich greift eine Zeichenobergrenze, damit ein Abruf den Kontext nicht
sprengt.

Die Trennung privat/geteilt ist absichtlich dieselbe wie beim FileRW-Tool, damit
das Konzept fuer Agenten wiedererkennbar bleibt.

Beim Testen fiel auf, dass das Maskieren der LIKE-Platzhalter falsch war: Die
Zeichen wurden entfernt statt maskiert, wodurch eine Suche nach einem
Prozentzeichen zu einem leeren Muster und damit zu einem Treffer auf alles wurde.
Jetzt mit ESCAPE-Klausel.

338 Tests gruen (190 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-28 10:44:39 +02:00
co-authored by Claude Opus 4.8
parent 8604fa30c7
commit 4747835fa1
13 changed files with 1425 additions and 51 deletions
+1
View File
@@ -56,6 +56,7 @@
<ProjectReference Include="src\ClawdDotNet.Tools.AgentSpawn\ClawdDotNet.Tools.AgentSpawn.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
<ProjectReference Include="src\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
</ItemGroup>
+1
View File
@@ -14,6 +14,7 @@
<Project Path="src/ClawdDotNet.Tools.AgentSpawn/ClawdDotNet.Tools.AgentSpawn.csproj" />
<Project Path="src/ClawdDotNet.Tools.AgentEditor/ClawdDotNet.Tools.AgentEditor.csproj" />
<Project Path="src/ClawdDotNet.Tools.SocialMediaManager/ClawdDotNet.Tools.SocialMediaManager.csproj" />
<Project Path="src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
+10 -4
View File
@@ -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,
+72
View File
@@ -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` | 15, 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.
+7 -2
View File
@@ -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);
+112
View File
@@ -0,0 +1,112 @@
namespace ClawdDotNet.Core.Memory;
/// <summary>
/// Wem eine Erinnerung gehört.
/// Bewusst dieselbe Trennung wie beim FileRW-Tool, damit sie für Agenten
/// nachvollziehbar bleibt.
/// </summary>
public enum MemoryScope
{
/// <summary>Nur für den Agenten selbst sichtbar.</summary>
Agent,
/// <summary>Für alle Agenten der Instanz sichtbar.</summary>
Shared
}
/// <summary>Grobe Einordnung, damit Recall gezielt filtern kann.</summary>
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; }
/// <summary>Agent-Id bei <see cref="MemoryScope.Agent"/>, sonst leer.</summary>
public string OwnerId { get; init; } = "";
public string Category { get; init; } = MemoryCategory.Other;
/// <summary>Worum es geht — etwa ein Ticker, ein Kundenname, ein Projekt.</summary>
public string Subject { get; init; } = "";
/// <summary>
/// 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.
/// </summary>
public string? Key { get; init; }
public string Content { get; init; } = "";
public IReadOnlyList<string> Tags { get; init; } = [];
/// <summary>1 (nebensächlich) bis 5 (zentral). Steuert die Reihenfolge beim Abruf.</summary>
public int Importance { get; init; } = 3;
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
/// <summary>Welcher Agent die Erinnerung angelegt hat — auch bei geteiltem Scope.</summary>
public string CreatedBy { get; init; } = "";
}
/// <summary>Suchkriterien für den Abruf.</summary>
public sealed record MemoryQuery
{
public MemoryScope Scope { get; init; } = MemoryScope.Agent;
public string OwnerId { get; init; } = "";
/// <summary>Freitext — wird gegen Betreff, Inhalt und Schlagworte geprüft.</summary>
public string? Search { get; init; }
public string? Subject { get; init; }
public string? Category { get; init; }
public IReadOnlyList<string> Tags { get; init; } = [];
public int Limit { get; init; } = 20;
/// <summary>Nur Erinnerungen ab dieser Wichtigkeit.</summary>
public int MinImportance { get; init; } = 1;
}
public interface IMemoryRepository
{
/// <summary>
/// Legt eine Erinnerung an oder aktualisiert sie, wenn ein Schlüssel angegeben ist
/// und darunter bereits eine besteht.
/// </summary>
Task<MemoryEntry> RememberAsync(MemoryEntry entry, CancellationToken ct);
Task<IReadOnlyList<MemoryEntry>> RecallAsync(MemoryQuery query, CancellationToken ct);
Task<MemoryEntry?> GetByKeyAsync(MemoryScope scope, string ownerId, string key, CancellationToken ct);
/// <summary>Löscht eine Erinnerung. Gibt an, ob es etwas zu löschen gab.</summary>
Task<bool> ForgetAsync(long id, CancellationToken ct);
Task<int> ForgetBySubjectAsync(MemoryScope scope, string ownerId, string subject, CancellationToken ct);
/// <summary>Betreffs mit Anzahl — für einen Überblick, ohne alle Inhalte zu laden.</summary>
Task<IReadOnlyList<(string Subject, int Count)>> ListSubjectsAsync(
MemoryScope scope, string ownerId, int limit, CancellationToken ct);
Task<int> CountAsync(MemoryScope scope, string ownerId, CancellationToken ct);
}
@@ -0,0 +1,307 @@
using System.Text;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Memory;
/// <summary>
/// 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.
/// </summary>
public sealed class SqliteMemoryRepository : IMemoryRepository
{
private readonly SqliteStorage _storage;
public SqliteMemoryRepository(SqliteStorage storage) => _storage = storage;
public Task<MemoryEntry> 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<IReadOnlyList<MemoryEntry>> 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<MemoryEntry>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public async Task<MemoryEntry?> 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<bool> 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<int> 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<IReadOnlyList<(string Subject, int Count)>> 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<int> 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<MemoryEntry?> 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<MemoryScope>(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)
};
/// <summary>
/// Schlagworte als "|a|b|c|". Die Begrenzer erlauben eine Suche nach ganzen
/// Schlagworten, ohne dass "news" auch "newsletter" trifft.
/// </summary>
private static string SerializeTags(IReadOnlyList<string> 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<string> 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();
/// <summary>
/// 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.
/// </summary>
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;
}
+27 -43
View File
@@ -1,63 +1,47 @@
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.State;
/// <summary>
/// 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.
/// </summary>
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<string?> 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);
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);
await cmd.ExecuteNonQueryAsync(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);
}
@@ -0,0 +1,135 @@
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// 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.
/// </summary>
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<SqliteConnection> OpenConnectionAsync(CancellationToken ct)
{
var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(ct);
ApplyPragmas(connection);
return connection;
}
/// <summary>
/// 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.
/// </summary>
public async Task<T> WriteAsync<T>(Func<SqliteConnection, Task<T>> 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<SqliteConnection, Task> action, CancellationToken ct)
=> await WriteAsync<object?>(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();
}
}
@@ -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
);
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Tools.Memory</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup>
</Project>
+318
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class MemoryTool : IAgentTool
{
/// <summary>Obergrenze für die Ausgabe, damit ein Abruf den Kontext nicht sprengt.</summary>
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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<ToolResult> 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<MemoryEntry> 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<string> { $"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<string> 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();
}
}
@@ -0,0 +1,417 @@
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Memory;
/// <summary>
/// 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.
/// </summary>
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<MemoryEntry> 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<IReadOnlyList<MemoryEntry>> 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<Task>().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");
}
}