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:
co-authored by
Claude Opus 4.8
parent
8604fa30c7
commit
4747835fa1
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user