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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user