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