using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Core.Transcripts; /// /// 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. /// /// Recording is bounded: tool results are clipped to , /// any single text to , and the whole transcript to /// . 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. /// /// A disabled recorder () accepts every call and keeps /// nothing, so the engine never has to branch. /// 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 _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; } /// The message as sent by its author - without the time line the engine prepends. public string? PromptPreview { 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 ─── /// What the model received, including what the engine added. /// The message as its author wrote it - the list preview. public void Begin(string? systemPrompt, string sentMessage, string? originalMessage = null) { if (!Enabled) return; SystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? null : SecretScrubber.Scrub(systemPrompt); PromptPreview = SecretScrubber.Scrub(originalMessage ?? sentMessage); Add(TranscriptEntryKind.User, sentMessage, MaxTextChars); } /// A message the engine sent on its own (follow-up after an empty answer etc.). 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); } /// One model response with the usage of exactly this request. 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 calls, IReadOnlyList 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), PromptPreview = PromptPreview, 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); } /// Keeps start and end — the end of a tool result is often the interesting part. 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..]; } } /// /// 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. /// 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(@"(?\b(?:password|passwort|pwd|secret|api[_-]?key|token)\b)(?\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(); }