Prompt history: every run records its transcript (task, each model step with tokens and cost, tool calls and results, engine follow-ups) - bounded in size, scrubbed of recognisable secrets, system prompts stored once per text. Retention in days per instance (default 14) and per agent (0 = never, -1 = forever); an hourly janitor deletes what is past it, "never" also purges. Usage analysis over run receipts: filter by period, agent, model, event, status, cost and free text; sort by every column; totals over the whole filter; hourly or daily series grouped by agent, model or event; markers for budget denials and model changes; run detail with receipt, transcript, tool calls and approvals. WebUI: Analysis > Token usage > Runs / Timeline, run detail, Prompt History with retention settings. New code, comments and texts in English; texts come from language packs (i18n/en.json, de.json) via modules/i18n.js. CLAUDE.md records English as the main language. Version 0.1.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
16 KiB
C#
377 lines
16 KiB
C#
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();
|
|
|
|
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
|
|
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
|
|
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
|
|
// die noch aus der Zeit vor dieser Zeile stammen.
|
|
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
|
|
{
|
|
if (File.Exists(datei))
|
|
AppPaths.RestrictToOwner(datei);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
CREATE TABLE IF NOT EXISTS RunUsage (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL,
|
|
PromptTokens INTEGER NOT NULL,
|
|
CompletionTokens INTEGER NOT NULL,
|
|
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
|
CostUsd TEXT NOT NULL DEFAULT '0',
|
|
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
StepCount INTEGER NOT NULL DEFAULT 0,
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
OccurredAt TEXT NOT NULL,
|
|
-- Ortsdatum, damit ein Tagesbudget der Wahrnehmung des Benutzers folgt
|
|
-- und die Abfrage ohne Zeitzonenrechnerei auskommt.
|
|
UsageDate TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunUsage_Day
|
|
ON RunUsage (UsageDate, AgentId);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
|
|
ON RunUsage (OccurredAt DESC);
|
|
|
|
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
|
|
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
|
|
CREATE TABLE IF NOT EXISTS Tasks (
|
|
Id TEXT PRIMARY KEY,
|
|
Title TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL DEFAULT 'todo',
|
|
Type TEXT NOT NULL DEFAULT 'work',
|
|
Priority INTEGER NOT NULL DEFAULT 3,
|
|
Assignee TEXT NOT NULL DEFAULT '@human',
|
|
WhenKind TEXT NULL,
|
|
WhenValue TEXT NULL,
|
|
WhenTz TEXT NULL,
|
|
RequireApproval INTEGER NOT NULL DEFAULT 0,
|
|
Acceptance TEXT NOT NULL DEFAULT '',
|
|
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
|
|
BlockedBy TEXT NOT NULL DEFAULT '',
|
|
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
|
|
Body TEXT NOT NULL DEFAULT '',
|
|
FileName TEXT NOT NULL DEFAULT '',
|
|
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
|
|
ToolName TEXT NOT NULL DEFAULT '',
|
|
JobTypeId TEXT NOT NULL DEFAULT '',
|
|
-- Ausführungszustand: nur das Board schreibt hier.
|
|
LastOccurrence TEXT NULL,
|
|
ClaimToken TEXT NULL,
|
|
ClaimedAt TEXT NULL,
|
|
CreatedAt TEXT NOT NULL,
|
|
UpdatedAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
|
|
ON Tasks (Status);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
|
|
ON Tasks (Assignee);
|
|
|
|
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
|
|
-- stempelt die Engine, Korrekturen sind neue Zeilen.
|
|
CREATE TABLE IF NOT EXISTS AuditLog (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT 'unknown',
|
|
Tool TEXT NOT NULL,
|
|
Arguments TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL,
|
|
Summary TEXT NOT NULL DEFAULT '',
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
OccurredAt TEXT NOT NULL,
|
|
-- Paart den "angefangen"-Eintrag mit seinem Abschluss (#554).
|
|
-- Wird fuer bestehende Datenbanken in Nachruesten() ergaenzt.
|
|
CallId TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
|
ON AuditLog (RunId, Id);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
|
|
ON AuditLog (Id DESC);
|
|
|
|
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
|
|
CREATE TABLE IF NOT EXISTS RunReceipts (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT 'unknown',
|
|
TaskId TEXT NULL,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
StepCount INTEGER NOT NULL DEFAULT 0,
|
|
PromptTokens INTEGER NOT NULL DEFAULT 0,
|
|
CompletionTokens INTEGER NOT NULL DEFAULT 0,
|
|
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
|
CostUsd TEXT NOT NULL DEFAULT '0',
|
|
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
|
DurationMs INTEGER NOT NULL DEFAULT 0,
|
|
ResultRef TEXT NOT NULL DEFAULT '',
|
|
OccurredAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
|
|
ON RunReceipts (TaskId);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
|
|
ON RunReceipts (RunId);
|
|
|
|
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
|
|
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
|
|
CREATE TABLE IF NOT EXISTS StagedCalls (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RunId TEXT NOT NULL,
|
|
AgentId TEXT NOT NULL,
|
|
InstanceId TEXT NOT NULL DEFAULT '',
|
|
Tool TEXT NOT NULL,
|
|
Action TEXT NULL,
|
|
ArgumentsJson TEXT NOT NULL DEFAULT '',
|
|
Proposal TEXT NOT NULL DEFAULT '',
|
|
Status TEXT NOT NULL DEFAULT 'Pending',
|
|
CreatedAt TEXT NOT NULL,
|
|
DecidedAt TEXT NULL,
|
|
DecidedBy TEXT NULL,
|
|
ResultRef TEXT NULL,
|
|
RejectionReason TEXT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
|
|
ON StagedCalls (Status, Id);
|
|
|
|
-- #641: "Gleichartiges kuenftig erlauben" - je Agent, Werkzeug und Operation.
|
|
-- Ein Widerruf setzt WiderrufenAm; geloescht wird nichts.
|
|
CREATE TABLE IF NOT EXISTS Dauerfreigaben (
|
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
AgentId TEXT NOT NULL,
|
|
Tool TEXT NOT NULL,
|
|
Operation TEXT NOT NULL,
|
|
ErteiltVon TEXT NOT NULL,
|
|
ErteiltAm TEXT NOT NULL,
|
|
AnlassId INTEGER NULL,
|
|
WiderrufenAm TEXT NULL,
|
|
WiderrufenVon TEXT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
|
|
ON Dauerfreigaben (AgentId, Tool, Operation);
|
|
|
|
-- #660: Prompt history - prompt and response of every run, kept as long as the
|
|
-- retention of the agent allows. System prompts are stored once per text.
|
|
CREATE TABLE IF NOT EXISTS RunTranscripts (
|
|
RunId TEXT PRIMARY KEY,
|
|
AgentId TEXT NOT NULL,
|
|
Model TEXT NOT NULL DEFAULT '',
|
|
Source TEXT NOT NULL DEFAULT '',
|
|
TaskId TEXT NULL,
|
|
StartedAt TEXT NOT NULL,
|
|
CompletedAt TEXT NULL,
|
|
Status TEXT NOT NULL DEFAULT '',
|
|
SystemPromptHash TEXT NULL,
|
|
Prompt TEXT NOT NULL DEFAULT '',
|
|
Response TEXT NOT NULL DEFAULT '',
|
|
EntriesJson TEXT NOT NULL DEFAULT '[]',
|
|
SizeChars INTEGER NOT NULL DEFAULT 0,
|
|
Truncated INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Agent
|
|
ON RunTranscripts (AgentId, StartedAt);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Started
|
|
ON RunTranscripts (StartedAt DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS SystemPrompts (
|
|
Hash TEXT PRIMARY KEY,
|
|
Text TEXT NOT NULL,
|
|
FirstSeenAt TEXT NOT NULL
|
|
);
|
|
""";
|
|
|
|
cmd.ExecuteNonQuery();
|
|
|
|
Nachruesten(connection);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind.
|
|
///
|
|
/// <para><c>CREATE TABLE IF NOT EXISTS</c> allein genügt dafür nicht: Bei einer
|
|
/// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und
|
|
/// erst das nächste <c>INSERT</c>, das sie benennt, scheitert — im Betrieb, beim
|
|
/// Anwender, nicht beim Entwickler mit der frischen Datei.</para>
|
|
/// </summary>
|
|
private static void Nachruesten(SqliteConnection connection)
|
|
{
|
|
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
|
|
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
|
|
|
|
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
|
|
using var index = connection.CreateCommand();
|
|
index.CommandText = """
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Call
|
|
ON AuditLog (CallId, Id);
|
|
|
|
CREATE INDEX IF NOT EXISTS IX_AuditLog_Agent
|
|
ON AuditLog (AgentId, Id DESC);
|
|
""";
|
|
index.ExecuteNonQuery();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts.
|
|
///
|
|
/// <para>SQLite kennt kein <c>ADD COLUMN IF NOT EXISTS</c>, deshalb wird erst
|
|
/// nachgesehen. Die Vorgabe ist Pflicht: Eine <c>NOT NULL</c>-Spalte ohne
|
|
/// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen.</para>
|
|
/// </summary>
|
|
private static void SpalteErgaenzen(SqliteConnection connection, string tabelle, string spalte, string definition)
|
|
{
|
|
using (var pruefen = connection.CreateCommand())
|
|
{
|
|
pruefen.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
|
|
pruefen.Parameters.AddWithValue("@spalte", spalte);
|
|
|
|
if (Convert.ToInt32(pruefen.ExecuteScalar()) > 0)
|
|
return;
|
|
}
|
|
|
|
using var ergaenzen = connection.CreateCommand();
|
|
ergaenzen.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {spalte} {definition}";
|
|
ergaenzen.ExecuteNonQuery();
|
|
}
|
|
}
|