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:
@@ -140,6 +140,15 @@ wenn keines dransteht.
|
||||
Anwendung ruft `observe` und `status` nicht auf. Wer den Instanzmanager baut
|
||||
(#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
|
||||
|
||||
| Dokument | Wofür |
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||
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.
|
||||
|
||||
@@ -98,6 +98,12 @@ public sealed class AppHost : IAsyncDisposable
|
||||
public TaskboardService? Board { 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>
|
||||
public ModelPricingCatalog? Pricing { get; private init; }
|
||||
|
||||
@@ -525,6 +531,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
var dauerfreigaben = new SqliteDauerfreigabeRepository(storage);
|
||||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben);
|
||||
var usage = new SqliteUsageRepository(storage);
|
||||
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricing = new ModelPricingCatalog();
|
||||
@@ -539,6 +546,8 @@ public sealed class AppHost : IAsyncDisposable
|
||||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||||
{
|
||||
InstanceBudget = instance.Budget,
|
||||
TranscriptStore = transcripts,
|
||||
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
|
||||
MaxDelegationDepth = instance.MaxDelegationDepth,
|
||||
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds)
|
||||
};
|
||||
@@ -574,6 +583,11 @@ public sealed class AppHost : IAsyncDisposable
|
||||
Staging = staging,
|
||||
Board = board,
|
||||
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,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey),
|
||||
@@ -591,6 +605,16 @@ public sealed class AppHost : IAsyncDisposable
|
||||
|
||||
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
|
||||
// mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf
|
||||
// "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.");
|
||||
}
|
||||
|
||||
/// <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(
|
||||
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>
|
||||
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>
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
|
||||
|
||||
@@ -99,6 +99,24 @@ public sealed class SqliteAuditRepository : IAuditRepository
|
||||
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)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
|
||||
@@ -145,6 +145,12 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("budget")]
|
||||
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>
|
||||
/// Übersetzt <see cref="ReasoningEffort"/> in das Anfragefeld. Null heißt:
|
||||
/// nichts mitschicken, das Modell denkt wie voreingestellt.
|
||||
|
||||
@@ -124,6 +124,13 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("budget")]
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -127,6 +127,15 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
@@ -161,9 +170,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
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 RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -174,7 +185,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
CancellationToken externalCt,
|
||||
string runId,
|
||||
string? source,
|
||||
DelegationChain chain)
|
||||
DelegationChain chain,
|
||||
Transcripts.RunTranscriptRecorder recorder)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||||
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.User(MitZeit(userMessage, source)));
|
||||
recorder.Begin(systemPrompt, messages[^1].Content?.ToString() ?? userMessage);
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
@@ -229,6 +242,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
// sie bezahlt ist statt danach.
|
||||
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
|
||||
{
|
||||
recorder.Event("Context compacted");
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)");
|
||||
}
|
||||
|
||||
@@ -249,6 +263,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
|
||||
var choice = response.Choices.FirstOrDefault();
|
||||
if (choice is not null)
|
||||
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
|
||||
if (choice is null)
|
||||
{
|
||||
finalMessage = "[No response from model]";
|
||||
@@ -284,6 +300,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
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",
|
||||
agentConfig.AgentId, loopGuard.Steps);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -314,6 +332,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
agentConfig.AgentId);
|
||||
messages.Add(ChatMessage.Assistant(text));
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -468,9 +487,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
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 RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
@@ -487,7 +508,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
CancellationToken runCt,
|
||||
string? source,
|
||||
string runId,
|
||||
DelegationChain chain)
|
||||
DelegationChain chain,
|
||||
Transcripts.RunTranscriptRecorder recorder)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -532,6 +554,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
messages.Add(ChatMessage.User(MitZeit(routedMessage, 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;
|
||||
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.
|
||||
if (await _compactor.CompactIfNeededAsync(messages, agentConfig.LoopGuard, agentConfig.Model, ct, tally.Add))
|
||||
{
|
||||
recorder.Event("Context compacted");
|
||||
PersistChatState(agentConfig.AgentId);
|
||||
OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert");
|
||||
}
|
||||
@@ -581,6 +607,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
|
||||
var choice = response.Choices.FirstOrDefault();
|
||||
if (choice is not null)
|
||||
recorder.ModelStep(choice.Message, response.Usage, choice.FinishReason);
|
||||
if (choice is null)
|
||||
{
|
||||
finalMessage = "[No response from model]";
|
||||
@@ -614,6 +642,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
finally
|
||||
{
|
||||
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
|
||||
recorder.ToolResults(assistantMessage.ToolCalls, toolResults);
|
||||
}
|
||||
|
||||
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",
|
||||
agentConfig.AgentId, loopGuard.Steps);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.LeerNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.LeerNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -647,6 +677,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
messages.Add(ChatMessage.Assistant(text));
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", text, source);
|
||||
messages.Add(ChatMessage.User(LaufAbschluss.ErgebnisNachfrage));
|
||||
recorder.EngineNote(LaufAbschluss.ErgebnisNachfrage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1746,6 +1777,37 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
/// <summary>
|
||||
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
|
||||
/// </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(
|
||||
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
|
||||
{
|
||||
|
||||
@@ -122,6 +122,20 @@ public sealed class SqliteStagingRepository : IStagingRepository
|
||||
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)
|
||||
{
|
||||
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".
|
||||
/// </summary>
|
||||
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<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
|
||||
public Task<IReadOnlyList<StagedCall>> ListForRunAsync(string runId, CancellationToken ct) => _repo.ListForRunAsync(runId, ct);
|
||||
|
||||
// ─── Dauerfreigaben (#641) ───
|
||||
|
||||
|
||||
@@ -288,6 +288,37 @@ public sealed class SqliteStorage
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
|
||||
ON Dauerfreigaben (AgentId, Tool, Operation);
|
||||
|
||||
-- #660: Prompt history - prompt and response of every run, kept as long as the
|
||||
-- retention of the agent allows. System prompts are stored once per text.
|
||||
CREATE TABLE IF NOT EXISTS RunTranscripts (
|
||||
RunId TEXT PRIMARY KEY,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT '',
|
||||
TaskId TEXT NULL,
|
||||
StartedAt TEXT NOT NULL,
|
||||
CompletedAt TEXT NULL,
|
||||
Status TEXT NOT NULL DEFAULT '',
|
||||
SystemPromptHash TEXT NULL,
|
||||
Prompt TEXT NOT NULL DEFAULT '',
|
||||
Response TEXT NOT NULL DEFAULT '',
|
||||
EntriesJson TEXT NOT NULL DEFAULT '[]',
|
||||
SizeChars INTEGER NOT NULL DEFAULT 0,
|
||||
Truncated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Agent
|
||||
ON RunTranscripts (AgentId, StartedAt);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunTranscripts_Started
|
||||
ON RunTranscripts (StartedAt DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS SystemPrompts (
|
||||
Hash TEXT PRIMARY KEY,
|
||||
Text TEXT NOT NULL,
|
||||
FirstSeenAt TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1415,3 +1415,309 @@ button.klein {
|
||||
margin-top: 10px;
|
||||
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 { rendereSicherung } from "./views/backup.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 ───
|
||||
|
||||
@@ -48,6 +53,12 @@ registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(
|
||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||
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("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); });
|
||||
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
|
||||
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}"
|
||||
}
|
||||
@@ -71,12 +71,24 @@
|
||||
|
||||
<div class="nav-gruppe">
|
||||
<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">
|
||||
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
|
||||
</svg>
|
||||
<span class="nav-label">Tokennutzung</span>
|
||||
</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">
|
||||
<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"/>
|
||||
|
||||
@@ -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() {
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const pfadTeile = pfad.split("/").filter(Boolean);
|
||||
|
||||
@@ -98,7 +112,15 @@ function aktualisiereAktivenNavEintrag(pfad) {
|
||||
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
|
||||
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");
|
||||
} else {
|
||||
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.MapStaging();
|
||||
api.MapUsage();
|
||||
api.MapAnalytics();
|
||||
api.MapLogs();
|
||||
|
||||
app.MapEvents();
|
||||
|
||||
@@ -198,6 +198,25 @@ public sealed class AgentAdministrationTests
|
||||
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]
|
||||
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(
|
||||
IAuditRepository? audit = null, StagingGate? staging = null, TimeProvider? clock = null,
|
||||
IUsageRepository? usage = null)
|
||||
IUsageRepository? usage = null, Core.Transcripts.IRunTranscriptStore? transcripts = null)
|
||||
{
|
||||
Engine = new AgentEngine(
|
||||
Client,
|
||||
@@ -37,7 +37,10 @@ internal sealed class EngineFixture
|
||||
usageRepository: usage,
|
||||
auditRepository: audit,
|
||||
stagingGate: staging,
|
||||
clock: clock);
|
||||
clock: clock)
|
||||
{
|
||||
TranscriptStore = transcripts
|
||||
};
|
||||
|
||||
// Kein Agent-Verzeichnis → keine Persistenz auf die Platte während der Tests.
|
||||
Engine.SetAgentConfigProvider(() => _agents, "test-instance", _ => null);
|
||||
|
||||
@@ -36,6 +36,11 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
|
||||
public Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
|
||||
=> 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);
|
||||
|
||||
/// <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)
|
||||
=> 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)
|
||||
=> Task.FromResult(_calls.Values.Count(c =>
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user