feat(ui): add decision-log reader and overlay components

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 2dc711a55c
9 changed files with 469 additions and 0 deletions

4
ui/JevOverlay/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
bin/
obj/
# Local absolute path to the decision log.
JevOverlay.config.json

View file

@ -0,0 +1,120 @@
using System.Text.Json;
namespace JevOverlay.Core;
public readonly record struct DecisionKey(string Session, int Step);
public sealed class DecisionEntry(DecisionKey key, JsonElement record)
{
private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
public DecisionKey Key { get; } = key;
public JsonElement Record { get; } = record;
public string Action => Text(Record, "action");
public string Source => Text(Record, "source", "unknown");
public string Status { get; internal set; } = "Proposal only; execution is not recorded.";
public string? SessionEnd { get; internal set; }
public string Title => $"#{Key.Step} · {Source.ToUpperInvariant()} · {Text(Record, "state_type")}";
public string Summary()
{
string latency = "";
var jev = Field(Record, "jev");
if (Field(jev, "latency_s") is { ValueKind: JsonValueKind.Number } seconds &&
seconds.TryGetDouble(out double number) && double.IsFinite(number))
latency = $" · {number * 1000:0} ms";
return Limit($"{Title}\nSession: {Key.Session} · {Text(Record, "ts")}\n" +
$"Proposed: {Action}\nParameters: {Field(Record, "params")}\n" +
$"{Status}\n" + (SessionEnd is null ? "" : $"{SessionEnd}\n") +
$"\nReason: {Text(Record, "reason", "Not recorded")}\n" +
$"Recorded confidence: {Field(Record, "confidence")?.ToString() ?? ""}{latency}\n" +
(string.IsNullOrEmpty(Text(Record, "error")) ? "" : $"Error: {Text(Record, "error")}\n") +
"Confidence does not prove correctness.");
}
public string Evidence()
{
var jev = Field(Record, "jev");
if (jev is null || jev.Value.ValueKind == JsonValueKind.Null)
return "No Jev response recorded for this decision.\nThe source field identifies the policy path.";
return Limit("Recorded Jev response (not private reasoning).\n" +
"The recorded answer gate may differ from the policy's final gate.\n\n" +
JsonSerializer.Serialize(jev.Value, Pretty));
}
internal static string Limit(string value) => value.Length <= 16000 ? value : value[..16000] + "\n[Display truncated]";
internal static JsonElement? Field(JsonElement? obj, string name) =>
obj is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(name, out var value)
? value : null;
internal static string Text(JsonElement? obj, string name, string fallback = "") =>
Field(obj, name) is { ValueKind: JsonValueKind.String } value ? value.GetString()! : fallback;
}
/// <summary>Joins recorded events by session and step, never by wall-clock time.</summary>
public sealed class DecisionHistory
{
public const int Capacity = 100;
private readonly List<DecisionEntry> entries = [];
public IReadOnlyList<DecisionEntry> Entries => entries;
public int SkippedLines { get; private set; }
public string? Notice { get; private set; }
public void Apply(TailBatch batch)
{
if (batch.Reset)
{
entries.Clear();
Notice = null;
SkippedLines = 0;
}
SkippedLines += batch.SkippedLines;
foreach (string line in batch.Lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var document = JsonDocument.Parse(line);
if (!Consume(document.RootElement)) SkippedLines++;
}
catch (JsonException) { SkippedLines++; }
}
}
private bool Consume(JsonElement root)
{
string session = DecisionEntry.Text(root, "session");
string kind = DecisionEntry.Text(root, "event");
if (session.Length == 0 || kind.Length == 0) return false;
if (kind == "session_end")
{
string message = $"Session ended: {DecisionEntry.Text(root, "reason", "unspecified")}";
foreach (var entry in entries.Where(e => e.Key.Session == session)) entry.SessionEnd = message;
Notice = $"{session}: {message}";
return true;
}
if (kind is not ("decide" or "action_error" or "action_rejected" or "model_error" or
"fallback_error" or "policy_error" or "no_decision"))
return true; // Other runner events do not describe a decision or its status.
if (DecisionEntry.Field(root, "step") is not { ValueKind: JsonValueKind.Number } stepValue ||
!stepValue.TryGetInt32(out int step) || step < 0) return false;
var key = new DecisionKey(session, step);
if (kind == "decide")
{
if (DecisionEntry.Text(root, "action").Length == 0) return false;
entries.RemoveAll(entry => entry.Key == key);
entries.Insert(0, new DecisionEntry(key, root.Clone()));
if (entries.Count > Capacity) entries.RemoveAt(entries.Count - 1);
Notice = null;
}
else if (kind is "action_error" or "action_rejected")
{
var entry = entries.Find(entry => entry.Key == key);
string action = DecisionEntry.Text(root, "action");
if (entry is not null && (action.Length == 0 || action == entry.Action))
entry.Status = kind == "action_error"
? $"Action request error; completion unknown: {DecisionEntry.Text(root, "error")}"
: $"Action request rejected: {DecisionEntry.Text(root, "message")}";
}
else Notice = DecisionEntry.Limit($"{session} #{step} · {kind}: {DecisionEntry.Text(root, "error")}");
return true;
}
}

View file

@ -0,0 +1,93 @@
using System.Text;
namespace JevOverlay.Core;
public sealed record TailBatch(IReadOnlyList<string> Lines, bool Reset = false,
string? Error = null, bool CatchingUp = false, int SkippedLines = 0);
/// <summary>Single-worker, bounded JSONL reader. It never writes to the source.</summary>
public sealed class LogTail(string path)
{
public const int MaxReadBytes = 256 * 1024;
public const int MaxLineBytes = 128 * 1024;
private static readonly UTF8Encoding Utf8 = new(false, true);
private readonly MemoryStream pending = new();
private long offset;
private byte[] prefix = [];
private byte[] checkpoint = [];
private bool initialized;
private bool discardLine;
public TailBatch Poll()
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
long length = stream.Length;
bool reset = !initialized || length < offset ||
!Matches(stream, 0, prefix) ||
!Matches(stream, offset - checkpoint.Length, checkpoint);
if (reset)
{
// Start near the end of an existing log, not at the start of a long run.
offset = Math.Max(0, length - MaxReadBytes);
pending.SetLength(0);
discardLine = offset > 0;
prefix = ReadAt(stream, 0, (int)Math.Min(64, length));
initialized = true;
}
byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset));
offset += bytes.Length;
checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset));
List<string> lines = [];
int skipped = 0;
foreach (byte value in bytes)
{
if (value == (byte)'\n')
{
if (!discardLine && pending.Length > 0)
{
try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); }
catch (DecoderFallbackException) { skipped++; }
}
discardLine = false;
pending.SetLength(0);
}
else if (!discardLine)
{
if (pending.Length == MaxLineBytes)
{
pending.SetLength(0);
discardLine = true;
skipped++;
}
else pending.WriteByte(value);
}
}
return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new([], Error: "Log unavailable. Check log_path and file permissions.");
}
}
private static bool Matches(FileStream stream, long position, byte[] expected) =>
expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected);
private static byte[] ReadAt(FileStream stream, long position, int count)
{
stream.Position = position;
byte[] buffer = new byte[count];
int read = 0;
while (read < count)
{
int next = stream.Read(buffer, read, count - read);
if (next == 0) break;
read += next;
}
return read == count ? buffer : buffer[..read];
}
}

View file

@ -0,0 +1,21 @@
using System.Text.Json;
namespace JevOverlay.Core;
public static class OverlayConfig
{
public const string FileName = "JevOverlay.config.json";
public static string LoadLogPath(string modDirectory)
{
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(modDirectory, FileName)));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("log_path", out var value) ||
value.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(value.GetString()) ||
!Path.IsPathFullyQualified(value.GetString()!))
throw new FormatException("log_path must be an absolute file path.");
return Path.GetFullPath(value.GetString()!);
}
}

View file

@ -0,0 +1,3 @@
{
"log_path": "/absolute/path/to/sts2-bot/capture/decisions.jsonl"
}

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<AssemblyName>JevOverlay</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="Mod.cs" />
<Compile Include="Core/**/*.cs" />
<Compile Include="UI/**/*.cs" />
<Reference Include="sts2">
<HintPath>$(STS2GameDataDir)/sts2.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="GodotSharp">
<HintPath>$(STS2GameDataDir)/GodotSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<None Include="JevOverlay.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CheckGameReferences" BeforeTargets="ResolveReferences">
<Error Condition="!Exists('$(STS2GameDataDir)/sts2.dll') or !Exists('$(STS2GameDataDir)/GodotSharp.dll')"
Text="Set STS2GameDataDir to the installed game's data directory containing sts2.dll and GodotSharp.dll." />
</Target>
</Project>

View file

@ -0,0 +1,10 @@
{
"id": "JevOverlay",
"name": "Jev Decision Log",
"author": "sts2-bot contributors",
"description": "Read-only in-game viewer for the bot's decision log.",
"version": "0.1.0",
"has_pck": false,
"has_dll": true,
"affects_gameplay": false
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!-- This mod and its tests need no external packages. -->
<packageSources>
<clear />
</packageSources>
</configuration>

View file

@ -0,0 +1,184 @@
using Godot;
using JevOverlay.Core;
namespace JevOverlay.UI;
/// <summary>Godot controls only. This view cannot read files or call the game API.</summary>
public sealed class OverlayView
{
private readonly CanvasLayer layer = new() { Name = "JevDecisionOverlay", Layer = 100 };
private readonly PanelContainer panel = new() { MouseFilter = Control.MouseFilterEnum.Stop };
private readonly VBoxContainer body = new();
private readonly Label status = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart };
private readonly RichTextLabel summary = TextArea();
private readonly RichTextLabel evidence = TextArea();
private readonly ItemList history = new()
{
CustomMinimumSize = new(0, 110),
FocusMode = Control.FocusModeEnum.None,
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
};
private readonly Button collapse = Button("", "Collapse panel (F8)");
private readonly Button details = Button("Show questions and answers");
private readonly Button follow = Button("Follow latest");
private IReadOnlyList<DecisionEntry> entries = [];
private DecisionKey? selected;
private bool following = true;
private bool collapsed;
private bool expanded;
public OverlayView(SceneTree tree)
{
var root = new Control { MouseFilter = Control.MouseFilterEnum.Ignore };
layer.AddChild(root);
tree.Root.AddChild(layer);
root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
root.AddChild(panel);
panel.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopRight);
var style = new StyleBoxFlat
{
BgColor = new Color(0.055f, 0.065f, 0.085f, 0.96f),
BorderColor = new Color(0.3f, 0.4f, 0.5f),
BorderWidthLeft = 1, BorderWidthRight = 1, BorderWidthTop = 1, BorderWidthBottom = 1,
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
ContentMarginLeft = 12, ContentMarginRight = 12,
ContentMarginTop = 10, ContentMarginBottom = 10,
};
panel.AddThemeStyleboxOverride("panel", style);
panel.Theme = new Theme { DefaultFontSize = 16 };
panel.AddThemeColorOverride("font_color", new Color(0.92f, 0.94f, 0.96f));
var layout = new VBoxContainer();
layout.AddThemeConstantOverride("separation", 8);
panel.AddChild(layout);
var header = new HBoxContainer();
layout.AddChild(header);
header.AddChild(new Label
{
Text = "Jev decisions · read only",
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
MouseFilter = Control.MouseFilterEnum.Ignore,
});
header.AddChild(collapse);
collapse.Pressed += ToggleCollapsed;
layout.AddChild(body);
body.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
body.AddThemeConstantOverride("separation", 8);
body.AddChild(status);
body.AddChild(summary);
body.AddChild(details);
body.AddChild(evidence);
evidence.Visible = false;
details.Pressed += () =>
{
expanded = !expanded;
evidence.Visible = expanded;
details.Text = expanded ? "Hide questions and answers" : "Show questions and answers";
};
body.AddChild(new Label { Text = "Recent decisions · select to inspect" });
body.AddChild(history);
body.AddChild(follow);
follow.ToggleMode = true;
follow.ButtonPressed = true;
follow.Toggled += enabled =>
{
following = enabled;
if (enabled) SelectLatest();
};
history.ItemSelected += index =>
{
if (index < 0 || index >= entries.Count) return;
following = false;
follow.SetPressedNoSignal(false);
selected = entries[(int)index].Key;
RenderSelection();
};
summary.Text = "Waiting for decision records.\nF8 collapses or expands this panel.";
}
public void ToggleCollapsed()
{
collapsed = !collapsed;
body.Visible = !collapsed;
collapse.Text = collapsed ? "+" : "";
collapse.TooltipText = collapsed ? "Expand panel (F8)" : "Collapse panel (F8)";
}
public void Resize(Vector2 viewport)
{
panel.OffsetLeft = -Math.Min(440, Math.Max(280, viewport.X - 32));
panel.OffsetRight = -16;
panel.OffsetTop = 64;
panel.OffsetBottom = collapsed ? 112 : Math.Min(viewport.Y - 16, expanded ? 764 : 584);
}
public void SetStatus(string text, string? tooltip = null)
{
status.Text = text;
status.TooltipText = tooltip ?? text;
}
public void Render(DecisionHistory model)
{
// Copy the list; selection must refer to the same ordering as the visible rows.
entries = model.Entries.ToArray();
history.Clear();
foreach (var entry in entries)
{
int index = history.AddItem($"#{entry.Key.Step} {entry.Source.ToUpperInvariant()} {entry.Action}");
history.SetItemTooltip(index, $"Session: {entry.Key.Session}\n{entry.Status}");
history.SetItemCustomFgColor(index, entry.Source switch
{
"jev" => new Color(0.55f, 0.8f, 1f),
"fallback" => new Color(1f, 0.75f, 0.4f),
_ => new Color(0.8f, 0.85f, 0.85f),
});
}
if (following) SelectLatest();
else RenderSelection();
}
private void SelectLatest()
{
selected = entries.Count == 0 ? null : entries[0].Key;
RenderSelection();
if (entries.Count > 0) history.EnsureCurrentIsVisible();
}
private void RenderSelection()
{
int index = -1;
for (int i = 0; i < entries.Count; i++)
if (entries[i].Key == selected) { index = i; break; }
if (index < 0)
{
summary.Text = selected is null ? "Waiting for decision records."
: "This decision is no longer in the recent history. Select Follow latest.";
evidence.Text = "No decision selected.";
}
else
{
history.Select(index);
summary.Text = entries[index].Summary();
evidence.Text = entries[index].Evidence();
}
}
public void Dispose() => layer.QueueFree();
private static Button Button(string text, string tooltip = "") => new()
{
Text = text, TooltipText = tooltip, FocusMode = Control.FocusModeEnum.None,
};
private static RichTextLabel TextArea() => new()
{
BbcodeEnabled = false,
SelectionEnabled = true,
ScrollActive = true,
CustomMinimumSize = new(0, 90),
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
FocusMode = Control.FocusModeEnum.None,
};
}