Prompt History und Verbrauchsauswertung (#660), englische Hauptsprache (#661)

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>
This commit is contained in:
Richard
2026-09-19 15:33:09 +02:00
co-authored by Claude Opus 5
parent 2f0361278b
commit 5adf4c1ca6
40 changed files with 3521 additions and 10 deletions
+9
View File
@@ -140,6 +140,15 @@ wenn keines dransteht.
Anwendung ruft `observe` und `status` nicht auf. Wer den Instanzmanager baut Anwendung ruft `observe` und `status` nicht auf. Wer den Instanzmanager baut
(#528, Stufe 3), liest den Befundzustand von Anfang an aus diesem Feld. (#528, Stufe 3), liest den Befundzustand von Anfang an aus diesem Feld.
## Sprache: Englisch ist die Hauptsprache (seit 2026-09-19, #661)
Neue Module, Klassen, Ansichten und Menüeinträge entstehen **auf Englisch** —
Bezeichner, Kommentare und Oberflächentexte. Texte stehen nicht fest im Code,
sondern kommen über Sprachschlüssel aus getrennten Sprachpaketen (WebUI:
`modules/i18n.js`, Pakete unter `wwwroot/i18n/`). Deutsch ist die erste
Zusatzsprache. Bestehenden deutschen Code nicht nebenbei umschreiben — die
Umstellung des Bestands ist ein eigenes Vorhaben mit niedriger Priorität.
## Weitere Handbücher ## Weitere Handbücher
| Dokument | Wofür | | Dokument | Wofür |
+1 -1
View File
@@ -18,7 +18,7 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
--> -->
<Version>0.1.11</Version> <Version>0.1.12</Version>
<!-- <!--
Symbole in die Assembly einbetten statt als .pdb daneben zu legen. Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
+24
View File
@@ -98,6 +98,12 @@ public sealed class AppHost : IAsyncDisposable
public TaskboardService? Board { get; private init; } public TaskboardService? Board { get; private init; }
public SqliteUsageRepository? Usage { get; private init; } public SqliteUsageRepository? Usage { get; private init; }
/// <summary>#660: Prompt history - prompt and response of every run.</summary>
public Core.Transcripts.IRunTranscriptStore? Transcripts { get; private init; }
/// <summary>#660: Usage analysis, run details and prompt history for the user interfaces.</summary>
public RunInsights? Insights { get; private init; }
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary> /// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
public ModelPricingCatalog? Pricing { get; private init; } public ModelPricingCatalog? Pricing { get; private init; }
@@ -525,6 +531,7 @@ public sealed class AppHost : IAsyncDisposable
var dauerfreigaben = new SqliteDauerfreigabeRepository(storage); var dauerfreigaben = new SqliteDauerfreigabeRepository(storage);
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben); var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben);
var usage = new SqliteUsageRepository(storage); var usage = new SqliteUsageRepository(storage);
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze. // Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
var pricing = new ModelPricingCatalog(); var pricing = new ModelPricingCatalog();
@@ -539,6 +546,8 @@ public sealed class AppHost : IAsyncDisposable
memory, usage, pricing, taskRepository, audit, stagingGate) memory, usage, pricing, taskRepository, audit, stagingGate)
{ {
InstanceBudget = instance.Budget, InstanceBudget = instance.Budget,
TranscriptStore = transcripts,
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
MaxDelegationDepth = instance.MaxDelegationDepth, MaxDelegationDepth = instance.MaxDelegationDepth,
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds) DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
}; };
@@ -574,6 +583,11 @@ public sealed class AppHost : IAsyncDisposable
Staging = staging, Staging = staging,
Board = board, Board = board,
Usage = usage, Usage = usage,
Transcripts = transcripts,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays)),
Pricing = pricing, Pricing = pricing,
TelegramClient = telegram, TelegramClient = telegram,
Status = new OpenRouterStatusService(instance.OpenRouterApiKey), Status = new OpenRouterStatusService(instance.OpenRouterApiKey),
@@ -591,6 +605,16 @@ public sealed class AppHost : IAsyncDisposable
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; }); host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
// #660: Retention of the prompt history - per agent, falling back to the instance.
var janitor = new Core.Transcripts.TranscriptJanitor(
transcripts,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays),
loggerFactory.CreateLogger("ClawdDotNet.Core.Transcripts"));
janitor.Start();
host._shutdown.Add(janitor.DisposeAsync);
// #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch // #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch
// mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf // mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf
// "OpenRouter: —" und /api/status auf "Prüfe…". // "OpenRouter: —" und /api/status auf "Prüfe…".
@@ -241,6 +241,37 @@ public sealed class AgentAdministration(AppHost host)
return AdminResult.Success($"Rechte für '{toolName}' ({agent.DisplayName}) gespeichert."); return AdminResult.Success($"Rechte für '{toolName}' ({agent.DisplayName}) gespeichert.");
} }
/// <summary>
/// #660: Sets how long the prompt history of an agent is kept, in days
/// (0 = never record, -1 = forever, <c>null</c> = instance default).
/// </summary>
public AdminResult SetPromptHistoryRetention(string agentId, int? retentionDays)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"No agent with id '{agentId}'.");
agent.PromptHistory.RetentionDays = retentionDays is { } d
? Core.Transcripts.PromptHistorySettings.Normalize(d)
: null;
PersistAgent(agent);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success(retentionDays is null
? $"Prompt history of '{agent.DisplayName}' follows the instance default."
: $"Prompt history of '{agent.DisplayName}': {Core.Transcripts.PromptHistorySettings.Describe(retentionDays.Value)}.");
}
/// <summary>#660: Instance default retention of the prompt history, in days.</summary>
public AdminResult SetDefaultPromptHistoryRetention(int retentionDays)
{
host.Instance.PromptHistory.RetentionDays = Core.Transcripts.PromptHistorySettings.Normalize(retentionDays);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
return AdminResult.Success(
$"Prompt history default: {Core.Transcripts.PromptHistorySettings.Describe(retentionDays)}.");
}
public AdminResult SetToolSettings( public AdminResult SetToolSettings(
string agentId, string toolName, Dictionary<string, object?> settings) string agentId, string toolName, Dictionary<string, object?> settings)
{ {
@@ -0,0 +1,71 @@
using ClawdDotNet.Core.Analytics;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Transcripts;
namespace ClawdDotNet.App.Services;
/// <summary>Everything known about a single run (#660).</summary>
public sealed record RunDetail(
string RunId,
RunReceipt? Receipt,
string? TaskTitle,
IReadOnlyList<AuditEntry> ToolCalls,
IReadOnlyList<StagedCall> Approvals,
RunTranscript? Transcript,
string? SystemPrompt,
string TranscriptState);
/// <summary>
/// Read side of the usage analysis and the prompt history (#660) for both user interfaces.
/// Owns no data: receipts and audit come from the audit repository, transcripts from the
/// transcript store, task titles from the board.
/// </summary>
public sealed class RunInsights(
IAuditRepository audit,
IRunTranscriptStore? transcripts,
TaskboardService? board,
StagingService? staging,
Func<string, int> retentionFor)
{
public UsageAnalytics Analytics { get; } = new(audit, board is null
? null
: async (id, ct) => (await board.GetAsync(id, ct))?.Title);
public IRunTranscriptStore? Transcripts => transcripts;
public async Task<RunDetail?> GetRunAsync(string runId, CancellationToken ct)
{
var receipt = await audit.GetReceiptForRunAsync(runId, ct);
var calls = await audit.ListForRunAsync(runId, ct);
var transcript = transcripts is null ? null : await transcripts.GetAsync(runId, ct);
if (receipt is null && calls.Count == 0 && transcript is null)
return null;
var agentId = receipt?.AgentId ?? transcript?.AgentId ?? calls.FirstOrDefault()?.AgentId ?? "";
var taskId = receipt?.TaskId ?? transcript?.TaskId;
return new RunDetail(
runId,
receipt,
taskId is not null && board is not null ? (await board.GetAsync(taskId, ct))?.Title : null,
// Only the completed calls; the "started" markers (#554) would double every line.
calls.Where(c => c.Status != AuditStatus.Started).ToList(),
staging is null ? [] : await staging.ListForRunAsync(runId, ct),
transcript,
transcript?.SystemPromptHash is { } hash && transcripts is not null
? await transcripts.GetSystemPromptAsync(hash, ct)
: null,
TranscriptState(transcript, agentId));
}
/// <summary>Why there is or is not a transcript - the UI says it instead of showing an empty box.</summary>
private string TranscriptState(RunTranscript? transcript, string agentId)
{
if (transcript is not null) return "recorded";
if (transcripts is null) return "unavailable";
return retentionFor(agentId) == PromptHistorySettings.Never ? "disabled" : "missing";
}
}
@@ -0,0 +1,224 @@
using ClawdDotNet.Core.Audit;
namespace ClawdDotNet.Core.Analytics;
/// <summary>Filter, sort order and page of <see cref="UsageAnalytics.QueryRunsAsync"/>.</summary>
public sealed record RunQuery
{
public DateTime FromUtc { get; init; }
public DateTime ToUtc { get; init; }
public IReadOnlyCollection<string> Agents { get; init; } = [];
public IReadOnlyCollection<string> Models { get; init; } = [];
public IReadOnlyCollection<string> Sources { get; init; } = [];
public IReadOnlyCollection<string> Statuses { get; init; } = [];
public decimal? MinCostUsd { get; init; }
/// <summary>Free text over run id, agent, model, task id, task title and result.</summary>
public string? Search { get; init; }
/// <summary>time, agent, model, source, status, steps, prompt, completion, tokens, cost, duration.</summary>
public string Sort { get; init; } = "time";
public bool Descending { get; init; } = true;
public int Offset { get; init; }
public int Limit { get; init; } = 50;
}
public sealed record RunRow(RunReceipt Receipt, string? TaskTitle)
{
public int TotalTokens => Receipt.PromptTokens + Receipt.CompletionTokens;
}
/// <summary>Sums over the whole filter, not only the current page.</summary>
public sealed record RunTotals(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
decimal CostUsd, int RunsWithUnknownCost, int NotRun);
public sealed record RunPage(IReadOnlyList<RunRow> Items, int Total, RunTotals Totals);
/// <summary>One stacked bar segment: <see cref="Group"/> within the bucket starting at <see cref="BucketStart"/>.</summary>
public sealed record SeriesPoint(DateTime BucketStart, string Group, int Runs, long Tokens, decimal CostUsd);
/// <summary>Something worth a marker on the time axis.</summary>
public sealed record UsageEvent(DateTime At, string Kind, string AgentId, string Text);
/// <summary>Values that occur in the range - the options of the filter dropdowns.</summary>
public sealed record UsageFacets(
IReadOnlyList<string> Agents, IReadOnlyList<string> Models,
IReadOnlyList<string> Sources, IReadOnlyList<string> Statuses);
/// <summary>
/// Usage analysis over run receipts (#660). Receipts rather than RunUsage: only they carry
/// run id, source, task and the runs that never happened (budget denials, #608).
///
/// <para>Filtering, sorting and bucketing happen in memory over the receipts of the
/// requested range. That keeps every rule in one testable place; the volume of a single
/// instance (a few hundred runs a day) is far below where this would matter. The range is
/// capped at <see cref="MaxReceipts"/> receipts.</para>
/// </summary>
public sealed class UsageAnalytics(
IAuditRepository audit,
Func<string, CancellationToken, Task<string?>>? taskTitle = null)
{
public const int MaxReceipts = 50_000;
/// <summary>Statuses of runs that were refused before any model call.</summary>
private static readonly HashSet<string> NotRunStatuses =
new(StringComparer.OrdinalIgnoreCase) { "BudgetExceeded", "ShuttingDown", "TargetBusy" };
public async Task<RunPage> QueryRunsAsync(RunQuery q, CancellationToken ct)
{
var rows = await LoadAsync(q.FromUtc, q.ToUtc, ct);
var filtered = rows.Where(r => Matches(r, q)).ToList();
var totals = new RunTotals(
filtered.Count,
filtered.Sum(r => (long)r.Receipt.PromptTokens),
filtered.Sum(r => (long)r.Receipt.CompletionTokens),
filtered.Sum(r => (long)r.Receipt.CachedTokens),
filtered.Sum(r => r.Receipt.CostUsd),
filtered.Count(r => !r.Receipt.CostIsKnown && !NotRunStatuses.Contains(r.Receipt.Status)),
filtered.Count(r => NotRunStatuses.Contains(r.Receipt.Status)));
var page = Sort(filtered, q.Sort, q.Descending)
.Skip(Math.Max(0, q.Offset))
.Take(Math.Clamp(q.Limit, 1, 500))
.ToList();
return new RunPage(page, filtered.Count, totals);
}
/// <param name="bucket"><c>hour</c> or <c>day</c>.</param>
/// <param name="groupBy"><c>agent</c>, <c>model</c> or <c>source</c>.</param>
/// <param name="utcOffset">Offset of the viewer's local time - a "day" is the viewer's day.</param>
public async Task<IReadOnlyList<SeriesPoint>> SeriesAsync(
RunQuery q, string bucket, string groupBy, TimeSpan utcOffset, CancellationToken ct)
{
var rows = (await LoadAsync(q.FromUtc, q.ToUtc, ct)).Where(r => Matches(r, q));
return rows
.GroupBy(r => (Start: BucketStart(r.Receipt.OccurredAt, bucket, utcOffset), Group: GroupKey(r.Receipt, groupBy)))
.Select(g => new SeriesPoint(
g.Key.Start, g.Key.Group, g.Count(),
g.Sum(r => (long)r.TotalTokens), g.Sum(r => r.Receipt.CostUsd)))
.OrderBy(p => p.BucketStart).ThenBy(p => p.Group, StringComparer.Ordinal)
.ToList();
}
/// <summary>Budget denials and model changes - the usual explanation for a jump in a chart.</summary>
public async Task<IReadOnlyList<UsageEvent>> EventsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var rows = await LoadAsync(fromUtc, toUtc, ct);
var events = new List<UsageEvent>();
// One marker per agent and hour is enough for a denial streak.
foreach (var g in rows.Where(r => r.Receipt.Status == "BudgetExceeded")
.GroupBy(r => (r.Receipt.AgentId, Hour: Truncate(r.Receipt.OccurredAt, TimeSpan.FromHours(1)))))
{
events.Add(new UsageEvent(g.Min(r => r.Receipt.OccurredAt), "budget_exceeded", g.Key.AgentId,
$"{g.Count()} run(s) refused: daily budget exhausted"));
}
foreach (var agent in rows.GroupBy(r => r.Receipt.AgentId))
{
string? previous = null;
foreach (var r in agent.Where(r => !string.IsNullOrWhiteSpace(r.Receipt.Model)))
{
if (previous is not null && previous != r.Receipt.Model)
events.Add(new UsageEvent(r.Receipt.OccurredAt, "model_changed", agent.Key,
$"{previous} → {r.Receipt.Model}"));
previous = r.Receipt.Model;
}
}
return events.OrderBy(e => e.At).ToList();
}
public async Task<UsageFacets> FacetsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var rows = await LoadAsync(fromUtc, toUtc, ct);
static IReadOnlyList<string> Distinct(IEnumerable<string> values)
=> values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct().Order(StringComparer.Ordinal).ToList();
return new UsageFacets(
Distinct(rows.Select(r => r.Receipt.AgentId)),
Distinct(rows.Select(r => r.Receipt.Model)),
Distinct(rows.Select(r => r.Receipt.Source)),
Distinct(rows.Select(r => r.Receipt.Status)));
}
// ─── Building blocks ───
private async Task<IReadOnlyList<RunRow>> LoadAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var receipts = await audit.ListReceiptsAsync(fromUtc, toUtc, MaxReceipts, ct);
if (taskTitle is null)
return receipts.Select(r => new RunRow(r, null)).ToList();
var titles = new Dictionary<string, string?>();
foreach (var id in receipts.Select(r => r.TaskId).OfType<string>().Distinct())
titles[id] = await taskTitle(id, ct);
return receipts.Select(r => new RunRow(r, r.TaskId is { } id ? titles.GetValueOrDefault(id) : null)).ToList();
}
internal static bool Matches(RunRow row, RunQuery q)
{
var r = row.Receipt;
if (q.Agents.Count > 0 && !q.Agents.Contains(r.AgentId)) return false;
if (q.Models.Count > 0 && !q.Models.Contains(r.Model)) return false;
if (q.Sources.Count > 0 && !q.Sources.Contains(r.Source)) return false;
if (q.Statuses.Count > 0 && !q.Statuses.Contains(r.Status)) return false;
if (q.MinCostUsd is { } min && r.CostUsd < min) return false;
if (!string.IsNullOrWhiteSpace(q.Search))
{
var s = q.Search.Trim();
bool Has(string? v) => v?.Contains(s, StringComparison.OrdinalIgnoreCase) == true;
if (!(Has(r.RunId) || Has(r.AgentId) || Has(r.Model) || Has(r.TaskId) || Has(row.TaskTitle) || Has(r.ResultRef)))
return false;
}
return true;
}
private static IEnumerable<RunRow> Sort(IEnumerable<RunRow> rows, string sort, bool descending)
{
Func<RunRow, IComparable> key = (sort ?? "time").ToLowerInvariant() switch
{
"agent" => r => r.Receipt.AgentId,
"model" => r => r.Receipt.Model,
"source" => r => r.Receipt.Source,
"status" => r => r.Receipt.Status,
"steps" => r => r.Receipt.StepCount,
"prompt" => r => r.Receipt.PromptTokens,
"completion" => r => r.Receipt.CompletionTokens,
"tokens" => r => r.TotalTokens,
"cost" => r => r.Receipt.CostUsd,
"duration" => r => r.Receipt.DurationMs,
_ => r => r.Receipt.OccurredAt
};
// Newest first as the tie-breaker, whatever the primary order.
return descending
? rows.OrderByDescending(key).ThenByDescending(r => r.Receipt.OccurredAt)
: rows.OrderBy(key).ThenByDescending(r => r.Receipt.OccurredAt);
}
internal static DateTime BucketStart(DateTime occurredUtc, string bucket, TimeSpan utcOffset)
{
var local = DateTime.SpecifyKind(occurredUtc, DateTimeKind.Utc) + utcOffset;
var start = bucket == "day"
? local.Date
: new DateTime(local.Year, local.Month, local.Day, local.Hour, 0, 0);
return DateTime.SpecifyKind(start - utcOffset, DateTimeKind.Utc);
}
private static string GroupKey(RunReceipt r, string groupBy) => groupBy switch
{
"model" => string.IsNullOrWhiteSpace(r.Model) ? "(unknown)" : r.Model,
"source" => r.Source,
_ => r.AgentId
};
private static DateTime Truncate(DateTime value, TimeSpan unit)
=> new(value.Ticks - value.Ticks % unit.Ticks, value.Kind);
}
@@ -24,6 +24,12 @@ public interface IAuditRepository
/// <summary>Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).</summary> /// <summary>Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).</summary>
Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct); Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct);
/// <summary>
/// #660: All receipts in <c>[fromUtc, toUtc)</c>, oldest first, at most
/// <paramref name="limit"/> - the input of the usage analysis.
/// </summary>
Task<IReadOnlyList<RunReceipt>> ListReceiptsAsync(DateTime fromUtc, DateTime toUtc, int limit, CancellationToken ct);
/// <summary>Zahl der Log-Einträge insgesamt.</summary> /// <summary>Zahl der Log-Einträge insgesamt.</summary>
Task<int> CountAsync(CancellationToken ct); Task<int> CountAsync(CancellationToken ct);
@@ -99,6 +99,24 @@ public sealed class SqliteAuditRepository : IAuditRepository
return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null; return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null;
} }
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsAsync(DateTime fromUtc, DateTime toUtc, int limit, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
// OccurredAt is ISO-8601 UTC ("O"), so the text comparison is a time comparison.
cmd.CommandText = "SELECT " + ReceiptColumns +
" FROM RunReceipts WHERE OccurredAt >= @from AND OccurredAt < @to ORDER BY OccurredAt LIMIT @limit";
cmd.Parameters.AddWithValue("@from", Format(fromUtc));
cmd.Parameters.AddWithValue("@to", Format(toUtc));
cmd.Parameters.AddWithValue("@limit", limit);
var results = new List<RunReceipt>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(ReadReceipt(reader));
return results;
}
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct) public async Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
{ {
await using var conn = await _storage.OpenConnectionAsync(ct); await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -145,6 +145,12 @@ public sealed class AgentConfig
[JsonPropertyName("budget")] [JsonPropertyName("budget")]
public AgentBudget Budget { get; set; } = new(); public AgentBudget Budget { get; set; } = new();
/// <summary>
/// #660: Per-agent retention of the prompt history. <c>null</c> retention = instance default.
/// </summary>
[JsonPropertyName("promptHistory")]
public Transcripts.AgentPromptHistorySettings PromptHistory { get; set; } = new();
/// <summary> /// <summary>
/// Übersetzt <see cref="ReasoningEffort"/> in das Anfragefeld. Null heißt: /// Übersetzt <see cref="ReasoningEffort"/> in das Anfragefeld. Null heißt:
/// nichts mitschicken, das Modell denkt wie voreingestellt. /// nichts mitschicken, das Modell denkt wie voreingestellt.
@@ -124,6 +124,13 @@ public sealed class InstanceConfig
[JsonPropertyName("budget")] [JsonPropertyName("budget")]
public InstanceBudget Budget { get; set; } = new(); public InstanceBudget Budget { get; set; } = new();
/// <summary>
/// #660: How long prompt and response of every run are kept. Default for all agents;
/// each agent can override it.
/// </summary>
[JsonPropertyName("promptHistory")]
public Transcripts.PromptHistorySettings PromptHistory { get; set; } = new();
/// <summary> /// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8, #206). /// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8, #206).
/// A→B ist die Tiefe 1. Zyklen fängt die Kette selbst ab; dieser Wert begrenzt die /// A→B ist die Tiefe 1. Zyklen fängt die Kette selbst ab; dieser Wert begrenzt die
+66 -4
View File
@@ -127,6 +127,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
public event Action<string, string>? OnStepCompleted; public event Action<string, string>? OnStepCompleted;
/// <summary>#660: Where prompt and response of every run are kept. <c>null</c> = not at all.</summary>
public Transcripts.IRunTranscriptStore? TranscriptStore { get; init; }
/// <summary>
/// #660: Instance-wide retention in days (0 = never, -1 = forever). A function, so a
/// changed setting applies to the next run without a restart.
/// </summary>
public Func<int> DefaultTranscriptRetention { get; init; } = () => Transcripts.PromptHistorySettings.DefaultRetentionDays;
/// <summary> /// <summary>
/// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source). /// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source).
/// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen. /// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen.
@@ -161,9 +170,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var runId = Guid.NewGuid().ToString("N"); var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId); var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain); var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
await RecordUsageAsync(agentConfig, result); await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId); await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result; return result;
} }
@@ -174,7 +185,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
CancellationToken externalCt, CancellationToken externalCt,
string runId, string runId,
string? source, string? source,
DelegationChain chain) DelegationChain chain,
Transcripts.RunTranscriptRecorder recorder)
{ {
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}"); var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard); var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -205,6 +217,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages.Add(ChatMessage.System(systemPrompt)); messages.Add(ChatMessage.System(systemPrompt));
messages.Add(ChatMessage.User(MitZeit(userMessage, source))); messages.Add(ChatMessage.User(MitZeit(userMessage, source)));
recorder.Begin(systemPrompt, messages[^1].Content?.ToString() ?? userMessage);
string? finalMessage = null; string? finalMessage = null;
var truncated = false; var truncated = false;
@@ -229,6 +242,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// sie bezahlt ist statt danach. // sie bezahlt ist statt danach.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add)) if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{ {
recorder.Event("Context compacted");
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)"); OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
} }
@@ -249,6 +263,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
} }
var choice = response.Choices.FirstOrDefault(); var choice = response.Choices.FirstOrDefault();
if (choice is not null)
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
if (choice is null) if (choice is null)
{ {
finalMessage = "[No response from model]"; finalMessage = "[No response from model]";
@@ -284,6 +300,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
finally finally
{ {
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults); AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
} }
if (abbruch is not null) if (abbruch is not null)
@@ -304,6 +321,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst", logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
agentConfig.AgentId, loopGuard.Steps); agentConfig.AgentId, loopGuard.Steps);
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage)); messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
continue; continue;
} }
@@ -314,6 +332,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
agentConfig.AgentId); agentConfig.AgentId);
messages.Add(ChatMessage.Assistant(text)); messages.Add(ChatMessage.Assistant(text));
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage)); messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
continue; continue;
} }
@@ -468,9 +487,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try try
{ {
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain); var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
await RecordUsageAsync(agentConfig, result); await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId); await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
return result; return result;
} }
finally finally
@@ -487,7 +508,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
CancellationToken runCt, CancellationToken runCt,
string? source, string? source,
string runId, string runId,
DelegationChain chain) DelegationChain chain,
Transcripts.RunTranscriptRecorder recorder)
{ {
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}"); var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard); var loopGuard = new LoopGuard(agentConfig.LoopGuard);
@@ -532,6 +554,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages.Add(ChatMessage.User(MitZeit(routedMessage, source))); messages.Add(ChatMessage.User(MitZeit(routedMessage, source)));
AddChatEntry(agentConfig.AgentId, "user", userMessage, source); AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
recorder.Begin(
messages.Count > 0 && messages[0].Role == "system" ? messages[0].Content?.ToString() : null,
messages[^1].Content?.ToString() ?? userMessage);
string? finalMessage = null; string? finalMessage = null;
var truncated = false; var truncated = false;
@@ -557,6 +582,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// nächsten Schritt. Genau ein Schritt zu spät, und der teuerste. // nächsten Schritt. Genau ein Schritt zu spät, und der teuerste.
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add)) if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
{ {
recorder.Event("Context compacted");
PersistChatState(agentConfig.AgentId); PersistChatState(agentConfig.AgentId);
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert"); OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
} }
@@ -581,6 +607,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
} }
var choice = response.Choices.FirstOrDefault(); var choice = response.Choices.FirstOrDefault();
if (choice is not null)
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
if (choice is null) if (choice is null)
{ {
finalMessage = "[No response from model]"; finalMessage = "[No response from model]";
@@ -614,6 +642,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
finally finally
{ {
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults); AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
} }
if (abbruch is not null) if (abbruch is not null)
@@ -636,6 +665,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst", logger.LogWarning("Leere Antwort von {AgentId} nach {Steps} Schritten - einmal nachgefasst",
agentConfig.AgentId, loopGuard.Steps); agentConfig.AgentId, loopGuard.Steps);
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage)); messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
continue; continue;
} }
@@ -647,6 +677,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages.Add(ChatMessage.Assistant(text)); messages.Add(ChatMessage.Assistant(text));
AddChatEntry(agentConfig.AgentId, "assistant", text, source); AddChatEntry(agentConfig.AgentId, "assistant", text, source);
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage)); messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
continue; continue;
} }
@@ -1746,6 +1777,37 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary> /// <summary>
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit. /// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
/// </summary> /// </summary>
// ─── Prompt history (#660) ───
/// <summary>Retention that applies to this agent: its own setting or the instance default.</summary>
public int TranscriptRetentionFor(AgentConfig agentConfig)
=> Transcripts.PromptHistorySettings.Resolve(agentConfig.PromptHistory?.RetentionDays, DefaultTranscriptRetention());
private Transcripts.RunTranscriptRecorder CreateRecorder(AgentConfig agentConfig)
=> TranscriptStore is not null && TranscriptRetentionFor(agentConfig) != Transcripts.PromptHistorySettings.Never
? new Transcripts.RunTranscriptRecorder(enabled: true, _clock)
: Transcripts.RunTranscriptRecorder.Disabled;
/// <summary>Best effort: a transcript that cannot be written must not fail the run.</summary>
private async Task SaveTranscriptAsync(
Transcripts.RunTranscriptRecorder recorder, string runId, AgentConfig agentConfig,
AgentRunResult result, string source, string? taskId)
{
if (!recorder.Enabled || TranscriptStore is null)
return;
try
{
var transcript = recorder.Build(runId, agentConfig.AgentId, agentConfig.Model, source, taskId, result.Status.ToString());
await TranscriptStore.SaveAsync(transcript, recorder.SystemPrompt, CancellationToken.None);
}
catch (Exception ex)
{
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Transcripts")
.LogWarning(ex, "Transcript of run {RunId} ({AgentId}) could not be stored", runId, agentConfig.AgentId);
}
}
private async Task RecordReceiptAsync( private async Task RecordReceiptAsync(
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId) string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
{ {
@@ -122,6 +122,20 @@ public sealed class SqliteStagingRepository : IStagingRepository
return results; return results;
} }
public async Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE RunId = @runId ORDER BY Id";
cmd.Parameters.AddWithValue("@runId", runId);
var results = new List<StagedCall>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
results.Add(Read(reader));
return results;
}
public async Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct) public async Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
{ {
await using var conn = await _storage.OpenConnectionAsync(ct); await using var conn = await _storage.OpenConnectionAsync(ct);
@@ -113,4 +113,7 @@ public interface IStagingRepository
/// ausgefuehrt wurde (#641) - Grundlage fuer den Vorschlag "Gleichartiges kuenftig erlauben". /// ausgefuehrt wurde (#641) - Grundlage fuer den Vorschlag "Gleichartiges kuenftig erlauben".
/// </summary> /// </summary>
Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct); Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct);
/// <summary>#660: Everything a run proposed for approval - for the run detail view.</summary>
Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct);
} }
@@ -93,6 +93,7 @@ public sealed class StagingService
} }
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct); public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct); public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
public Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct) => _repo.ListForRunAsync(runId, ct);
// ─── Dauerfreigaben (#641) ─── // ─── Dauerfreigaben (#641) ───
@@ -288,6 +288,37 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
ON Dauerfreigaben (AgentId, Tool, Operation); 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(); cmd.ExecuteNonQuery();
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Transcripts;
/// <summary>
/// How long run transcripts (prompt and response of every run) are kept (#660).
///
/// <para>One number of days covers the whole range the user asked for:
/// <see cref="Never"/> (0) records nothing, <see cref="Forever"/> (-1) keeps everything,
/// any positive value keeps that many days.</para>
/// </summary>
public sealed class PromptHistorySettings
{
public const int Never = 0;
public const int Forever = -1;
public const int DefaultRetentionDays = 14;
/// <summary>Instance default for all agents without their own setting.</summary>
[JsonPropertyName("retentionDays")]
public int RetentionDays { get; set; } = DefaultRetentionDays;
/// <summary>
/// The retention that applies to an agent: its own value if set, otherwise the
/// instance default. Values below <see cref="Forever"/> are treated as "forever".
/// </summary>
public static int Resolve(int? agentRetentionDays, int instanceRetentionDays)
=> Normalize(agentRetentionDays ?? instanceRetentionDays);
public static int Normalize(int days) => days < Forever ? Forever : days;
/// <summary>Human readable form for logs and the UI fallback.</summary>
public static string Describe(int days) => Normalize(days) switch
{
Never => "never recorded",
Forever => "kept forever",
var d => $"kept for {d} day(s)"
};
}
/// <summary>
/// Per-agent override (#660): debug a single agent over a long period, or switch
/// recording off completely for an agent that handles sensitive secrets.
/// </summary>
public sealed class AgentPromptHistorySettings
{
/// <summary><c>null</c> = use the instance default.</summary>
[JsonPropertyName("retentionDays")]
public int? RetentionDays { get; set; }
}
@@ -0,0 +1,103 @@
namespace ClawdDotNet.Core.Transcripts;
/// <summary>What a transcript entry is.</summary>
public static class TranscriptEntryKind
{
/// <summary>The task or message the run started with.</summary>
public const string User = "user";
/// <summary>A follow-up the engine itself sent (empty answer, missing result line).</summary>
public const string EngineNote = "engine";
/// <summary>One model response: text and/or tool calls, with the tokens of this step.</summary>
public const string Assistant = "assistant";
/// <summary>The result of one tool call.</summary>
public const string ToolResult = "tool_result";
/// <summary>Something that happened to the context, e.g. compaction.</summary>
public const string Event = "event";
}
/// <summary>One tool call as the model issued it.</summary>
public sealed record TranscriptToolCall(string Id, string Name, string Arguments);
/// <summary>
/// One entry of a run transcript. Texts are stored as the model saw or wrote them, after
/// secret scrubbing and clipping (<see cref="Clipped"/>).
/// </summary>
public sealed record TranscriptEntry
{
public string Kind { get; init; } = "";
public DateTime At { get; init; }
public string? Text { get; init; }
public IReadOnlyList<TranscriptToolCall>? ToolCalls { get; init; }
/// <summary>For <see cref="TranscriptEntryKind.ToolResult"/>: which call and tool.</summary>
public string? ToolCallId { get; init; }
public string? ToolName { get; init; }
/// <summary>For <see cref="TranscriptEntryKind.Assistant"/>: usage of this single step.</summary>
public int? PromptTokens { get; init; }
public int? CompletionTokens { get; init; }
public int? CachedTokens { get; init; }
public decimal? CostUsd { get; init; }
public string? FinishReason { get; init; }
/// <summary>The text was shortened to stay within the limits.</summary>
public bool Clipped { get; init; }
}
/// <summary>
/// Prompt and response of one run (#660), joined to receipts and audit via
/// <see cref="RunId"/>. The system prompt is stored once per distinct text and referenced
/// by <see cref="SystemPromptHash"/>.
/// </summary>
public sealed record RunTranscript
{
public string RunId { get; init; } = "";
public string AgentId { get; init; } = "";
public string Model { get; init; } = "";
public string Source { get; init; } = "";
public string? TaskId { get; init; }
public DateTime StartedAt { get; init; }
public DateTime? CompletedAt { get; init; }
public string Status { get; init; } = "";
public string? SystemPromptHash { get; init; }
public IReadOnlyList<TranscriptEntry> Entries { get; init; } = [];
/// <summary>Characters stored, after clipping.</summary>
public int SizeChars { get; init; }
/// <summary>The run exceeded the size limit; later entries are shortened or omitted.</summary>
public bool Truncated { get; init; }
}
/// <summary>A transcript without its entries — for lists.</summary>
public sealed record RunTranscriptSummary(
string RunId,
string AgentId,
string Model,
string Source,
string? TaskId,
DateTime StartedAt,
DateTime? CompletedAt,
string Status,
string Prompt,
string Response,
int SizeChars,
bool Truncated);
/// <summary>Filter for <see cref="IRunTranscriptStore.ListAsync"/>.</summary>
public sealed record TranscriptQuery
{
public string? AgentId { get; init; }
public DateTime? From { get; init; }
public DateTime? To { get; init; }
/// <summary>Full text over the whole transcript.</summary>
public string? Search { get; init; }
public int Limit { get; init; } = 50;
public int Offset { get; init; }
}
@@ -0,0 +1,212 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using ClawdDotNet.Core.Api.Models;
namespace ClawdDotNet.Core.Transcripts;
/// <summary>
/// Collects the transcript of one run while it happens (#660). The engine calls it at the
/// few points where something enters or leaves the conversation; nothing here talks to
/// the model or the database.
///
/// <para>Recording is bounded: tool results are clipped to <see cref="MaxToolResultChars"/>,
/// any single text to <see cref="MaxTextChars"/>, and the whole transcript to
/// <see cref="MaxTotalChars"/>. Beyond that only the entry heads are kept (who called what,
/// how many tokens), which is what an analysis of a runaway run needs anyway.</para>
///
/// <para>A disabled recorder (<see cref="Disabled"/>) accepts every call and keeps
/// nothing, so the engine never has to branch.</para>
/// </summary>
public sealed class RunTranscriptRecorder
{
public const int MaxToolResultChars = 4_000;
public const int MaxTextChars = 32_000;
public const int MaxTotalChars = 256_000;
private readonly Lock _lock = new();
private readonly List<TranscriptEntry> _entries = [];
private readonly TimeProvider _clock;
private int _size;
private bool _truncated;
public static RunTranscriptRecorder Disabled { get; } = new(enabled: false);
public bool Enabled { get; }
public string? SystemPrompt { get; private set; }
public DateTime StartedAt { get; }
public RunTranscriptRecorder(bool enabled = true, TimeProvider? clock = null)
{
Enabled = enabled;
_clock = clock ?? TimeProvider.System;
StartedAt = _clock.GetUtcNow().UtcDateTime;
}
// ─── Engine hooks ───
public void Begin(string? systemPrompt, string userMessage)
{
if (!Enabled) return;
SystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? null : SecretScrubber.Scrub(systemPrompt);
Add(TranscriptEntryKind.User, userMessage, MaxTextChars);
}
/// <summary>A message the engine sent on its own (follow-up after an empty answer etc.).</summary>
public void EngineNote(string text)
{
if (!Enabled) return;
Add(TranscriptEntryKind.EngineNote, text, MaxTextChars);
}
public void Event(string text)
{
if (!Enabled) return;
Add(TranscriptEntryKind.Event, text, MaxTextChars);
}
/// <summary>One model response with the usage of exactly this request.</summary>
public void ModelStep(ChatMessage response, Usage? usage, string? finishReason)
{
if (!Enabled) return;
var calls = response.ToolCalls?
.Select(c => new TranscriptToolCall(
c.Id, c.Function.Name, Clip(SecretScrubber.Scrub(c.Function.Arguments ?? ""), MaxTextChars, out _)))
.ToList();
var (text, clipped) = Prepare(response.Content, MaxTextChars);
lock (_lock)
{
var groesse = (text?.Length ?? 0) + (calls?.Sum(c => c.Arguments.Length + c.Name.Length) ?? 0);
var ueber = _size + groesse > MaxTotalChars;
if (ueber) _truncated = true;
_entries.Add(new TranscriptEntry
{
Kind = TranscriptEntryKind.Assistant,
At = _clock.GetUtcNow().UtcDateTime,
Text = ueber ? null : text,
ToolCalls = ueber ? calls?.Select(c => c with { Arguments = "" }).ToList() : calls,
PromptTokens = usage?.PromptTokens,
CompletionTokens = usage?.CompletionTokens,
CachedTokens = usage?.CachedTokens,
CostUsd = usage?.Cost,
FinishReason = finishReason,
Clipped = clipped || ueber
});
if (!ueber) _size += groesse;
}
}
public void ToolResults(IReadOnlyList<ToolCall> calls, IReadOnlyList<string?> results)
{
if (!Enabled) return;
for (var i = 0; i < calls.Count; i++)
{
Add(TranscriptEntryKind.ToolResult, results.ElementAtOrDefault(i) ?? "(no result)", MaxToolResultChars,
calls[i].Id, calls[i].Function.Name);
}
}
// ─── Result ───
public RunTranscript Build(string runId, string agentId, string model, string source, string? taskId, string status)
{
lock (_lock)
{
return new RunTranscript
{
RunId = runId,
AgentId = agentId,
Model = model,
Source = source,
TaskId = taskId,
StartedAt = StartedAt,
CompletedAt = _clock.GetUtcNow().UtcDateTime,
Status = status,
SystemPromptHash = SystemPrompt is null ? null : Hash(SystemPrompt),
Entries = _entries.ToList(),
SizeChars = _size,
Truncated = _truncated
};
}
}
public static string Hash(string text)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..32].ToLowerInvariant();
// ─── Helpers ───
private void Add(string kind, string? text, int max, string? toolCallId = null, string? toolName = null)
{
var (prepared, clipped) = Prepare(text, max);
lock (_lock)
{
var ueber = _size + (prepared?.Length ?? 0) > MaxTotalChars;
if (ueber) _truncated = true;
_entries.Add(new TranscriptEntry
{
Kind = kind,
At = _clock.GetUtcNow().UtcDateTime,
Text = ueber ? null : prepared,
ToolCallId = toolCallId,
ToolName = toolName,
Clipped = clipped || ueber
});
if (!ueber) _size += prepared?.Length ?? 0;
}
}
private static (string? Text, bool Clipped) Prepare(string? text, int max)
{
if (string.IsNullOrEmpty(text)) return (text, false);
var clipped = Clip(SecretScrubber.Scrub(text), max, out var wasClipped);
return (clipped, wasClipped);
}
/// <summary>Keeps start and end — the end of a tool result is often the interesting part.</summary>
internal static string Clip(string text, int max, out bool clipped)
{
clipped = text.Length > max;
if (!clipped) return text;
var head = max * 3 / 4;
var tail = max - head;
return text[..head] + $"\n[… {text.Length - max} characters omitted …]\n" + text[^tail..];
}
}
/// <summary>
/// Removes the kinds of secrets that can end up in prompts or tool results before a
/// transcript is stored. Deliberately conservative: it targets recognisable secret
/// formats, not arbitrary text.
/// </summary>
public static partial class SecretScrubber
{
public const string Mask = "«redacted»";
public static string Scrub(string text)
{
if (string.IsNullOrEmpty(text)) return text;
text = ApiKey().Replace(text, Mask);
text = Bearer().Replace(text, "Bearer " + Mask);
text = PasswordAssignment().Replace(text, m => m.Groups["key"].Value + m.Groups["sep"].Value + Mask);
text = LicenseKey().Replace(text, Mask);
return text;
}
[GeneratedRegex(@"\b(?:sk-or-v1-|sk-or-|sk-ant-|sk-proj-|sk-|ghp_|gho_|github_pat_|xox[baprs]-)[A-Za-z0-9_\-]{16,}")]
private static partial Regex ApiKey();
[GeneratedRegex(@"Bearer\s+[A-Za-z0-9._~+/\-]{16,}=*", RegexOptions.IgnoreCase)]
private static partial Regex Bearer();
[GeneratedRegex(@"(?<key>\b(?:password|passwort|pwd|secret|api[_-]?key|token)\b)(?<sep>\s*[=:]\s*""?)[^;\s""',}]+", RegexOptions.IgnoreCase)]
private static partial Regex PasswordAssignment();
[GeneratedRegex(@"\b[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4}\b")]
private static partial Regex LicenseKey();
}
@@ -0,0 +1,211 @@
using System.Globalization;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Transcripts;
public interface IRunTranscriptStore
{
Task SaveAsync(RunTranscript transcript, string? systemPrompt, CancellationToken ct);
Task<RunTranscript?> GetAsync(string runId, CancellationToken ct);
Task<string?> GetSystemPromptAsync(string hash, CancellationToken ct);
Task<(IReadOnlyList<RunTranscriptSummary> Items, int Total)> ListAsync(TranscriptQuery query, CancellationToken ct);
/// <summary>
/// Deletes what is older than each agent's retention allows. <paramref name="retentionFor"/>
/// returns the resolved retention in days for an agent id (0 = delete all, -1 = keep).
/// </summary>
Task<int> PurgeAsync(Func<string, int> retentionFor, DateTime now, CancellationToken ct);
}
/// <summary>
/// Run transcripts in the instance database (#660). Entries are one JSON column per run:
/// a transcript is always read and written as a whole, and the full-text search over
/// it is a plain LIKE — enough for the volumes of a single instance.
/// </summary>
public sealed class SqliteRunTranscriptStore(SqliteStorage storage) : IRunTranscriptStore
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
// Unescaped, so the LIKE search finds "Grüße" as typed. Never rendered as HTML.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private const string SummaryColumns =
"RunId, AgentId, Model, Source, TaskId, StartedAt, CompletedAt, Status, Prompt, Response, SizeChars, Truncated";
public Task SaveAsync(RunTranscript t, string? systemPrompt, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
if (t.SystemPromptHash is not null && systemPrompt is not null)
{
using var sp = conn.CreateCommand();
sp.CommandText = """
INSERT OR IGNORE INTO SystemPrompts (Hash, Text, FirstSeenAt) VALUES (@hash, @text, @at)
""";
sp.Parameters.AddWithValue("@hash", t.SystemPromptHash);
sp.Parameters.AddWithValue("@text", systemPrompt);
sp.Parameters.AddWithValue("@at", Format(t.StartedAt));
await sp.ExecuteNonQueryAsync(ct);
}
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO RunTranscripts
(RunId, AgentId, Model, Source, TaskId, StartedAt, CompletedAt, Status,
SystemPromptHash, Prompt, Response, EntriesJson, SizeChars, Truncated)
VALUES
(@runId, @agent, @model, @source, @task, @started, @completed, @status,
@hash, @prompt, @response, @entries, @size, @truncated)
""";
cmd.Parameters.AddWithValue("@runId", t.RunId);
cmd.Parameters.AddWithValue("@agent", t.AgentId);
cmd.Parameters.AddWithValue("@model", t.Model);
cmd.Parameters.AddWithValue("@source", t.Source);
cmd.Parameters.AddWithValue("@task", (object?)t.TaskId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@started", Format(t.StartedAt));
cmd.Parameters.AddWithValue("@completed", t.CompletedAt is { } c ? Format(c) : DBNull.Value);
cmd.Parameters.AddWithValue("@status", t.Status);
cmd.Parameters.AddWithValue("@hash", (object?)t.SystemPromptHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@prompt", Snippet(t.Entries.FirstOrDefault(e => e.Kind == TranscriptEntryKind.User)?.Text));
cmd.Parameters.AddWithValue("@response", Snippet(t.Entries.LastOrDefault(e => e.Kind == TranscriptEntryKind.Assistant && !string.IsNullOrWhiteSpace(e.Text))?.Text));
cmd.Parameters.AddWithValue("@entries", JsonSerializer.Serialize(t.Entries, Json));
cmd.Parameters.AddWithValue("@size", t.SizeChars);
cmd.Parameters.AddWithValue("@truncated", t.Truncated ? 1 : 0);
await cmd.ExecuteNonQueryAsync(ct);
return true;
}, ct);
public async Task<RunTranscript?> GetAsync(string runId, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT RunId, AgentId, Model, Source, TaskId, StartedAt, CompletedAt, Status,
SystemPromptHash, EntriesJson, SizeChars, Truncated
FROM RunTranscripts WHERE RunId = @runId
""";
cmd.Parameters.AddWithValue("@runId", runId);
await using var r = await cmd.ExecuteReaderAsync(ct);
if (!await r.ReadAsync(ct)) return null;
return new RunTranscript
{
RunId = r.GetString(0),
AgentId = r.GetString(1),
Model = r.GetString(2),
Source = r.GetString(3),
TaskId = r.IsDBNull(4) ? null : r.GetString(4),
StartedAt = Parse(r.GetString(5)),
CompletedAt = r.IsDBNull(6) ? null : Parse(r.GetString(6)),
Status = r.GetString(7),
SystemPromptHash = r.IsDBNull(8) ? null : r.GetString(8),
Entries = JsonSerializer.Deserialize<List<TranscriptEntry>>(r.GetString(9), Json) ?? [],
SizeChars = r.GetInt32(10),
Truncated = r.GetInt32(11) != 0
};
}
public async Task<string?> GetSystemPromptAsync(string hash, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Text FROM SystemPrompts WHERE Hash = @hash";
cmd.Parameters.AddWithValue("@hash", hash);
return await cmd.ExecuteScalarAsync(ct) as string;
}
public async Task<(IReadOnlyList<RunTranscriptSummary> Items, int Total)> ListAsync(TranscriptQuery q, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
var where = new List<string>();
void Bind(SqliteCommand c)
{
if (!string.IsNullOrWhiteSpace(q.AgentId)) c.Parameters.AddWithValue("@agent", q.AgentId);
if (q.From is { } f) c.Parameters.AddWithValue("@from", Format(f));
if (q.To is { } t) c.Parameters.AddWithValue("@to", Format(t));
if (!string.IsNullOrWhiteSpace(q.Search)) c.Parameters.AddWithValue("@q", "%" + Escape(q.Search.Trim()) + "%");
}
if (!string.IsNullOrWhiteSpace(q.AgentId)) where.Add("AgentId = @agent");
if (q.From is not null) where.Add("StartedAt >= @from");
if (q.To is not null) where.Add("StartedAt < @to");
if (!string.IsNullOrWhiteSpace(q.Search)) where.Add("EntriesJson LIKE @q ESCAPE '\\'");
var filter = where.Count == 0 ? "" : " WHERE " + string.Join(" AND ", where);
using var count = conn.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM RunTranscripts" + filter;
Bind(count);
var total = Convert.ToInt32(await count.ExecuteScalarAsync(ct));
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT {SummaryColumns} FROM RunTranscripts{filter} ORDER BY StartedAt DESC LIMIT @limit OFFSET @offset";
Bind(cmd);
cmd.Parameters.AddWithValue("@limit", Math.Clamp(q.Limit, 1, 500));
cmd.Parameters.AddWithValue("@offset", Math.Max(0, q.Offset));
var items = new List<RunTranscriptSummary>();
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
{
items.Add(new RunTranscriptSummary(
r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3),
r.IsDBNull(4) ? null : r.GetString(4),
Parse(r.GetString(5)),
r.IsDBNull(6) ? null : Parse(r.GetString(6)),
r.GetString(7), r.GetString(8), r.GetString(9), r.GetInt32(10), r.GetInt32(11) != 0));
}
return (items, total);
}
public Task<int> PurgeAsync(Func<string, int> retentionFor, DateTime now, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
var agents = new List<string>();
using (var list = conn.CreateCommand())
{
list.CommandText = "SELECT DISTINCT AgentId FROM RunTranscripts";
await using var r = await list.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct)) agents.Add(r.GetString(0));
}
var deleted = 0;
foreach (var agent in agents)
{
var days = PromptHistorySettings.Normalize(retentionFor(agent));
if (days == PromptHistorySettings.Forever) continue;
using var del = conn.CreateCommand();
del.CommandText = "DELETE FROM RunTranscripts WHERE AgentId = @agent AND StartedAt < @cutoff";
del.Parameters.AddWithValue("@agent", agent);
// Never (0): everything goes, including what was recorded before the switch.
del.Parameters.AddWithValue("@cutoff", Format(days == PromptHistorySettings.Never ? DateTime.MaxValue : now.AddDays(-days)));
deleted += await del.ExecuteNonQueryAsync(ct);
}
using var orphans = conn.CreateCommand();
orphans.CommandText = """
DELETE FROM SystemPrompts
WHERE Hash NOT IN (SELECT SystemPromptHash FROM RunTranscripts WHERE SystemPromptHash IS NOT NULL)
""";
await orphans.ExecuteNonQueryAsync(ct);
return deleted;
}, ct);
private static string Snippet(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return "";
var t = text.Trim();
return t.Length <= 300 ? t : t[..300] + "…";
}
private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
private static string Format(DateTime value)
=> (value == DateTime.MaxValue ? value : value.ToUniversalTime()).ToString("O", CultureInfo.InvariantCulture);
private static DateTime Parse(string value)
=> DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime();
}
@@ -0,0 +1,58 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Transcripts;
/// <summary>
/// Enforces the prompt history retention (#660): once at start and then every hour,
/// transcripts older than their agent's retention are deleted. An agent switched to
/// "never" loses everything recorded before the switch — that is the point of the switch.
/// </summary>
public sealed class TranscriptJanitor(
IRunTranscriptStore store,
Func<string, int> retentionFor,
ILogger logger,
TimeProvider? clock = null,
TimeSpan? interval = null) : IAsyncDisposable
{
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
public void Start() => _loop ??= RunAsync(_cts.Token);
public async Task<int> PurgeOnceAsync(CancellationToken ct)
{
var deleted = await store.PurgeAsync(retentionFor, _clock.GetUtcNow().UtcDateTime, ct);
if (deleted > 0)
logger.LogInformation("Prompt history: {Count} transcript(s) past their retention deleted", deleted);
return deleted;
}
private async Task RunAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(interval ?? TimeSpan.FromHours(1), _clock);
do
{
try
{
await PurgeOnceAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Prompt history cleanup failed");
}
}
while (await timer.WaitForNextTickAsync(ct));
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { }
}
_cts.Dispose();
}
}
+306
View File
@@ -1415,3 +1415,309 @@ button.klein {
margin-top: 10px; margin-top: 10px;
font-size: 14px; font-size: 14px;
} }
/* ─── Token usage analysis and prompt history (#660) ─── */
.nav-unterlink {
padding-left: 42px;
font-size: 13px;
}
.seitenleiste.kollabiert .nav-unterlink {
display: none;
}
.filter-leiste {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px 14px;
margin: 12px 0 16px;
}
.filter-feld {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--gedaempft);
}
.filter-feld select,
.filter-feld input {
min-width: 140px;
}
.filter-suche input {
min-width: 220px;
}
.filter-eigen {
display: flex;
align-items: center;
gap: 6px;
}
.filter-aktionen {
display: flex;
gap: 6px;
}
.kennzahlen-raster {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.kennzahl {
border: 1px solid var(--divider);
padding: 10px 12px;
}
.kennzahl-wert {
font-size: 20px;
font-weight: 600;
}
.kennzahl-titel {
font-size: 12px;
color: var(--gedaempft);
}
.tabelle-scroll,
.diagramm-scroll {
overflow-x: auto;
}
.daten-tabelle td.zahl,
.daten-tabelle th.zahl {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sortier-knopf {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-transform: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.klickbar {
cursor: pointer;
}
.klickbar:focus-visible {
outline: 2px solid var(--accent);
}
.blaettern {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
}
.zurueck-link {
display: inline-block;
margin-bottom: 8px;
}
.diagramm-steuerung {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin-bottom: 12px;
}
.zeitverlauf .raster {
stroke: var(--divider);
stroke-width: 1;
}
.zeitverlauf .achse {
fill: var(--gedaempft);
font-size: 11px;
}
.zeitverlauf .balken {
cursor: pointer;
}
.zeitverlauf .balken:hover,
.zeitverlauf .balken:focus {
opacity: 0.8;
outline: none;
}
.zeitverlauf .ereignis line {
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
.zeitverlauf .ereignis-budget line,
.zeitverlauf .ereignis-budget path {
stroke: var(--gefahr);
fill: var(--gefahr);
}
.zeitverlauf .ereignis-modell line,
.zeitverlauf .ereignis-modell path {
stroke: var(--accent);
fill: var(--accent);
}
.legende {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 10px;
font-size: 13px;
}
.legende-eintrag {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legende-farbe {
width: 12px;
height: 12px;
display: inline-block;
}
.legende-marke {
width: 0;
height: 12px;
display: inline-block;
border-left: 2px dashed;
}
.legende-marke.ereignis-budget {
border-color: var(--gefahr);
}
.legende-marke.ereignis-modell {
border-color: var(--accent);
}
.transkript-system {
margin-bottom: 12px;
}
.transkript-eintrag {
border-left: 3px solid var(--divider);
padding: 6px 0 6px 12px;
margin: 10px 0;
}
.transkript-eintrag.art-user { border-left-color: var(--accent); }
.transkript-eintrag.art-assistant { border-left-color: var(--erfolg); }
.transkript-eintrag.art-tool_result { border-left-color: var(--warnung); }
.transkript-eintrag.art-engine,
.transkript-eintrag.art-event { border-left-color: var(--gedaempft); }
.transkript-kopf {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.transkript-text {
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow-y: auto;
margin: 4px 0;
}
.transkript-aufruf {
margin-top: 6px;
}
.schlichte-liste {
list-style: none;
padding: 0;
margin: 0;
}
.schlichte-liste li {
padding: 4px 0;
}
.verlauf-eintrag {
border: 1px solid var(--divider);
padding: 10px 12px;
margin-bottom: 10px;
}
.verlauf-eintrag:hover {
background: var(--surface-hover);
}
.verlauf-kopf {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.verlauf-zeile {
display: flex;
gap: 10px;
font-size: 14px;
margin: 2px 0;
}
.verlauf-marke {
flex: 0 0 80px;
color: var(--gedaempft);
font-size: 12px;
text-transform: uppercase;
}
.verlauf-text {
flex: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
word-break: break-word;
}
.aufbewahrung input[type="number"] {
width: 80px;
}
@media (max-width: 768px) {
.filter-feld select,
.filter-feld input,
.filter-suche input {
min-width: 0;
width: 100%;
}
.filter-feld {
flex: 1 1 140px;
}
.verlauf-zeile {
flex-direction: column;
gap: 2px;
}
}
span.klein,
div.klein,
td.klein {
font-size: 12px;
}
@@ -30,6 +30,11 @@ import { rendereInstanzen, stoppePolling as stoppeInstanzenPolling } from "./vie
import { rendereEinstellungen } from "./views/settings.js"; import { rendereEinstellungen } from "./views/settings.js";
import { rendereSicherung } from "./views/backup.js"; import { rendereSicherung } from "./views/backup.js";
import { rendereAgentenChats, trenneAgentenChatsStream } from "./views/agent-chats.js"; import { rendereAgentenChats, trenneAgentenChatsStream } from "./views/agent-chats.js";
import { loadLanguage, translateDocument } from "./modules/i18n.js";
import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
// ─── 1. Routen registrieren ─── // ─── 1. Routen registrieren ───
@@ -48,6 +53,12 @@ registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
// #660: token usage sub-views and the prompt history
registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsageRuns(); });
registriereRoute("/verbrauch/runs/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/verbrauch/runs"); });
registriereRoute("/verbrauch/timeline", () => { verlasseAnsicht(); return renderUsageTimeline(); });
registriereRoute("/prompt-history", () => { verlasseAnsicht(); return renderPromptHistory(); });
registriereRoute("/prompt-history/:runId", (p) => { verlasseAnsicht(); return renderRunDetail(p.runId, "/prompt-history"); });
registriereRoute("/agenten-chats", () => { verlasseAnsicht(); return rendereAgentenChats(); }); registriereRoute("/agenten-chats", () => { verlasseAnsicht(); return rendereAgentenChats(); });
registriereRoute("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); }); registriereRoute("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); });
registriereRoute("/sicherung", () => { verlasseAnsicht(); return rendereSicherung(); }); registriereRoute("/sicherung", () => { verlasseAnsicht(); return rendereSicherung(); });
@@ -352,6 +363,10 @@ async function starteHauptAnwendung(instanzen) {
} }
}); });
// #661: language packs before the first render
await loadLanguage();
translateDocument();
// Router initialisieren // Router initialisieren
initialisiereRouter(); initialisiereRouter();
} }
@@ -0,0 +1,141 @@
{
"nav.usage.runs": "Läufe",
"nav.usage.timeline": "Zeitverlauf",
"nav.promptHistory": "Prompt-Verlauf",
"common.loading": "Lade…",
"common.previous": "Zurück",
"common.next": "Weiter",
"filter.range": "Zeitraum",
"filter.range.24h": "Letzte 24 Stunden",
"filter.range.7d": "Letzte 7 Tage",
"filter.range.30d": "Letzte 30 Tage",
"filter.range.90d": "Letzte 90 Tage",
"filter.range.custom": "Eigener…",
"filter.from": "Von",
"filter.to": "Bis",
"filter.agent": "Agent",
"filter.model": "Modell",
"filter.event": "Ereignis",
"filter.status": "Status",
"filter.all": "Alle",
"filter.minCost": "Kosten ab ($)",
"filter.search": "Suche",
"filter.search.placeholder": "Aufgabe, Ergebnis, Lauf-ID…",
"filter.apply": "Anwenden",
"filter.reset": "Zurücksetzen",
"source.task": "Aufgabe",
"source.direct": "Direkter Lauf",
"source.approval": "Freigabe",
"source.webview": "Chat (Fenster)",
"source.webui": "Chat (Web)",
"source.telegram": "Telegram",
"source.agentcomm": "Delegation",
"source.job": "Job",
"source.unknown": "Unbekannt",
"runs.title": "Tokennutzung › Läufe",
"runs.unavailable": "Keine Verbrauchsdaten",
"runs.totals": "Summen für diesen Filter",
"runs.total.runs": "Läufe",
"runs.total.prompt": "Prompt-Tokens",
"runs.total.completion": "Antwort-Tokens",
"runs.total.cached": "Aus dem Cache",
"runs.total.cost": "Kosten",
"runs.total.notRun": "Abgewiesen (Budget, Herunterfahren)",
"runs.unknownCost": "{n} Lauf/Läufe ohne bekannten Preis sind mit 0 $ gezählt.",
"runs.empty.title": "Keine Läufe",
"runs.empty.text": "Kein Lauf passt zu diesem Filter.",
"runs.list": "Läufe",
"runs.page": "{from}–{to} von {total}",
"runs.col.time": "Zeit",
"runs.col.agent": "Agent",
"runs.col.model": "Modell",
"runs.col.event": "Ereignis",
"runs.col.status": "Status",
"runs.col.steps": "Schritte",
"runs.col.prompt": "Prompt",
"runs.col.completion": "Antwort",
"runs.col.cost": "Kosten",
"runs.col.duration": "Dauer",
"detail.back": "← Zurück",
"detail.title": "Lauf",
"detail.notFound": "Lauf nicht gefunden",
"detail.runId": "Lauf-ID",
"detail.tokens": "Tokens",
"detail.tokensValue": "{prompt} Prompt · {completion} Antwort · {cached} aus dem Cache",
"detail.result": "Ergebnis",
"detail.receipt": "Beleg",
"detail.transcript": "Prompt und Antwort",
"detail.transcript.disabled": "Für diesen Agenten ist der Prompt-Verlauf abgeschaltet (Aufbewahrung: nie).",
"detail.transcript.unavailable": "Diese Instanz zeichnet keinen Prompt-Verlauf auf.",
"detail.transcript.missing": "Kein Transkript zu diesem Lauf – er lief vor Beginn der Aufzeichnung oder ist über seine Aufbewahrungsfrist hinaus.",
"detail.transcript.truncated": "Dieser Lauf hat die Größengrenze des Prompt-Verlaufs überschritten; spätere Einträge sind gekürzt.",
"detail.transcript.size": "{n} Einträge, {chars} Zeichen",
"detail.systemPrompt": "Systemprompt ({n} Zeichen)",
"detail.entry.user": "Auftrag / Nachricht",
"detail.entry.engine": "Nachfrage der Engine",
"detail.entry.event": "Ereignis",
"detail.entry.assistant": "Modellschritt {n}",
"detail.entry.usage": "{prompt} rein · {completion} raus",
"detail.entry.tool_result": "Ergebnis: {tool}",
"detail.entry.call": "Ruft {tool} auf",
"detail.entry.clipped": "gekürzt",
"detail.tool": "Werkzeug",
"detail.arguments": "Argumente",
"detail.toolCalls": "Werkzeugaufrufe",
"detail.toolCalls.hint": "Aus dem Audit-Log – auch ohne Transkript vorhanden.",
"detail.approvals": "Zur Freigabe vorgelegt",
"timeline.title": "Tokennutzung › Zeitverlauf",
"timeline.chart": "Zeitverlauf",
"timeline.metric": "Maß",
"timeline.metric.cost": "Kosten",
"timeline.metric.tokens": "Tokens",
"timeline.metric.runs": "Läufe",
"timeline.groupBy": "Nach",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Modell",
"timeline.groupBy.source": "Ereignis",
"timeline.bucket": "Je",
"timeline.bucket.hour": "Stunde",
"timeline.bucket.day": "Tag",
"timeline.tooltipRuns": "{n} Lauf/Läufe",
"timeline.event.budget_exceeded": "Budget erschöpft",
"timeline.event.model_changed": "Modell gewechselt",
"timeline.hint": "Ein Klick auf einen Balken zeigt dessen Läufe.",
"history.title": "Prompt-Verlauf",
"history.intro": "Prompt und Antwort jedes Laufs, durchsuchbar über das ganze Gespräch samt Werkzeugaufrufen und Ergebnissen.",
"history.unavailable": "Prompt-Verlauf nicht verfügbar",
"history.empty.title": "Nichts aufgezeichnet",
"history.empty.text": "Keine Transkripte in diesem Zeitraum. Die Aufzeichnung beginnt mit Version 0.1.12 und folgt der Aufbewahrung unten.",
"history.empty.search": "Kein Transkript enthält diesen Text.",
"history.list": "Transkripte ({n})",
"history.prompt": "Prompt",
"history.response": "Antwort",
"history.truncated": "Größengrenze erreicht ({chars} Zeichen gespeichert).",
"history.settings.title": "Aufbewahrung",
"history.settings.intro": "Wie lange Prompts und Antworten aufbewahrt werden. Die Vorgabe gilt für jeden Agenten ohne eigene Einstellung; „nie“ löscht auch, was vorher aufgezeichnet wurde.",
"history.settings.default": "Vorgabe für alle Agenten:",
"history.settings.agent": "Agent",
"history.settings.retention": "Aufbewahrung",
"history.settings.effective": "Gilt",
"history.settings.mode": "Aufbewahrung",
"history.settings.mode.inherit": "Vorgabe",
"history.settings.mode.never": "Nie aufzeichnen",
"history.settings.mode.days": "Tage",
"history.settings.mode.forever": "Unbegrenzt",
"history.settings.days": "Tage",
"history.settings.daysUnit": "Tage",
"history.settings.effective.never": "wird nicht aufgezeichnet",
"history.settings.effective.forever": "unbegrenzt",
"history.settings.effective.days": "{n} Tage",
"history.settings.save": "Aufbewahrung speichern",
"history.settings.saved": "Aufbewahrung gespeichert.",
"history.settings.failed": "Speichern fehlgeschlagen: {error}"
}
@@ -0,0 +1,141 @@
{
"nav.usage.runs": "Runs",
"nav.usage.timeline": "Timeline",
"nav.promptHistory": "Prompt History",
"common.loading": "Loading…",
"common.previous": "Previous",
"common.next": "Next",
"filter.range": "Period",
"filter.range.24h": "Last 24 hours",
"filter.range.7d": "Last 7 days",
"filter.range.30d": "Last 30 days",
"filter.range.90d": "Last 90 days",
"filter.range.custom": "Custom…",
"filter.from": "From",
"filter.to": "To",
"filter.agent": "Agent",
"filter.model": "Model",
"filter.event": "Event",
"filter.status": "Status",
"filter.all": "All",
"filter.minCost": "Cost from ($)",
"filter.search": "Search",
"filter.search.placeholder": "Task, result, run id…",
"filter.apply": "Apply",
"filter.reset": "Reset",
"source.task": "Task",
"source.direct": "Direct run",
"source.approval": "Approval",
"source.webview": "Chat (window)",
"source.webui": "Chat (web)",
"source.telegram": "Telegram",
"source.agentcomm": "Delegation",
"source.job": "Job",
"source.unknown": "Unknown",
"runs.title": "Token usage › Runs",
"runs.unavailable": "No usage data",
"runs.totals": "Totals for this filter",
"runs.total.runs": "Runs",
"runs.total.prompt": "Prompt tokens",
"runs.total.completion": "Completion tokens",
"runs.total.cached": "From cache",
"runs.total.cost": "Cost",
"runs.total.notRun": "Refused (budget, shutdown)",
"runs.unknownCost": "{n} run(s) without a known price are counted with 0 $.",
"runs.empty.title": "No runs",
"runs.empty.text": "No run matches this filter.",
"runs.list": "Runs",
"runs.page": "{from}–{to} of {total}",
"runs.col.time": "Time",
"runs.col.agent": "Agent",
"runs.col.model": "Model",
"runs.col.event": "Event",
"runs.col.status": "Status",
"runs.col.steps": "Steps",
"runs.col.prompt": "Prompt",
"runs.col.completion": "Response",
"runs.col.cost": "Cost",
"runs.col.duration": "Duration",
"detail.back": "← Back",
"detail.title": "Run",
"detail.notFound": "Run not found",
"detail.runId": "Run id",
"detail.tokens": "Tokens",
"detail.tokensValue": "{prompt} prompt · {completion} response · {cached} from cache",
"detail.result": "Result",
"detail.receipt": "Receipt",
"detail.transcript": "Prompt and response",
"detail.transcript.disabled": "Prompt history is switched off for this agent (retention: never).",
"detail.transcript.unavailable": "This instance does not record a prompt history.",
"detail.transcript.missing": "No transcript for this run - it ran before recording started or is past its retention.",
"detail.transcript.truncated": "This run exceeded the size limit of the prompt history; later entries are shortened.",
"detail.transcript.size": "{n} entries, {chars} characters",
"detail.systemPrompt": "System prompt ({n} characters)",
"detail.entry.user": "Task / message",
"detail.entry.engine": "Follow-up by the engine",
"detail.entry.event": "Event",
"detail.entry.assistant": "Model step {n}",
"detail.entry.usage": "{prompt} in · {completion} out",
"detail.entry.tool_result": "Result: {tool}",
"detail.entry.call": "Calls {tool}",
"detail.entry.clipped": "shortened",
"detail.tool": "Tool",
"detail.arguments": "Arguments",
"detail.toolCalls": "Tool calls",
"detail.toolCalls.hint": "From the audit log - available even without a transcript.",
"detail.approvals": "Proposed for approval",
"timeline.title": "Token usage › Timeline",
"timeline.chart": "Timeline",
"timeline.metric": "Measure",
"timeline.metric.cost": "Cost",
"timeline.metric.tokens": "Tokens",
"timeline.metric.runs": "Runs",
"timeline.groupBy": "By",
"timeline.groupBy.agent": "Agent",
"timeline.groupBy.model": "Model",
"timeline.groupBy.source": "Event",
"timeline.bucket": "Per",
"timeline.bucket.hour": "Hour",
"timeline.bucket.day": "Day",
"timeline.tooltipRuns": "{n} run(s)",
"timeline.event.budget_exceeded": "Budget exhausted",
"timeline.event.model_changed": "Model changed",
"timeline.hint": "Click a bar to see its runs.",
"history.title": "Prompt History",
"history.intro": "Prompt and response of every run, searchable over the whole conversation including tool calls and results.",
"history.unavailable": "Prompt history not available",
"history.empty.title": "Nothing recorded",
"history.empty.text": "No transcripts in this period. Recording starts with version 0.1.12 and follows the retention below.",
"history.empty.search": "No transcript contains this text.",
"history.list": "Transcripts ({n})",
"history.prompt": "Prompt",
"history.response": "Response",
"history.truncated": "Size limit reached ({chars} characters stored).",
"history.settings.title": "Retention",
"history.settings.intro": "How long prompts and responses are kept. The default applies to every agent without its own setting; \"never\" also deletes what was recorded before.",
"history.settings.default": "Default for all agents:",
"history.settings.agent": "Agent",
"history.settings.retention": "Retention",
"history.settings.effective": "Applies",
"history.settings.mode": "Retention",
"history.settings.mode.inherit": "Default",
"history.settings.mode.never": "Never record",
"history.settings.mode.days": "Days",
"history.settings.mode.forever": "Keep forever",
"history.settings.days": "Days",
"history.settings.daysUnit": "days",
"history.settings.effective.never": "not recorded",
"history.settings.effective.forever": "kept forever",
"history.settings.effective.days": "kept {n} days",
"history.settings.save": "Save retention",
"history.settings.saved": "Retention saved.",
"history.settings.failed": "Saving failed: {error}"
}
+13 -1
View File
@@ -71,12 +71,24 @@
<div class="nav-gruppe"> <div class="nav-gruppe">
<div class="nav-gruppe-titel">Analyse</div> <div class="nav-gruppe-titel">Analyse</div>
<a href="#/verbrauch" class="nav-link" title="Tokennutzung"> <a href="#/verbrauch" class="nav-link" title="Tokennutzung" data-exakt>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/> <path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
</svg> </svg>
<span class="nav-label">Tokennutzung</span> <span class="nav-label">Tokennutzung</span>
</a> </a>
<a href="#/verbrauch/runs" class="nav-link nav-unterlink" data-i18n-title="nav.usage.runs">
<span class="nav-label" data-i18n="nav.usage.runs">Runs</span>
</a>
<a href="#/verbrauch/timeline" class="nav-link nav-unterlink" data-i18n-title="nav.usage.timeline">
<span class="nav-label" data-i18n="nav.usage.timeline">Timeline</span>
</a>
<a href="#/prompt-history" class="nav-link" data-i18n-title="nav.promptHistory">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M12 8v4l3 2 M3.05 11a9 9 0 1 1 .5 4 M3 20v-5h5"/>
</svg>
<span class="nav-label" data-i18n="nav.promptHistory">Prompt History</span>
</a>
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats"> <a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/> <path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
@@ -0,0 +1,63 @@
// ClawdDotNet — translations (#661)
//
// English is the main language: every key has its English text in i18n/en.json, and that
// pack is always loaded as the fallback. Other languages live in their own packs next to
// it (i18n/de.json, ...), separate from the code. A missing key falls back to English,
// a key missing there too shows the key itself - visible, so it gets noticed.
//
// The language selection itself is part of #661; until then the choice is read from
// localStorage ("clawd.language") and defaults to English.
export const LANGUAGE_KEY = "clawd.language";
export const DEFAULT_LANGUAGE = "en";
let fallbackPack = {};
let activePack = {};
let activeLanguage = DEFAULT_LANGUAGE;
async function loadPack(language) {
try {
const response = await fetch(`i18n/${encodeURIComponent(language)}.json`, { cache: "no-cache" });
return response.ok ? await response.json() : {};
} catch {
return {};
}
}
/** Loads English plus the selected language. Call once before the first render. */
export async function loadLanguage() {
let wanted = DEFAULT_LANGUAGE;
try {
wanted = localStorage.getItem(LANGUAGE_KEY) || DEFAULT_LANGUAGE;
} catch {
// Storage blocked: stay with English.
}
fallbackPack = await loadPack(DEFAULT_LANGUAGE);
activePack = wanted === DEFAULT_LANGUAGE ? fallbackPack : await loadPack(wanted);
activeLanguage = wanted;
document.documentElement.lang = wanted;
}
export function currentLanguage() {
return activeLanguage;
}
/**
* Text for a key. Placeholders in braces are replaced: t("runs.count", { n: 3 }).
* Values are inserted as plain text - callers put the result into textContent.
*/
export function t(key, values = {}) {
const text = activePack[key] ?? fallbackPack[key] ?? key;
return text.replace(/\{(\w+)\}/g, (match, name) => (name in values ? String(values[name]) : match));
}
/** Translates static markup: every element with data-i18n gets its text from the pack. */
export function translateDocument(root = document) {
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const element of root.querySelectorAll("[data-i18n-title]")) {
element.title = t(element.dataset.i18nTitle);
}
}
@@ -39,10 +39,24 @@ export function navigiere(hash) {
function parseHash() { function parseHash() {
const hash = window.location.hash || "#/chat"; const hash = window.location.hash || "#/chat";
const pfad = hash.startsWith("#") ? hash.slice(1) : hash; let pfad = hash.startsWith("#") ? hash.slice(1) : hash;
// #660: Filters travel in the address ("#/verbrauch/runs?agent=x") so a view can be
// linked. They are not part of the route pattern.
const frage = pfad.indexOf("?");
if (frage >= 0) pfad = pfad.slice(0, frage);
return pfad.startsWith("/") ? pfad : `/${pfad}`; return pfad.startsWith("/") ? pfad : `/${pfad}`;
} }
/**
* Query parameters of the current hash address (#660).
* @returns {URLSearchParams}
*/
export function currentQuery() {
const hash = window.location.hash || "";
const frage = hash.indexOf("?");
return new URLSearchParams(frage >= 0 ? hash.slice(frage + 1) : "");
}
function findePassendeRoute(pfad) { function findePassendeRoute(pfad) {
const pfadTeile = pfad.split("/").filter(Boolean); const pfadTeile = pfad.split("/").filter(Boolean);
@@ -98,7 +112,15 @@ function aktualisiereAktivenNavEintrag(pfad) {
const zielPfad = href.startsWith("#") ? href.slice(1) : href; const zielPfad = href.startsWith("#") ? href.slice(1) : href;
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0]; const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
if (basisPfad === zielBasis) { // #660: Sub-entries match their own path (and what lies below it), the parent entry
// only its exact path - otherwise "Token usage" and "Runs" would both light up.
const treffer = link.classList.contains("nav-unterlink")
? pfad === zielPfad || pfad.startsWith(zielPfad + "/")
: link.dataset.exakt !== undefined
? pfad === zielPfad
: basisPfad === zielBasis;
if (treffer) {
link.classList.add("aktiv"); link.classList.add("aktiv");
} else { } else {
link.classList.remove("aktiv"); link.classList.remove("aktiv");
@@ -0,0 +1,156 @@
// ClawdDotNet — filter bar of the usage analysis (#660)
//
// The filter lives in the hash address ("#/verbrauch/runs?range=7d&agent=hausmeister"), so a
// filtered view can be bookmarked and shared, and back/forward work. Each view reads it with
// readFilters() and writes it with applyFilters().
import { api } from "./api.js";
import { erstelleElement } from "./dom.js";
import { currentQuery, navigiere } from "./router.js";
import { t } from "./i18n.js";
export const RANGES = ["24h", "7d", "30d", "90d", "custom"];
// bucket, groupBy and metric belong to the timeline; the API query ignores them.
const FILTER_KEYS = ["range", "from", "to", "agent", "model", "source", "status", "minCost", "q", "sort", "dir",
"bucket", "groupBy", "metric"];
/** The filter of the current address, with defaults. */
export function readFilters() {
const query = currentQuery();
const filters = {};
for (const key of FILTER_KEYS) {
const value = query.get(key);
if (value) filters[key] = value;
}
filters.range ??= "7d";
filters.offset = Number(query.get("offset") || 0);
return filters;
}
/** Navigates to the same view with a new filter. Resets paging unless offset is given. */
export function applyFilters(path, filters) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "" || (key === "offset" && !value)) continue;
query.set(key, String(value));
}
const text = query.toString();
navigiere(`#${path}${text ? "?" + text : ""}`);
}
/** Start and end of the selected range as ISO strings (UTC). */
export function rangeOf(filters) {
const now = new Date();
if (filters.range === "custom") {
// Date inputs are local calendar days; "to" includes the whole day.
const from = filters.from ? new Date(`${filters.from}T00:00:00`) : new Date(now.getTime() - 7 * 864e5);
const to = filters.to ? new Date(new Date(`${filters.to}T00:00:00`).getTime() + 864e5) : now;
return { from: from.toISOString(), to: to.toISOString() };
}
const hours = { "24h": 24, "7d": 168, "30d": 720, "90d": 2160 }[filters.range] ?? 168;
return { from: new Date(now.getTime() - hours * 36e5).toISOString(), to: new Date(now.getTime() + 60e3).toISOString() };
}
/** Query string for the API: range plus every set filter. */
export function toApiQuery(filters, extra = {}) {
const { from, to } = rangeOf(filters);
const query = new URLSearchParams({ from, to });
for (const key of ["agent", "model", "source", "status", "minCost", "q", "sort", "dir"]) {
if (filters[key]) query.set(key, filters[key]);
}
for (const [key, value] of Object.entries(extra)) {
if (value !== undefined && value !== null && value !== "") query.set(key, String(value));
}
return query.toString();
}
export async function loadFacets(filters) {
const { from, to } = rangeOf(filters);
try {
return await api(`/usage/facets?${new URLSearchParams({ from, to })}`);
} catch {
return { agents: [], models: [], sources: [], statuses: [] };
}
}
function select(name, label, options, current, allText) {
const element = erstelleElement("select", { name, "aria-label": label });
element.append(erstelleElement("option", { value: "", text: allText }));
for (const option of options) {
const value = typeof option === "string" ? option : option.value;
const text = typeof option === "string" ? option : option.text;
element.append(erstelleElement("option", { value, text }));
}
element.value = current || "";
return erstelleElement("label", { klasse: "filter-feld" }, erstelleElement("span", { text: label }), element);
}
/**
* The bar itself. `fields` chooses what is shown, e.g. ["range", "agent", "q"].
* `onApply(filters)` receives the complete new filter.
*/
export function buildFilterBar(filters, facets, onApply, fields = ["range", "agent", "model", "source", "status", "minCost", "q"]) {
const form = erstelleElement("form", { klasse: "filter-leiste" });
const show = (field) => fields.includes(field);
if (show("range")) {
form.append(select("range", t("filter.range"),
RANGES.map((r) => ({ value: r, text: t(`filter.range.${r}`) })), filters.range, t("filter.range.7d")));
const rangeSelect = form.querySelector("select[name=range]");
rangeSelect.querySelector("option[value='']").remove();
const from = erstelleElement("input", { type: "date", name: "from", value: filters.from || "", "aria-label": t("filter.from") });
const to = erstelleElement("input", { type: "date", name: "to", value: filters.to || "", "aria-label": t("filter.to") });
const custom = erstelleElement("span", { klasse: "filter-eigen" }, from, erstelleElement("span", { text: "–" }), to);
custom.hidden = filters.range !== "custom";
rangeSelect.addEventListener("change", () => { custom.hidden = rangeSelect.value !== "custom"; });
form.append(custom);
}
if (show("agent")) form.append(select("agent", t("filter.agent"), facets.agents ?? [], filters.agent, t("filter.all")));
if (show("model")) form.append(select("model", t("filter.model"), facets.models ?? [], filters.model, t("filter.all")));
if (show("source")) {
form.append(select("source", t("filter.event"),
(facets.sources ?? []).map((s) => ({ value: s, text: t(`source.${s}`) === `source.${s}` ? s : t(`source.${s}`) })),
filters.source, t("filter.all")));
}
if (show("status")) form.append(select("status", t("filter.status"), facets.statuses ?? [], filters.status, t("filter.all")));
if (show("minCost")) {
form.append(erstelleElement("label", { klasse: "filter-feld" },
erstelleElement("span", { text: t("filter.minCost") }),
erstelleElement("input", { type: "number", name: "minCost", min: "0", step: "0.001", value: filters.minCost || "", placeholder: "0.00" })));
}
if (show("q")) {
form.append(erstelleElement("label", { klasse: "filter-feld filter-suche" },
erstelleElement("span", { text: t("filter.search") }),
erstelleElement("input", { type: "search", name: "q", value: filters.q || "", placeholder: t("filter.search.placeholder") })));
}
const apply = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("filter.apply") });
const reset = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche flach klein",
text: t("filter.reset"),
onclick: () => onApply({ range: "7d" })
});
form.append(erstelleElement("div", { klasse: "filter-aktionen" }, apply, reset));
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const next = { sort: filters.sort, dir: filters.dir };
for (const [key, value] of data.entries()) {
if (value) next[key] = String(value);
}
if (next.range !== "custom") {
delete next.from;
delete next.to;
}
onApply(next);
});
return form;
}
@@ -0,0 +1,202 @@
// ClawdDotNet — Analysis › Prompt History (#660)
//
// Recorded prompts and responses of every run, searchable over the full transcript, plus
// how long they are kept: an instance default (14 days unless changed) and an override per
// agent - "never" for an agent that handles secrets, "forever" or many days to debug one.
//
// Transcripts are foreign text and only ever inserted via textContent.
import { api } from "../modules/api.js";
import { erstelleElement, erstelleKarte, formatiereDatumZeit, formatiereTokens, zeigeMeldung } from "../modules/dom.js";
import { navigiere } from "../modules/router.js";
import { t } from "../modules/i18n.js";
import { readFilters, applyFilters, rangeOf, loadFacets, buildFilterBar } from "../modules/usage-filters.js";
import { statusBadge, sourceLabel } from "./usage-runs.js";
const PATH = "/prompt-history";
const PAGE_SIZE = 25;
const NEVER = 0;
const FOREVER = -1;
export async function renderPromptHistory() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const filters = readFilters();
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(
erstelleElement("h1", { text: t("history.title") }),
erstelleElement("p", { klasse: "hinweis", text: t("history.intro") }));
const facets = await loadFacets(filters);
layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, next), ["range", "agent", "q"]));
const list = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
const settings = erstelleElement("div", {});
layout.append(list, settings);
container.replaceChildren(layout);
await Promise.all([loadList(list, filters), loadSettings(settings)]);
}
async function loadList(target, filters) {
const { from, to } = rangeOf(filters);
const query = new URLSearchParams({ from, to, offset: String(filters.offset || 0), limit: String(PAGE_SIZE) });
if (filters.agent) query.set("agent", filters.agent);
if (filters.q) query.set("q", filters.q);
let page;
try {
page = await api(`/prompt-history?${query}`);
} catch (err) {
target.replaceChildren(erstelleKarte(t("history.unavailable"), [erstelleElement("p", { klasse: "gedaempft", text: err.message })]));
return;
}
if (page.items.length === 0) {
target.replaceChildren(erstelleKarte(t("history.empty.title"), [
erstelleElement("p", { klasse: "gedaempft", text: filters.q ? t("history.empty.search") : t("history.empty.text") })
]));
return;
}
const items = page.items.map((item) => {
const open = () => navigiere(`#${PATH}/${encodeURIComponent(item.runId)}`);
const card = erstelleElement("div", { klasse: "verlauf-eintrag klickbar", tabindex: "0", onclick: open });
card.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
card.append(
erstelleElement("div", { klasse: "verlauf-kopf" },
erstelleElement("strong", { text: item.agentId }),
erstelleElement("span", { klasse: "gedaempft klein", text: `${formatiereDatumZeit(item.startedAt)} · ${sourceLabel(item.source)} · ${item.model}` }),
statusBadge(item.status)),
erstelleElement("div", { klasse: "verlauf-zeile" },
erstelleElement("span", { klasse: "verlauf-marke", text: t("history.prompt") }),
erstelleElement("span", { klasse: "verlauf-text", text: item.prompt || "—" })),
erstelleElement("div", { klasse: "verlauf-zeile" },
erstelleElement("span", { klasse: "verlauf-marke", text: t("history.response") }),
erstelleElement("span", { klasse: "verlauf-text", text: item.response || "—" })));
if (item.truncated) {
card.append(erstelleElement("div", { klasse: "gedaempft klein", text: t("history.truncated", { chars: formatiereTokens(item.sizeChars) }) }));
}
return card;
});
const offset = filters.offset || 0;
const pager = erstelleElement("div", { klasse: "blaettern" },
erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("common.previous"), disabled: offset <= 0,
onclick: () => applyFilters(PATH, { ...filters, offset: Math.max(0, offset - PAGE_SIZE) })
}),
erstelleElement("span", { klasse: "gedaempft", text: t("runs.page", { from: offset + 1, to: offset + page.items.length, total: page.total }) }),
erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("common.next"), disabled: offset + PAGE_SIZE >= page.total,
onclick: () => applyFilters(PATH, { ...filters, offset: offset + PAGE_SIZE })
}));
target.replaceChildren(erstelleKarte(t("history.list", { n: page.total }), [...items, pager]));
}
// ─── Retention settings ───
function modeOf(days) {
if (days === null || days === undefined) return "inherit";
if (days === NEVER) return "never";
if (days === FOREVER) return "forever";
return "days";
}
/** A mode select plus a day count that is only shown for "days". */
function retentionInput(days, allowInherit) {
const modes = allowInherit ? ["inherit", "never", "days", "forever"] : ["never", "days", "forever"];
const mode = erstelleElement("select", { "aria-label": t("history.settings.mode") });
for (const m of modes) mode.append(erstelleElement("option", { value: m, text: t(`history.settings.mode.${m}`) }));
mode.value = modeOf(days);
const count = erstelleElement("input", {
type: "number", min: "1", step: "1", value: days > 0 ? String(days) : "14", "aria-label": t("history.settings.days")
});
const unit = erstelleElement("span", { klasse: "gedaempft klein", text: t("history.settings.daysUnit") });
const dayBox = erstelleElement("span", { klasse: "gruppe-horizontal" }, count, unit);
const sync = () => { dayBox.hidden = mode.value !== "days"; };
mode.addEventListener("change", sync);
sync();
return {
element: erstelleElement("span", { klasse: "gruppe-horizontal aufbewahrung" }, mode, dayBox),
value() {
switch (mode.value) {
case "inherit": return null;
case "never": return NEVER;
case "forever": return FOREVER;
default: return Math.max(1, Math.round(Number(count.value) || 14));
}
}
};
}
function describe(days) {
if (days === NEVER) return t("history.settings.effective.never");
if (days === FOREVER) return t("history.settings.effective.forever");
return t("history.settings.effective.days", { n: days });
}
async function loadSettings(target) {
let settings;
try {
settings = await api("/prompt-history/settings");
} catch (err) {
target.replaceChildren();
return;
}
const instance = retentionInput(settings.defaultRetentionDays, false);
const rows = [];
const table = erstelleElement("table", { klasse: "daten-tabelle" });
const head = erstelleElement("tr", {});
for (const label of ["history.settings.agent", "history.settings.retention", "history.settings.effective"]) {
head.append(erstelleElement("th", { text: t(label) }));
}
table.append(head);
for (const agent of settings.agents) {
const input = retentionInput(agent.retentionDays, true);
rows.push({ agentId: agent.agentId, input, before: agent.retentionDays ?? null });
table.append(erstelleElement("tr", {},
erstelleElement("td", { text: agent.displayName }),
erstelleElement("td", {}, input.element),
erstelleElement("td", { klasse: "gedaempft", text: describe(agent.effectiveRetentionDays) })));
}
const save = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche primaer klein",
text: t("history.settings.save"),
onclick: async () => {
const body = { defaultRetentionDays: instance.value(), agents: [] };
for (const row of rows) {
const value = row.input.value();
if (value !== row.before) body.agents.push({ agentId: row.agentId, retentionDays: value });
}
save.disabled = true;
try {
const result = await api("/prompt-history/settings", { method: "PUT", body });
zeigeMeldung(result?.message ?? t("history.settings.saved"), "erfolg");
await loadSettings(target);
} catch (err) {
zeigeMeldung(t("history.settings.failed", { error: err.message }), "gefahr");
save.disabled = false;
}
}
});
target.replaceChildren(erstelleKarte(t("history.settings.title"), [
erstelleElement("p", { klasse: "hinweis", text: t("history.settings.intro") }),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("strong", { text: t("history.settings.default") }), instance.element),
erstelleElement("div", { klasse: "tabelle-scroll" }, table),
save
]));
}
@@ -0,0 +1,195 @@
// ClawdDotNet — run detail: prompt, response and every step in between (#660)
//
// Reached from Token usage › Runs and from Prompt History. Shows the receipt, the recorded
// transcript (task, each model step with its tokens and cost, each tool call and result),
// the system prompt, and what the run proposed for approval.
//
// Everything in a transcript is foreign text - model output, web pages, database rows.
// It is only ever inserted via textContent.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
} from "../modules/dom.js";
import { t } from "../modules/i18n.js";
import { statusBadge, sourceLabel, formatDuration } from "./usage-runs.js";
export async function renderRunDetail(runId, backPath) {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const back = erstelleElement("a", { href: `#${backPath}`, klasse: "zurueck-link", text: t("detail.back") });
// Back to the filtered list if we came from one.
back.addEventListener("click", (event) => {
if (window.history.length > 1) {
event.preventDefault();
window.history.back();
}
});
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht lauf-detail" }, back,
erstelleElement("h1", { text: t("detail.title") }),
erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
container.replaceChildren(layout);
let run;
try {
run = await api(`/runs/${encodeURIComponent(runId)}`);
} catch (err) {
layout.lastChild.replaceWith(erstelleKarte(t("detail.notFound"), [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
]));
return;
}
const parts = [buildReceipt(run)];
parts.push(run.transcript ? buildTranscript(run) : buildTranscriptState(run));
if (!run.transcript && run.toolCalls.length > 0) parts.push(buildToolCalls(run.toolCalls));
if (run.approvals.length > 0) parts.push(buildApprovals(run.approvals));
layout.lastChild.replaceWith(...parts);
}
function buildReceipt(run) {
const r = run.receipt;
const list = erstelleElement("dl", { klasse: "datenliste" });
const add = (label, value) => list.append(
erstelleElement("dt", { text: t(label) }),
value instanceof Node ? erstelleElement("dd", {}, value) : erstelleElement("dd", { text: value ?? "—" }));
add("detail.runId", run.runId);
if (r) {
add("runs.col.time", formatiereDatumZeit(r.occurredAt));
add("runs.col.agent", r.agentId);
add("runs.col.model", r.model);
add("runs.col.event", r.taskTitle ? `${sourceLabel(r.source)} · ${r.taskTitle}` : sourceLabel(r.source));
add("runs.col.status", statusBadge(r.status));
add("runs.col.steps", String(r.stepCount));
add("detail.tokens", t("detail.tokensValue", {
prompt: formatiereTokens(r.promptTokens),
completion: formatiereTokens(r.completionTokens),
cached: formatiereTokens(r.cachedTokens)
}));
add("runs.col.cost", r.costIsKnown || r.costUsd > 0 ? formatiereKosten(r.costUsd) : "—");
add("runs.col.duration", formatDuration(r.durationMs));
if (r.result) add("detail.result", r.result);
}
return erstelleKarte(t("detail.receipt"), [list]);
}
function buildTranscriptState(run) {
const text = {
disabled: t("detail.transcript.disabled"),
unavailable: t("detail.transcript.unavailable"),
missing: t("detail.transcript.missing")
}[run.transcriptState] ?? t("detail.transcript.missing");
return erstelleKarte(t("detail.transcript"), [erstelleElement("p", { klasse: "gedaempft", text })]);
}
function pre(text) {
return erstelleElement("pre", { klasse: "code-block transkript-text", text: text ?? "" });
}
function prettyJson(text) {
if (!text) return "";
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
function buildTranscript(run) {
const transcript = run.transcript;
const items = [];
if (run.systemPrompt) {
const details = erstelleElement("details", { klasse: "transkript-system" },
erstelleElement("summary", { text: t("detail.systemPrompt", { n: run.systemPrompt.length }) }),
pre(run.systemPrompt));
items.push(details);
}
if (transcript.truncated) {
items.push(erstelleElement("div", { klasse: "hinweis-box warnung", text: t("detail.transcript.truncated") }));
}
let step = 0;
for (const entry of transcript.entries) {
const kind = entry.kind;
const block = erstelleElement("div", { klasse: `transkript-eintrag art-${kind}` });
const head = erstelleElement("div", { klasse: "transkript-kopf" });
if (kind === "assistant") {
step++;
head.append(erstelleElement("strong", { text: t("detail.entry.assistant", { n: step }) }));
if (entry.promptTokens !== undefined && entry.promptTokens !== null) {
head.append(erstellePlakette(t("detail.entry.usage", {
prompt: formatiereTokens(entry.promptTokens),
completion: formatiereTokens(entry.completionTokens ?? 0)
}), "neutral"));
}
if (entry.costUsd) head.append(erstellePlakette(formatiereKosten(entry.costUsd), "neutral"));
if (entry.finishReason && entry.finishReason !== "stop" && entry.finishReason !== "tool_calls") {
head.append(erstellePlakette(entry.finishReason, "warnung"));
}
} else {
head.append(erstelleElement("strong", {
text: kind === "tool_result"
? t("detail.entry.tool_result", { tool: entry.toolName ?? "?" })
: t(`detail.entry.${kind}`)
}));
}
head.append(erstelleElement("span", { klasse: "gedaempft klein", text: formatiereDatumZeit(entry.at) }));
if (entry.clipped) head.append(erstellePlakette(t("detail.entry.clipped"), "warnung"));
block.append(head);
if (entry.text) block.append(pre(entry.text));
for (const call of entry.toolCalls ?? []) {
block.append(erstelleElement("div", { klasse: "transkript-aufruf" },
erstelleElement("span", { klasse: "klein", text: t("detail.entry.call", { tool: call.name }) }),
pre(prettyJson(call.arguments))));
}
items.push(block);
}
const summary = t("detail.transcript.size", { n: transcript.entries.length, chars: formatiereTokens(transcript.sizeChars) });
return erstelleKarte(t("detail.transcript"), [erstelleElement("p", { klasse: "hinweis", text: summary }), ...items]);
}
function buildToolCalls(calls) {
const table = erstelleElement("table", { klasse: "daten-tabelle" });
const head = erstelleElement("tr", {});
for (const label of ["runs.col.time", "detail.tool", "runs.col.status", "detail.arguments"]) {
head.append(erstelleElement("th", { text: t(label) }));
}
table.append(head);
for (const call of calls) {
table.append(erstelleElement("tr", {},
erstelleElement("td", { text: formatiereDatumZeit(call.at) }),
erstelleElement("td", { text: call.tool }),
erstelleElement("td", { text: call.status }),
erstelleElement("td", {}, pre(prettyJson(call.arguments)))));
}
return erstelleKarte(t("detail.toolCalls"), [
erstelleElement("p", { klasse: "hinweis", text: t("detail.toolCalls.hint") }),
erstelleElement("div", { klasse: "tabelle-scroll" }, table)
]);
}
function buildApprovals(approvals) {
const list = erstelleElement("ul", { klasse: "schlichte-liste" });
for (const a of approvals) {
list.append(erstelleElement("li", {},
erstelleElement("strong", { text: `#${a.id} ${a.tool}.${a.action ?? "?"} ` }),
erstellePlakette(a.status, a.status === "Executed" ? "erfolg" : a.status === "Pending" ? "warnung" : "neutral")));
}
return erstelleKarte(t("detail.approvals"), [list]);
}
@@ -0,0 +1,187 @@
// ClawdDotNet — Token usage › Runs (#660)
//
// Every run of the selected range: filterable by agent, model, event (source), status,
// cost and free text; sortable by every column; totals over the whole filter, not only
// the visible page. A row opens the run detail with prompt and response.
//
// Strictly DOM API, no innerHTML: results and task titles are foreign text.
import { api } from "../modules/api.js";
import {
erstelleElement,
erstelleKarte,
erstellePlakette,
formatiereDatumZeit,
formatiereTokens,
formatiereKosten
} from "../modules/dom.js";
import { navigiere } from "../modules/router.js";
import { t } from "../modules/i18n.js";
import { readFilters, applyFilters, toApiQuery, loadFacets, buildFilterBar } from "../modules/usage-filters.js";
const PATH = "/verbrauch/runs";
const PAGE_SIZE = 50;
const COLUMNS = [
{ key: "time", label: "runs.col.time" },
{ key: "agent", label: "runs.col.agent" },
{ key: "model", label: "runs.col.model" },
{ key: "source", label: "runs.col.event" },
{ key: "status", label: "runs.col.status" },
{ key: "steps", label: "runs.col.steps", numeric: true },
{ key: "prompt", label: "runs.col.prompt", numeric: true },
{ key: "completion", label: "runs.col.completion", numeric: true },
{ key: "cost", label: "runs.col.cost", numeric: true },
{ key: "duration", label: "runs.col.duration", numeric: true }
];
export function statusBadge(status) {
const kind = {
Completed: "erfolg",
BudgetExceeded: "warnung",
Truncated: "warnung",
LoopLimitExceeded: "gefahr",
RepeatedFailure: "gefahr",
Failed: "gefahr",
Cancelled: "neutral"
}[status] ?? "neutral";
return erstellePlakette(status, kind);
}
export function sourceLabel(source) {
const key = `source.${source}`;
const text = t(key);
return text === key ? source : text;
}
export async function renderUsageRuns() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const filters = readFilters();
filters.sort ??= "time";
filters.dir ??= "desc";
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(erstelleElement("h1", { text: t("runs.title") }));
const facets = await loadFacets(filters);
layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, next)));
const content = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
layout.append(content);
container.replaceChildren(layout);
let page;
try {
page = await api(`/usage/runs?${toApiQuery(filters, { offset: filters.offset, limit: PAGE_SIZE })}`);
} catch (err) {
content.replaceChildren(erstelleKarte(t("runs.unavailable"), [
erstelleElement("p", { klasse: "gedaempft", text: err.message })
]));
return;
}
content.replaceChildren(buildTotals(page.totals), buildTable(page, filters));
}
function buildTotals(totals) {
const tiles = [
[t("runs.total.runs"), String(totals.runs)],
[t("runs.total.prompt"), formatiereTokens(totals.promptTokens)],
[t("runs.total.completion"), formatiereTokens(totals.completionTokens)],
[t("runs.total.cached"), formatiereTokens(totals.cachedTokens)],
[t("runs.total.cost"), formatiereKosten(totals.costUsd)],
[t("runs.total.notRun"), String(totals.notRun)]
];
const grid = erstelleElement("div", { klasse: "kennzahlen-raster" });
for (const [label, value] of tiles) {
grid.append(erstelleElement("div", { klasse: "kennzahl" },
erstelleElement("div", { klasse: "kennzahl-wert", text: value }),
erstelleElement("div", { klasse: "kennzahl-titel", text: label })));
}
const parts = [grid];
if (totals.runsWithUnknownCost > 0) {
parts.push(erstelleElement("p", { klasse: "hinweis", text: t("runs.unknownCost", { n: totals.runsWithUnknownCost }) }));
}
return erstelleKarte(t("runs.totals"), parts);
}
function buildTable(page, filters) {
if (page.items.length === 0) {
return erstelleKarte(t("runs.empty.title"), [erstelleElement("p", { klasse: "gedaempft", text: t("runs.empty.text") })]);
}
const table = erstelleElement("table", { klasse: "daten-tabelle lauf-tabelle" });
const head = erstelleElement("tr", {});
for (const column of COLUMNS) {
const active = filters.sort === column.key;
const arrow = active ? (filters.dir === "asc" ? " ▲" : " ▼") : "";
const button = erstelleElement("button", {
type: "button",
klasse: "sortier-knopf",
text: t(column.label) + arrow,
onclick: () => applyFilters(PATH, {
...filters,
offset: 0,
sort: column.key,
dir: active && filters.dir === "desc" ? "asc" : "desc"
})
});
head.append(erstelleElement("th", { klasse: column.numeric ? "zahl" : "", "aria-sort": active ? (filters.dir === "asc" ? "ascending" : "descending") : "none" }, button));
}
table.append(erstelleElement("thead", {}, head));
const body = erstelleElement("tbody", {});
for (const run of page.items) {
const open = () => navigiere(`#${PATH}/${encodeURIComponent(run.runId)}`);
const row = erstelleElement("tr", { klasse: "klickbar", tabindex: "0", onclick: open });
row.addEventListener("keydown", (event) => { if (event.key === "Enter") open(); });
const event = erstelleElement("td", {}, erstelleElement("div", { text: sourceLabel(run.source) }));
if (run.taskTitle || run.taskId) {
event.append(erstelleElement("div", { klasse: "gedaempft klein", text: run.taskTitle || run.taskId }));
}
row.append(
erstelleElement("td", { text: formatiereDatumZeit(run.occurredAt) }),
erstelleElement("td", { text: run.agentId }),
erstelleElement("td", { klasse: "klein", text: run.model || "—" }),
event,
erstelleElement("td", {}, statusBadge(run.status)),
erstelleElement("td", { klasse: "zahl", text: String(run.stepCount) }),
erstelleElement("td", { klasse: "zahl", text: formatiereTokens(run.promptTokens) }),
erstelleElement("td", { klasse: "zahl", text: formatiereTokens(run.completionTokens) }),
erstelleElement("td", { klasse: "zahl", text: run.costIsKnown || run.costUsd > 0 ? formatiereKosten(run.costUsd) : "—" }),
erstelleElement("td", { klasse: "zahl", text: formatDuration(run.durationMs) })
);
body.append(row);
}
table.append(body);
const offset = filters.offset || 0;
const pager = erstelleElement("div", { klasse: "blaettern" },
erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("common.previous"), disabled: offset <= 0,
onclick: () => applyFilters(PATH, { ...filters, offset: Math.max(0, offset - PAGE_SIZE) })
}),
erstelleElement("span", {
klasse: "gedaempft",
text: t("runs.page", { from: offset + 1, to: offset + page.items.length, total: page.total })
}),
erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("common.next"), disabled: offset + PAGE_SIZE >= page.total,
onclick: () => applyFilters(PATH, { ...filters, offset: offset + PAGE_SIZE })
}));
return erstelleKarte(t("runs.list"), [erstelleElement("div", { klasse: "tabelle-scroll" }, table), pager]);
}
export function formatDuration(ms) {
if (!ms) return "—";
if (ms < 1000) return `${ms} ms`;
const seconds = ms / 1000;
return seconds < 60 ? `${seconds.toFixed(1)} s` : `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")} min`;
}
@@ -0,0 +1,234 @@
// ClawdDotNet — Token usage › Timeline (#660)
//
// Stacked bars per hour or day, grouped by agent, model or event, measuring cost, tokens or
// runs. Budget denials and model changes are drawn as markers on the axis - the usual
// explanation for a jump. Clicking a bar opens the runs of exactly that bucket and group.
//
// Inline SVG built with the DOM API: the manager runs offline, no chart library.
import { api } from "../modules/api.js";
import { erstelleElement, erstelleKarte, formatiereTokens, formatiereKosten } from "../modules/dom.js";
import { t } from "../modules/i18n.js";
import { readFilters, applyFilters, toApiQuery, loadFacets, buildFilterBar, rangeOf } from "../modules/usage-filters.js";
import { sourceLabel } from "./usage-runs.js";
const PATH = "/verbrauch/timeline";
const SVG = "http://www.w3.org/2000/svg";
// Distinguishable in light and dark theme; repeats after ten groups.
const PALETTE = ["#4e79a7", "#f28e2b", "#59a14f", "#e15759", "#76b7b2", "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac"];
function svg(tag, attributes = {}, ...children) {
const element = document.createElementNS(SVG, tag);
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, String(value));
for (const child of children) element.append(child);
return element;
}
function title(text) {
const element = document.createElementNS(SVG, "title");
element.textContent = text;
return element;
}
export async function renderUsageTimeline() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
const filters = readFilters();
const bucket = filters.bucket ?? (["24h", "7d"].includes(filters.range) ? "hour" : "day");
const groupBy = filters.groupBy ?? "agent";
const metric = filters.metric ?? "cost";
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
layout.append(erstelleElement("h1", { text: t("timeline.title") }));
const facets = await loadFacets(filters);
layout.append(buildFilterBar(filters, facets, (next) => applyFilters(PATH, { ...next, bucket, groupBy, metric })));
layout.append(buildControls(filters, bucket, groupBy, metric));
const content = erstelleElement("div", {}, erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
layout.append(content);
container.replaceChildren(layout);
let points;
let events;
try {
const tzOffset = -new Date().getTimezoneOffset();
[points, events] = await Promise.all([
api(`/usage/series?${toApiQuery(filters, { bucket, groupBy, tzOffset })}`),
api(`/usage/events?${toApiQuery(filters)}`)
]);
} catch (err) {
content.replaceChildren(erstelleKarte(t("runs.unavailable"), [erstelleElement("p", { klasse: "gedaempft", text: err.message })]));
return;
}
if (points.length === 0) {
content.replaceChildren(erstelleKarte(t("runs.empty.title"), [erstelleElement("p", { klasse: "gedaempft", text: t("runs.empty.text") })]));
return;
}
content.replaceChildren(buildChart(points, events, filters, bucket, groupBy, metric));
}
function buildControls(filters, bucket, groupBy, metric) {
const bar = erstelleElement("div", { klasse: "reiter-leiste diagramm-steuerung" });
const group = (name, options, current) => {
const wrapper = erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("span", { klasse: "gedaempft klein", text: t(`timeline.${name}`) }));
for (const option of options) {
wrapper.append(erstelleElement("button", {
type: "button",
klasse: `reiter-schaltflaeche${option === current ? " aktiv" : ""}`,
text: t(`timeline.${name}.${option}`),
onclick: () => applyFilters(PATH, { ...readFilters(), bucket, groupBy, metric, [name]: option })
}));
}
return wrapper;
};
bar.append(
group("metric", ["cost", "tokens", "runs"], metric),
group("groupBy", ["agent", "model", "source"], groupBy),
group("bucket", ["hour", "day"], bucket));
return bar;
}
function bucketsBetween(from, to, bucket) {
const step = bucket === "day" ? 864e5 : 36e5;
const start = new Date(from);
if (bucket === "day") start.setHours(0, 0, 0, 0);
else start.setMinutes(0, 0, 0);
const list = [];
for (let time = start.getTime(); time < new Date(to).getTime() && list.length < 2000; time += step) {
list.push(time);
}
return list;
}
function formatBucket(time, bucket) {
const date = new Date(time);
return bucket === "day"
? date.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit" })
: date.toLocaleString(undefined, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function formatValue(value, metric) {
return metric === "cost" ? formatiereKosten(value) : metric === "tokens" ? formatiereTokens(value) : String(value);
}
function buildChart(points, events, filters, bucket, groupBy, metric) {
const valueOf = (p) => (metric === "cost" ? Number(p.costUsd) : metric === "tokens" ? p.tokens : p.runs);
const { from, to } = rangeOf(filters);
// The API only returns buckets that have runs; the axis shows the whole range.
const firstPoint = Math.min(...points.map((p) => new Date(p.bucketStart).getTime()));
const buckets = bucketsBetween(Math.min(new Date(from).getTime(), firstPoint), to, bucket);
const step = bucket === "day" ? 864e5 : 36e5;
const indexOf = (time) => Math.floor((time - buckets[0]) / step);
const groups = [...new Set(points.map((p) => p.group))].sort();
const color = (group) => PALETTE[groups.indexOf(group) % PALETTE.length];
const stacks = buckets.map(() => []);
for (const p of points) {
const i = indexOf(new Date(p.bucketStart).getTime());
if (i >= 0 && i < stacks.length) stacks[i].push(p);
}
const max = Math.max(...stacks.map((s) => s.reduce((sum, p) => sum + valueOf(p), 0)), 0) || 1;
const barWidth = Math.max(4, Math.min(28, Math.floor(1100 / buckets.length)));
const left = 70;
const top = 16;
const height = 260;
const width = left + buckets.length * barWidth + 20;
const chart = svg("svg", {
class: "zeitverlauf",
viewBox: `0 0 ${width} ${height + top + 50}`,
width,
height: height + top + 50,
role: "img",
"aria-label": t("timeline.title")
});
// Axis and grid: four lines with the value.
for (let i = 0; i <= 4; i++) {
const y = top + height - (height * i) / 4;
chart.append(svg("line", { x1: left, x2: width - 10, y1: y, y2: y, class: "raster" }));
const label = svg("text", { x: left - 6, y: y + 4, "text-anchor": "end", class: "achse" });
label.textContent = formatValue((max * i) / 4, metric);
chart.append(label);
}
const labelEvery = Math.ceil(buckets.length / 12);
stacks.forEach((stack, i) => {
const x = left + i * barWidth;
let y = top + height;
for (const p of stack) {
const h = (valueOf(p) / max) * height;
if (h <= 0) continue;
y -= h;
const rect = svg("rect", {
x: x + 1, y, width: barWidth - 2, height: h, fill: color(p.group), class: "balken", tabindex: 0
}, title(`${formatBucket(buckets[i], bucket)} · ${groupBy === "source" ? sourceLabel(p.group) : p.group}: ` +
`${formatValue(valueOf(p), metric)} (${t("timeline.tooltipRuns", { n: p.runs })})`));
rect.addEventListener("click", () => openBucket(filters, buckets[i], step, groupBy, p.group));
chart.append(rect);
}
if (i % labelEvery === 0) {
const label = svg("text", { x: x + barWidth / 2, y: top + height + 16, "text-anchor": "middle", class: "achse" });
label.textContent = formatBucket(buckets[i], bucket);
chart.append(label);
}
});
for (const event of events) {
const time = new Date(event.at).getTime();
const i = (time - buckets[0]) / step;
if (i < 0 || i > buckets.length) continue;
const x = left + i * barWidth;
const kind = event.kind === "budget_exceeded" ? "ereignis-budget" : "ereignis-modell";
chart.append(svg("g", { class: `ereignis ${kind}` },
svg("line", { x1: x, x2: x, y1: top, y2: top + height }),
svg("path", { d: `M${x - 5},${top + height + 22} L${x + 5},${top + height + 22} L${x},${top + height + 30} Z` }),
title(`${new Date(event.at).toLocaleString()} · ${event.agentId}: ${t(`timeline.event.${event.kind}`)} – ${event.text}`)));
}
const legend = erstelleElement("div", { klasse: "legende" });
for (const group of groups) {
const total = points.filter((p) => p.group === group).reduce((sum, p) => sum + valueOf(p), 0);
legend.append(erstelleElement("span", { klasse: "legende-eintrag" },
erstelleElement("span", { klasse: "legende-farbe", stil: { background: color(group) } }),
erstelleElement("span", { text: `${groupBy === "source" ? sourceLabel(group) : group}: ${formatValue(total, metric)}` })));
}
if (events.length > 0) {
legend.append(
erstelleElement("span", { klasse: "legende-eintrag" },
erstelleElement("span", { klasse: "legende-marke ereignis-budget" }),
erstelleElement("span", { text: t("timeline.event.budget_exceeded") })),
erstelleElement("span", { klasse: "legende-eintrag" },
erstelleElement("span", { klasse: "legende-marke ereignis-modell" }),
erstelleElement("span", { text: t("timeline.event.model_changed") })));
}
return erstelleKarte(t("timeline.chart"), [
erstelleElement("div", { klasse: "diagramm-scroll" }, chart),
legend,
erstelleElement("p", { klasse: "hinweis", text: t("timeline.hint") })
]);
}
function openBucket(filters, start, step, groupBy, group) {
const day = (time) => {
const d = new Date(time);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
const next = { ...filters, range: "custom", from: day(start), to: day(start + step - 1), offset: 0 };
delete next.bucket;
delete next.groupBy;
delete next.metric;
next[groupBy === "source" ? "source" : groupBy] = group;
applyFilters("/verbrauch/runs", next);
}
@@ -0,0 +1,279 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Analytics;
using ClawdDotNet.Core.Transcripts;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Usage analysis and prompt history (#660).
///
/// <list type="bullet">
/// <item><c>GET /api/usage/runs</c> - filterable, sortable, paged list of runs with totals over the whole filter</item>
/// <item><c>GET /api/usage/series</c> - stacked buckets per hour or day for the timeline</item>
/// <item><c>GET /api/usage/events</c> - markers for the timeline (budget denials, model changes)</item>
/// <item><c>GET /api/usage/facets</c> - the values the filter dropdowns offer</item>
/// <item><c>GET /api/runs/{runId}</c> - one run: receipt, tool calls, approvals, transcript</item>
/// <item><c>GET /api/prompt-history</c> - recorded transcripts with full-text search</item>
/// <item><c>GET|PUT /api/prompt-history/settings</c> - retention per instance and per agent</item>
/// </list>
///
/// Filters use the same query parameters everywhere: <c>from</c>, <c>to</c> (ISO 8601),
/// <c>agent</c>, <c>model</c>, <c>source</c>, <c>status</c> (comma separated),
/// <c>minCost</c>, <c>q</c>. Without <c>from</c> the last 7 days are shown.
/// </summary>
public static class AnalyticsEndpoints
{
private static readonly TimeSpan DefaultRange = TimeSpan.FromDays(7);
public static void MapAnalytics(this RouteGroupBuilder api)
{
api.MapGet("/usage/runs", async (AppHost host, HttpRequest request, CancellationToken ct) =>
{
if (host.Insights is not { } insights)
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
var page = await insights.Analytics.QueryRunsAsync(ParseQuery(request), ct);
return Results.Ok(new RunPageDto(
page.Items.Select(ToDto).ToList(),
page.Total,
new RunTotalsDto(page.Totals.Runs, page.Totals.PromptTokens, page.Totals.CompletionTokens,
page.Totals.CachedTokens, page.Totals.CostUsd, page.Totals.RunsWithUnknownCost, page.Totals.NotRun)));
});
api.MapGet("/usage/series", async (
AppHost host, HttpRequest request, CancellationToken ct,
string bucket = "hour", string groupBy = "agent", int tzOffset = 0) =>
{
if (host.Insights is not { } insights)
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
var points = await insights.Analytics.SeriesAsync(
ParseQuery(request),
bucket == "day" ? "day" : "hour",
groupBy is "model" or "source" ? groupBy : "agent",
TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)),
ct);
return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList());
});
api.MapGet("/usage/events", async (AppHost host, HttpRequest request, CancellationToken ct) =>
{
if (host.Insights is not { } insights)
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
var (from, to) = ParseRange(request);
var events = await insights.Analytics.EventsAsync(from, to, ct);
return Results.Ok(events.Select(e => new UsageEventDto(e.At, e.Kind, e.AgentId, e.Text)).ToList());
});
api.MapGet("/usage/facets", async (AppHost host, HttpRequest request, CancellationToken ct) =>
{
if (host.Insights is not { } insights)
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
var (from, to) = ParseRange(request);
var f = await insights.Analytics.FacetsAsync(from, to, ct);
return Results.Ok(new UsageFacetsDto(f.Agents, f.Models, f.Sources, f.Statuses));
});
api.MapGet("/runs/{runId}", async (AppHost host, string runId, CancellationToken ct) =>
{
if (host.Insights is not { } insights)
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
return await insights.GetRunAsync(runId, ct) is { } detail
? Results.Ok(ToDto(detail))
: Results.NotFound(new OutcomeDto(false, $"No run with id '{runId}'."));
});
api.MapGet("/prompt-history", async (
AppHost host, HttpRequest request, CancellationToken ct, int offset = 0, int limit = 50) =>
{
if (host.Insights?.Transcripts is not { } store)
return ApiEndpoints.Unavailable("Prompt history is not available in this instance.");
var (from, to) = ParseRange(request);
var (items, total) = await store.ListAsync(new TranscriptQuery
{
AgentId = request.Query["agent"].FirstOrDefault(),
From = from,
To = to,
Search = request.Query["q"].FirstOrDefault(),
Offset = offset,
Limit = limit
}, ct);
return Results.Ok(new TranscriptPageDto(items.Select(i => new TranscriptSummaryDto(
i.RunId, i.AgentId, i.Model, i.Source, i.TaskId, i.StartedAt, i.CompletedAt,
i.Status, i.Prompt, i.Response, i.SizeChars, i.Truncated)).ToList(), total));
});
api.MapGet("/prompt-history/settings", (AppHost host) => Results.Ok(SettingsDto(host)));
api.MapPut("/prompt-history/settings", (AppHost host, PromptHistorySettingsInputDto input) =>
{
var messages = new List<string>();
if (input.DefaultRetentionDays is { } days)
{
var result = host.AgentAdmin.SetDefaultPromptHistoryRetention(days);
if (!result.Ok) return result.ToResponse();
messages.Add(result.Message);
}
foreach (var agent in input.Agents ?? [])
{
var result = host.AgentAdmin.SetPromptHistoryRetention(agent.AgentId, agent.RetentionDays);
if (!result.Ok) return result.ToResponse();
messages.Add(result.Message);
}
return Results.Ok(new OutcomeDto(true, messages.Count == 0 ? "Nothing changed." : string.Join(" ", messages)));
});
}
// ─── Parsing ───
internal static (DateTime From, DateTime To) ParseRange(HttpRequest request)
{
var to = ParseDate(request.Query["to"].FirstOrDefault()) ?? DateTime.UtcNow.AddMinutes(1);
var from = ParseDate(request.Query["from"].FirstOrDefault()) ?? to - DefaultRange;
return (from, to);
}
internal static RunQuery ParseQuery(HttpRequest request)
{
var (from, to) = ParseRange(request);
var query = request.Query;
static IReadOnlyCollection<string> List(string? value)
=> string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
return new RunQuery
{
FromUtc = from,
ToUtc = to,
Agents = List(query["agent"].FirstOrDefault()),
Models = List(query["model"].FirstOrDefault()),
Sources = List(query["source"].FirstOrDefault()),
Statuses = List(query["status"].FirstOrDefault()),
MinCostUsd = decimal.TryParse(query["minCost"].FirstOrDefault(), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var min) ? min : null,
Search = query["q"].FirstOrDefault(),
Sort = query["sort"].FirstOrDefault() ?? "time",
Descending = !string.Equals(query["dir"].FirstOrDefault(), "asc", StringComparison.OrdinalIgnoreCase),
Offset = int.TryParse(query["offset"].FirstOrDefault(), out var o) ? o : 0,
Limit = int.TryParse(query["limit"].FirstOrDefault(), out var l) ? l : 50
};
}
private static DateTime? ParseDate(string? value)
=> DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
? DateTime.SpecifyKind(d, DateTimeKind.Utc)
: null;
// ─── Mapping ───
private static RunRowDto ToDto(RunRow row)
{
var r = row.Receipt;
return new RunRowDto(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, row.TaskTitle, r.Status,
r.StepCount, r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown,
r.DurationMs, r.ResultRef, r.OccurredAt);
}
private static RunDetailDto ToDto(RunDetail d) => new(
d.RunId,
d.Receipt is { } r
? new RunRowDto(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, d.TaskTitle, r.Status, r.StepCount,
r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown, r.DurationMs, r.ResultRef, r.OccurredAt)
: null,
d.ToolCalls.Select(c => new RunToolCallDto(c.OccurredAt, c.Tool, c.Status.ToString(), c.Arguments, c.Summary, c.DurationMs, c.Source)).ToList(),
d.Approvals.Select(c => c.ToDto()).ToList(),
d.Transcript is { } t
? new TranscriptDto(t.RunId, t.AgentId, t.Model, t.Source, t.TaskId, t.StartedAt, t.CompletedAt, t.Status,
t.Entries, t.SizeChars, t.Truncated)
: null,
d.SystemPrompt,
d.TranscriptState);
private static PromptHistorySettingsDto SettingsDto(AppHost host)
{
var instanceDays = PromptHistorySettings.Normalize(host.Instance.PromptHistory.RetentionDays);
return new PromptHistorySettingsDto(
instanceDays,
host.Instance.Agents.Select(a => new AgentRetentionDto(
a.AgentId,
string.IsNullOrWhiteSpace(a.DisplayName) ? a.AgentId : a.DisplayName,
a.PromptHistory.RetentionDays,
PromptHistorySettings.Resolve(a.PromptHistory.RetentionDays, instanceDays))).ToList());
}
}
// ─── DTOs (#660) ───
public sealed record RunRowDto(
string RunId, string AgentId, string Model, string Source, string? TaskId, string? TaskTitle,
string Status, int StepCount, int PromptTokens, int CompletionTokens, int CachedTokens,
decimal CostUsd, bool CostIsKnown, long DurationMs, string Result, DateTime OccurredAt);
public sealed record RunTotalsDto(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
decimal CostUsd, int RunsWithUnknownCost, int NotRun);
public sealed record RunPageDto(IReadOnlyList<RunRowDto> Items, int Total, RunTotalsDto Totals);
public sealed record SeriesPointDto(DateTime BucketStart, string Group, int Runs, long Tokens, decimal CostUsd);
public sealed record UsageEventDto(DateTime At, string Kind, string AgentId, string Text);
public sealed record UsageFacetsDto(
IReadOnlyList<string> Agents, IReadOnlyList<string> Models,
IReadOnlyList<string> Sources, IReadOnlyList<string> Statuses);
public sealed record RunToolCallDto(
DateTime At, string Tool, string Status, string Arguments, string Summary, long DurationMs, string Source);
/// <summary>
/// Entries go out as recorded. They contain model output and tool results - untrusted
/// text; the UI inserts them as text, never as HTML.
/// </summary>
public sealed record TranscriptDto(
string RunId, string AgentId, string Model, string Source, string? TaskId,
DateTime StartedAt, DateTime? CompletedAt, string Status,
IReadOnlyList<TranscriptEntry> Entries, int SizeChars, bool Truncated);
/// <param name="TranscriptState">recorded, disabled (retention "never"), missing, unavailable.</param>
public sealed record RunDetailDto(
string RunId,
RunRowDto? Receipt,
IReadOnlyList<RunToolCallDto> ToolCalls,
IReadOnlyList<StagedCallDto> Approvals,
TranscriptDto? Transcript,
string? SystemPrompt,
string TranscriptState);
public sealed record TranscriptSummaryDto(
string RunId, string AgentId, string Model, string Source, string? TaskId,
DateTime StartedAt, DateTime? CompletedAt, string Status,
string Prompt, string Response, int SizeChars, bool Truncated);
public sealed record TranscriptPageDto(IReadOnlyList<TranscriptSummaryDto> Items, int Total);
/// <param name="RetentionDays">Own setting of the agent; <c>null</c> = instance default.</param>
/// <param name="EffectiveRetentionDays">What applies: 0 = never, -1 = forever.</param>
public sealed record AgentRetentionDto(string AgentId, string DisplayName, int? RetentionDays, int EffectiveRetentionDays);
public sealed record PromptHistorySettingsDto(int DefaultRetentionDays, IReadOnlyList<AgentRetentionDto> Agents);
public sealed record AgentRetentionInputDto(string AgentId, int? RetentionDays);
public sealed record PromptHistorySettingsInputDto(int? DefaultRetentionDays, IReadOnlyList<AgentRetentionInputDto>? Agents);
@@ -29,6 +29,7 @@ internal static class ApiEndpoints
api.MapTasks(); api.MapTasks();
api.MapStaging(); api.MapStaging();
api.MapUsage(); api.MapUsage();
api.MapAnalytics();
api.MapLogs(); api.MapLogs();
app.MapEvents(); app.MapEvents();
@@ -198,6 +198,25 @@ public sealed class AgentAdministrationTests
host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt"); host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt");
} }
// ─── #660: prompt history retention ───
[Fact]
public void Prompt_history_retention_is_stored_per_agent_and_for_the_instance()
{
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("tresor", "Tresor", "modell"));
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", 0).Ok.ShouldBeTrue();
host.Host.AgentAdmin.SetDefaultPromptHistoryRetention(30).Ok.ShouldBeTrue();
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
reloaded.PromptHistory.RetentionDays.ShouldBe(30);
reloaded.Agents.Single(a => a.AgentId == "tresor").PromptHistory.RetentionDays.ShouldBe(0);
host.Host.AgentAdmin.SetPromptHistoryRetention("tresor", null).Ok.ShouldBeTrue();
host.Host.AgentAdmin.Find("tresor")!.PromptHistory.RetentionDays.ShouldBeNull("back to the instance default");
}
[Fact] [Fact]
public void Rechte_nur_fuer_zugewiesene_Werkzeuge() public void Rechte_nur_fuer_zugewiesene_Werkzeuge()
{ {
@@ -0,0 +1,143 @@
using ClawdDotNet.Core.Analytics;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Analytics;
/// <summary>
/// Usage analysis (#660): filter, sort, page and bucket run receipts. Totals cover the whole
/// filter, not only the page; runs refused before any model call are counted separately.
/// </summary>
public sealed class UsageAnalyticsTests
{
private static readonly DateTime Day = new(2026, 9, 19, 0, 0, 0, DateTimeKind.Utc);
private static RunReceipt Receipt(string agent, int hour, decimal cost, string model = "gemini", string status = "Completed",
string source = "task", string? task = null, int prompt = 1000, string result = "ERGEBNIS: ok")
=> new()
{
RunId = Guid.NewGuid().ToString("N"),
AgentId = agent,
Model = model,
Source = source,
TaskId = task,
Status = status,
StepCount = 3,
PromptTokens = prompt,
CompletionTokens = 100,
CostUsd = cost,
CostIsKnown = cost > 0,
ResultRef = result,
OccurredAt = Day.AddHours(hour)
};
private static UsageAnalytics Analytics(params RunReceipt[] receipts)
{
var audit = new InMemoryAuditRepository();
foreach (var r in receipts) audit.Receipts.Enqueue(r);
return new UsageAnalytics(audit, (id, _) => Task.FromResult<string?>(id == "t-1" ? "Wetter abrufen" : null));
}
private static RunQuery All => new() { FromUtc = Day, ToUtc = Day.AddDays(1) };
[Fact]
public async Task Totals_cover_the_whole_filter_not_only_the_page()
{
var analytics = Analytics(
Receipt("wetter", 1, 0.01m), Receipt("wetter", 2, 0.02m), Receipt("kurse", 3, 0.04m),
Receipt("kurse", 4, 0m, status: "BudgetExceeded", prompt: 0));
var page = await analytics.QueryRunsAsync(All with { Limit = 1 }, default);
page.Items.Count.ShouldBe(1);
page.Total.ShouldBe(4);
page.Totals.CostUsd.ShouldBe(0.07m);
page.Totals.NotRun.ShouldBe(1);
page.Totals.RunsWithUnknownCost.ShouldBe(0, "a refused run has no cost to know");
}
[Fact]
public async Task Filters_combine()
{
var analytics = Analytics(
Receipt("wetter", 1, 0.01m, model: "haiku"), Receipt("wetter", 2, 0.02m, model: "gemini"),
Receipt("kurse", 3, 0.04m, model: "gemini"));
var page = await analytics.QueryRunsAsync(All with { Agents = ["wetter"], Models = ["gemini"] }, default);
page.Total.ShouldBe(1);
page.Items.Single().Receipt.CostUsd.ShouldBe(0.02m);
(await analytics.QueryRunsAsync(All with { MinCostUsd = 0.015m }, default)).Total.ShouldBe(2);
}
[Fact]
public async Task The_search_covers_task_title_and_result()
{
var analytics = Analytics(
Receipt("wetter", 1, 0.01m, task: "t-1"),
Receipt("kurse", 2, 0.01m, result: "ERGEBNIS: NVDA bei 120"));
(await analytics.QueryRunsAsync(All with { Search = "wetter abrufen" }, default)).Items.Single().TaskTitle.ShouldBe("Wetter abrufen");
(await analytics.QueryRunsAsync(All with { Search = "nvda" }, default)).Items.Single().Receipt.AgentId.ShouldBe("kurse");
}
[Theory]
[InlineData("cost", true, 0.04)]
[InlineData("cost", false, 0.01)]
[InlineData("time", true, 0.02)]
public async Task Every_column_sorts(string sort, bool descending, double firstCost)
{
var analytics = Analytics(Receipt("a", 1, 0.01m), Receipt("b", 5, 0.02m), Receipt("c", 3, 0.04m));
var page = await analytics.QueryRunsAsync(All with { Sort = sort, Descending = descending }, default);
page.Items[0].Receipt.CostUsd.ShouldBe((decimal)firstCost);
}
[Fact]
public async Task Series_stack_per_bucket_and_group()
{
var analytics = Analytics(Receipt("wetter", 1, 0.01m), Receipt("wetter", 1, 0.02m), Receipt("kurse", 1, 0.04m), Receipt("wetter", 5, 0.1m));
var points = await analytics.SeriesAsync(All, "hour", "agent", TimeSpan.Zero, default);
points.Count.ShouldBe(3);
points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).CostUsd.ShouldBe(0.03m);
points.Single(p => p.Group == "wetter" && p.BucketStart == Day.AddHours(1)).Runs.ShouldBe(2);
}
[Fact]
public void A_day_is_the_viewers_day()
{
// 23:30 UTC is already the next day in Berlin (UTC+2).
var bucket = UsageAnalytics.BucketStart(Day.AddHours(23.5), "day", TimeSpan.FromHours(2));
bucket.ShouldBe(Day.AddDays(1).AddHours(-2), "local midnight of 20.09 expressed in UTC");
}
[Fact]
public async Task Budget_denials_and_model_changes_become_events()
{
var analytics = Analytics(
Receipt("wetter", 1, 0.01m, model: "haiku"),
Receipt("wetter", 2, 0.01m, model: "gemini"),
Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini"),
Receipt("wetter", 3, 0m, status: "BudgetExceeded", model: "gemini"));
var events = await analytics.EventsAsync(Day, Day.AddDays(1), default);
events.Single(e => e.Kind == "model_changed").Text.ShouldBe("haiku → gemini");
events.Single(e => e.Kind == "budget_exceeded").Text.ShouldContain("2 run(s)");
}
[Fact]
public async Task Facets_list_what_occurs()
{
var analytics = Analytics(Receipt("wetter", 1, 0.01m, source: "task"), Receipt("kurse", 1, 0.01m, source: "webui", model: "haiku"));
var facets = await analytics.FacetsAsync(Day, Day.AddDays(1), default);
facets.Agents.ShouldBe(["kurse", "wetter"]);
facets.Sources.ShouldBe(["task", "webui"]);
}
}
@@ -26,7 +26,7 @@ internal sealed class EngineFixture
public EngineFixture( public EngineFixture(
IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null, IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null,
IUsageRepository? usage = null) IUsageRepository? usage = null, Core.Transcripts.IRunTranscriptStore? transcripts = null)
{ {
Engine = new AgentEngine( Engine = new AgentEngine(
Client, Client,
@@ -37,7 +37,10 @@ internal sealed class EngineFixture
usageRepository: usage, usageRepository: usage,
auditRepository: audit, auditRepository: audit,
stagingGate: staging, stagingGate: staging,
clock: clock); clock: clock)
{
TranscriptStore = transcripts
};
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests. // Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null); Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
@@ -36,6 +36,11 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct) public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList()); => Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
public Task<IReadOnlyList<RunReceipt>> ListReceiptsAsync(DateTime fromUtc, DateTime toUtc, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts
.Where(r => r.OccurredAt >= fromUtc && r.OccurredAt < toUtc)
.OrderBy(r => r.OccurredAt).Take(limit).ToList());
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count); public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary> /// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
@@ -20,6 +20,9 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) public Task<StagedCall?> GetAsync(long id, CancellationToken ct)
=> Task.FromResult(_calls.GetValueOrDefault(id)); => Task.FromResult(_calls.GetValueOrDefault(id));
public Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(_calls.Values.Where(c => c.RunId == runId).OrderBy(c => c.Id).ToList());
public Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct) public Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
=> Task.FromResult(_calls.Values.Count(c => => Task.FromResult(_calls.Values.Count(c =>
c.Status == StagingStatus.Executed && c.AgentId == agentId && c.Tool == tool c.Status == StagingStatus.Executed && c.AgentId == agentId && c.Tool == tool
@@ -0,0 +1,244 @@
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Transcripts;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Transcripts;
/// <summary>
/// Prompt history (#660): prompt and response of every run are recorded, bounded in size,
/// scrubbed of recognisable secrets, searchable, and deleted once the retention of the
/// agent - or the instance default - says so.
/// </summary>
public sealed class PromptHistoryTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteRunTranscriptStore _store;
public PromptHistoryTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_store = new SqliteRunTranscriptStore(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is best effort */ }
}
private static ToolCall Call(string id, string name, string args)
=> new() { Id = id, Function = new ToolCallFunction { Name = name, Arguments = args } };
// ─── Retention settings ───
[Theory]
[InlineData(null, 14, 14)]
[InlineData(0, 14, 0)]
[InlineData(-1, 14, -1)]
[InlineData(365, 14, 365)]
[InlineData(null, 0, 0)]
[InlineData(-7, 14, -1)]
public void The_agent_setting_overrides_the_instance_default(int? agent, int instance, int expected)
=> PromptHistorySettings.Resolve(agent, instance).ShouldBe(expected);
// ─── Recorder ───
[Fact]
public void The_recorder_keeps_task_steps_tool_results_and_usage()
{
var recorder = new RunTranscriptRecorder();
recorder.Begin("You are a test.", "Fetch the weather.");
recorder.ModelStep(new ChatMessage { Role = "assistant", Content = "Let me look.", ToolCalls = [Call("c1", "WebFetch", "{\"url\":\"x\"}")] },
new Usage { PromptTokens = 120, CompletionTokens = 30, Cost = 0.0004m }, "tool_calls");
recorder.ToolResults([Call("c1", "WebFetch", "{}")], ["18 °C"]);
recorder.ModelStep(new ChatMessage { Role = "assistant", Content = "ERGEBNIS: ok - 18 °C" }, null, "stop");
var t = recorder.Build("run-1", "wetter", "m", "task", "t-1", "Completed");
t.Entries.Select(e => e.Kind).ShouldBe(["user", "assistant", "tool_result", "assistant"]);
t.Entries[1].ToolCalls!.Single().Name.ShouldBe("WebFetch");
t.Entries[1].PromptTokens.ShouldBe(120);
t.Entries[1].CostUsd.ShouldBe(0.0004m);
t.Entries[2].ToolName.ShouldBe("WebFetch");
t.SystemPromptHash.ShouldNotBeNull();
recorder.SystemPrompt.ShouldBe("You are a test.");
}
[Fact]
public void Long_tool_results_keep_start_and_end()
{
var recorder = new RunTranscriptRecorder();
var text = "START" + new string('x', 20_000) + "END";
recorder.ToolResults([Call("c", "FileRW", "{}")], [text]);
var entry = recorder.Build("r", "a", "m", "task", null, "Completed").Entries.Single();
entry.Clipped.ShouldBeTrue();
entry.Text!.Length.ShouldBeLessThan(RunTranscriptRecorder.MaxToolResultChars + 100);
entry.Text.ShouldStartWith("START");
entry.Text.ShouldEndWith("END");
}
[Fact]
public void A_runaway_run_stops_storing_text_but_keeps_the_heads()
{
var recorder = new RunTranscriptRecorder();
for (var i = 0; i < 100; i++)
recorder.ToolResults([Call($"c{i}", "WebFetch", "{}")], [new string('y', 4_000)]);
var t = recorder.Build("r", "a", "m", "task", null, "LoopLimitExceeded");
t.Truncated.ShouldBeTrue();
t.SizeChars.ShouldBeLessThanOrEqualTo(RunTranscriptRecorder.MaxTotalChars);
t.Entries.Count.ShouldBe(100, "every call is still listed");
t.Entries.Last().Text.ShouldBeNull();
}
[Theory]
[InlineData("key sk-or-v1-0123456789abcdef0123456789abcdef here")]
[InlineData("Server=db;Password=geheim123;Database=x")]
[InlineData("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijk")]
[InlineData("Lizenz ABCDE-12345-FGHIJ-67890-KLMNO")]
public void Recognisable_secrets_are_scrubbed(string text)
{
var recorder = new RunTranscriptRecorder();
recorder.Begin(null, text);
var stored = recorder.Build("r", "a", "m", "task", null, "Completed").Entries.Single().Text!;
stored.ShouldContain(SecretScrubber.Mask);
stored.ShouldNotContain("geheim123");
stored.ShouldNotContain("0123456789abcdef0123456789abcdef");
}
[Fact]
public void A_disabled_recorder_keeps_nothing()
{
var recorder = RunTranscriptRecorder.Disabled;
recorder.Begin("s", "u");
recorder.Enabled.ShouldBeFalse();
}
// ─── Store ───
private static RunTranscript Transcript(string runId, string agent, DateTime started, string text = "Hallo Grüße")
=> new()
{
RunId = runId,
AgentId = agent,
Model = "m",
Source = "task",
StartedAt = started,
CompletedAt = started.AddSeconds(5),
Status = "Completed",
Entries =
[
new TranscriptEntry { Kind = TranscriptEntryKind.User, At = started, Text = text },
new TranscriptEntry { Kind = TranscriptEntryKind.Assistant, At = started, Text = "ERGEBNIS: ok" }
],
SizeChars = text.Length + 12
};
[Fact]
public async Task A_transcript_is_stored_and_read_back_with_its_system_prompt()
{
var t = Transcript("run-1", "wetter", DateTime.UtcNow) with { SystemPromptHash = RunTranscriptRecorder.Hash("sys") };
await _store.SaveAsync(t, "sys", default);
var back = (await _store.GetAsync("run-1", default)).ShouldNotBeNull();
back.Entries.Count.ShouldBe(2);
back.Entries[0].Text.ShouldBe("Hallo Grüße");
(await _store.GetSystemPromptAsync(t.SystemPromptHash!, default)).ShouldBe("sys");
}
[Fact]
public async Task The_search_finds_text_with_umlauts_and_filters_by_agent()
{
await _store.SaveAsync(Transcript("a", "wetter", DateTime.UtcNow, "Unwetterwarnung für Berlin"), null, default);
await _store.SaveAsync(Transcript("b", "kurse", DateTime.UtcNow, "Kurs von NVDA"), null, default);
var (items, total) = await _store.ListAsync(new TranscriptQuery { Search = "für Berlin" }, default);
total.ShouldBe(1);
items.Single().RunId.ShouldBe("a");
items.Single().Prompt.ShouldBe("Unwetterwarnung für Berlin");
items.Single().Response.ShouldBe("ERGEBNIS: ok");
(await _store.ListAsync(new TranscriptQuery { AgentId = "kurse" }, default)).Total.ShouldBe(1);
}
[Fact]
public async Task Purge_follows_the_retention_of_each_agent()
{
var now = DateTime.UtcNow;
await _store.SaveAsync(Transcript("old-default", "a", now.AddDays(-20)), null, default);
await _store.SaveAsync(Transcript("new-default", "a", now.AddDays(-2)), null, default);
await _store.SaveAsync(Transcript("old-forever", "debug", now.AddDays(-400)), null, default);
await _store.SaveAsync(Transcript("fresh-never", "secret", now), null, default);
var deleted = await _store.PurgeAsync(agent => agent switch
{
"debug" => PromptHistorySettings.Forever,
"secret" => PromptHistorySettings.Never,
_ => 14
}, now, default);
deleted.ShouldBe(2);
(await _store.GetAsync("old-default", default)).ShouldBeNull();
(await _store.GetAsync("new-default", default)).ShouldNotBeNull();
(await _store.GetAsync("old-forever", default)).ShouldNotBeNull();
(await _store.GetAsync("fresh-never", default)).ShouldBeNull("never also removes what was recorded before");
}
// ─── Engine ───
[Fact]
public async Task Every_run_leaves_its_transcript()
{
var fixture = new EngineFixture(transcripts: _store).WithTool(FakeTool.Returning("18 °C", "Wetter"));
var agent = fixture.AddAgent("wetterdienst", "Wetter");
fixture.Client.RespondsWithToolCall("Wetter", "{\"ort\":\"Berlin\"}").RespondsWithText("Es sind 18 °C.");
await fixture.Engine.RunAsync(agent, "Wie ist das Wetter?", "test-instance", default);
var (items, _) = await _store.ListAsync(new TranscriptQuery(), default);
var t = (await _store.GetAsync(items.Single().RunId, default)).ShouldNotBeNull();
t.AgentId.ShouldBe("wetterdienst");
t.Entries.Select(e => e.Kind).ShouldBe(["user", "assistant", "tool_result", "assistant"]);
t.Entries[0].Text.ShouldContain("Wie ist das Wetter?");
t.Entries[2].Text.ShouldNotBeNull().ShouldContain("18 °C", Case.Sensitive, "stored as the model saw it, frame included");
t.Entries[3].Text.ShouldBe("Es sind 18 °C.");
(await _store.GetSystemPromptAsync(t.SystemPromptHash!, default)).ShouldNotBeNull().ShouldContain("wetterdienst");
}
[Fact]
public async Task An_agent_set_to_never_is_not_recorded()
{
var fixture = new EngineFixture(transcripts: _store);
var agent = fixture.AddAgent("tresor");
agent.PromptHistory.RetentionDays = PromptHistorySettings.Never;
fixture.Client.RespondsWithText("geheim");
await fixture.Engine.RunAsync(agent, "Passwort?", "test-instance", default);
(await _store.ListAsync(new TranscriptQuery(), default)).Total.ShouldBe(0);
}
[Fact]
public async Task Chats_are_recorded_per_run_not_as_the_whole_history()
{
var fixture = new EngineFixture(transcripts: _store);
var agent = fixture.AddAgent("assistent");
fixture.Client.RespondsWithText("Erste Antwort").RespondsWithText("Zweite Antwort");
await fixture.Engine.ChatAsync(agent, "Erste Frage", "test-instance", default);
await fixture.Engine.ChatAsync(agent, "Zweite Frage", "test-instance", default);
var (items, _) = await _store.ListAsync(new TranscriptQuery(), default);
items.Count.ShouldBe(2);
var second = (await _store.GetAsync(items.First().RunId, default)).ShouldNotBeNull();
second.Entries.Count(e => e.Kind == TranscriptEntryKind.User).ShouldBe(1);
second.Entries[0].Text.ShouldContain("Zweite Frage");
}
}