diff --git a/ui/JevOverlay/.gitignore b/ui/JevOverlay/.gitignore
new file mode 100644
index 0000000..3b7b156
--- /dev/null
+++ b/ui/JevOverlay/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+# Local absolute path to the decision log.
+JevOverlay.config.json
diff --git a/ui/JevOverlay/Core/DecisionHistory.cs b/ui/JevOverlay/Core/DecisionHistory.cs
new file mode 100644
index 0000000..824e167
--- /dev/null
+++ b/ui/JevOverlay/Core/DecisionHistory.cs
@@ -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;
+}
+
+/// Joins recorded events by session and step, never by wall-clock time.
+public sealed class DecisionHistory
+{
+ public const int Capacity = 100;
+ private readonly List entries = [];
+ public IReadOnlyList 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;
+ }
+}
diff --git a/ui/JevOverlay/Core/LogTail.cs b/ui/JevOverlay/Core/LogTail.cs
new file mode 100644
index 0000000..20f4b30
--- /dev/null
+++ b/ui/JevOverlay/Core/LogTail.cs
@@ -0,0 +1,93 @@
+using System.Text;
+
+namespace JevOverlay.Core;
+
+public sealed record TailBatch(IReadOnlyList Lines, bool Reset = false,
+ string? Error = null, bool CatchingUp = false, int SkippedLines = 0);
+
+/// Single-worker, bounded JSONL reader. It never writes to the source.
+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 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];
+ }
+}
diff --git a/ui/JevOverlay/Core/OverlayConfig.cs b/ui/JevOverlay/Core/OverlayConfig.cs
new file mode 100644
index 0000000..7e4e692
--- /dev/null
+++ b/ui/JevOverlay/Core/OverlayConfig.cs
@@ -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()!);
+ }
+}
diff --git a/ui/JevOverlay/JevOverlay.config.example.json b/ui/JevOverlay/JevOverlay.config.example.json
new file mode 100644
index 0000000..4500868
--- /dev/null
+++ b/ui/JevOverlay/JevOverlay.config.example.json
@@ -0,0 +1,3 @@
+{
+ "log_path": "/absolute/path/to/sts2-bot/capture/decisions.jsonl"
+}
diff --git a/ui/JevOverlay/JevOverlay.csproj b/ui/JevOverlay/JevOverlay.csproj
new file mode 100644
index 0000000..4d5aa72
--- /dev/null
+++ b/ui/JevOverlay/JevOverlay.csproj
@@ -0,0 +1,27 @@
+
+
+ net9.0
+ JevOverlay
+ enable
+ enable
+ false
+
+
+
+
+
+
+ $(STS2GameDataDir)/sts2.dll
+ false
+
+
+ $(STS2GameDataDir)/GodotSharp.dll
+ false
+
+
+
+
+
+
+
diff --git a/ui/JevOverlay/JevOverlay.json b/ui/JevOverlay/JevOverlay.json
new file mode 100644
index 0000000..113ff0a
--- /dev/null
+++ b/ui/JevOverlay/JevOverlay.json
@@ -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
+}
diff --git a/ui/JevOverlay/NuGet.Config b/ui/JevOverlay/NuGet.Config
new file mode 100644
index 0000000..ddd33ab
--- /dev/null
+++ b/ui/JevOverlay/NuGet.Config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/ui/JevOverlay/UI/OverlayView.cs b/ui/JevOverlay/UI/OverlayView.cs
new file mode 100644
index 0000000..4ab931a
--- /dev/null
+++ b/ui/JevOverlay/UI/OverlayView.cs
@@ -0,0 +1,184 @@
+using Godot;
+using JevOverlay.Core;
+
+namespace JevOverlay.UI;
+
+/// Godot controls only. This view cannot read files or call the game API.
+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 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,
+ };
+}