diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css index a0621a2..c528a0e 100644 --- a/public/dashboard/dashboard.css +++ b/public/dashboard/dashboard.css @@ -2530,6 +2530,447 @@ body { @keyframes dash-sched-spin { to { transform: rotate(360deg); } } +/* Tab switcher (shared: Memory type tabs, plus future tabs that need a + pill-style segmented group sitting above a list). */ +.dash-tab-switcher { + display: inline-flex; + gap: 2px; + padding: 3px; + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); +} +.dash-tab-switcher-tab { + padding: 6px 12px; + border-radius: var(--radius-sm); + background: transparent; + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + border: 0; + cursor: pointer; + font: 500 13px Inter, sans-serif; + transition: background-color var(--motion-fast); +} +.dash-tab-switcher-tab:hover { + background: color-mix(in oklab, var(--color-base-content) 6%, transparent); +} +.dash-tab-switcher-tab[aria-pressed="true"] { + background: var(--color-base-100); + color: var(--color-base-content); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} +.dash-tab-switcher-tab:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Session pill (shared: Memory detail panes, Evolution diff cards). Links + cross-tab to #/sessions/. */ +.dash-session-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-pill); + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + font: 500 11px 'JetBrains Mono', ui-monospace, monospace; + color: var(--color-base-content); + cursor: pointer; + text-decoration: none; + margin-right: 4px; + transition: border-color var(--motion-fast); +} +.dash-session-pill:hover { + border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-base-300)); +} + +/* Memory-specific neutral pill (tags, tools, ratios, categories). Distinct + from .dash-chip (primary-tinted, used as an interactive token across + Settings/Hooks/Skills/Subagents). */ +.dash-memory-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-pill); + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + font: 500 11px Inter, sans-serif; + color: color-mix(in oklab, var(--color-base-content) 75%, transparent); +} + +/* Evolution tab primitives. */ +.dash-timeline { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-4); + padding-left: 18px; + position: relative; +} +.dash-timeline::before { + content: ""; + position: absolute; + left: 9px; + top: 12px; + bottom: 12px; + width: 1px; + background: color-mix(in oklab, var(--color-base-content) 14%, transparent); +} +.dash-timeline-card { + position: relative; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-100); + transition: border-color var(--motion-fast), box-shadow var(--motion-fast); +} +.dash-timeline-card:hover { + border-color: color-mix(in oklab, var(--color-primary) 20%, var(--color-base-300)); +} +.dash-timeline-card::before { + content: ""; + position: absolute; + left: -14px; + top: 18px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-base-100); + border: 2px solid color-mix(in oklab, var(--color-primary) 60%, var(--color-base-300)); +} +.dash-timeline-card-open::before { + background: var(--color-primary); +} +.dash-timeline-card-header { + padding: var(--space-3) var(--space-4); + cursor: pointer; + outline: none; +} +.dash-timeline-card-header:focus-visible { + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--color-primary) 60%, transparent); + border-radius: var(--radius-md); +} +.dash-timeline-card-head-row { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} +.dash-timeline-card-version { + font: 600 14px 'JetBrains Mono', ui-monospace, monospace; + color: var(--color-base-content); +} +.dash-timeline-card-time { + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); +} +.dash-timeline-tier { + font: 500 11px 'JetBrains Mono', ui-monospace, monospace; + color: color-mix(in oklab, var(--color-base-content) 75%, transparent); + text-transform: lowercase; +} +.dash-timeline-card-count { + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + margin-left: auto; +} +.dash-timeline-chevron { + font-size: 16px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + transition: transform var(--motion-fast); +} +.dash-timeline-card-open .dash-timeline-chevron { transform: rotate(90deg); } +.dash-timeline-card-summary { + margin: 6px 0 0; + font-size: 13px; + color: color-mix(in oklab, var(--color-base-content) 80%, transparent); +} +.dash-timeline-card-meta { + margin: 0 0 var(--space-2); + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + font-family: 'JetBrains Mono', ui-monospace, monospace; +} +.dash-timeline-card-body { + display: none; + padding: 0 var(--space-4) var(--space-4); + border-top: 1px solid var(--color-base-300); + padding-top: var(--space-3); + margin-top: var(--space-3); +} +.dash-timeline-card-open .dash-timeline-card-body { display: block; } +.dash-timeline-sessions { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + margin-bottom: var(--space-3); +} +.dash-timeline-sessions-label { + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-right: 6px; +} +.dash-timeline-diffs { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.dash-timeline-footer { + display: flex; + justify-content: center; + padding: var(--space-3); +} + +.dash-diff-file { + border: 1px solid var(--color-base-300); + border-radius: var(--radius-sm); + padding: var(--space-3); + background: var(--color-base-200); +} +.dash-diff-meta { + display: flex; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; + margin-bottom: var(--space-2); +} +.dash-diff-file-name { + font: 500 12px 'JetBrains Mono', ui-monospace, monospace; + color: var(--color-base-content); +} +.dash-diff-file-size { + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + margin-left: auto; +} +.dash-diff-line { + margin: 0 0 4px; + font-size: 12px; + display: flex; + gap: 8px; +} +.dash-diff-line-label { + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; + min-width: 72px; + padding-top: 2px; +} +.dash-diff-line-value { + color: color-mix(in oklab, var(--color-base-content) 85%, transparent); + flex: 1; +} +.dash-diff-preview { + font: 400 11px 'JetBrains Mono', ui-monospace, monospace; + background: var(--color-base-100); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-sm); + padding: var(--space-2); + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + margin: var(--space-2) 0 0; +} + +.dash-poison-banner { + display: flex; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + margin-top: var(--space-3); + background: color-mix(in oklab, var(--color-warning) 10%, var(--color-base-100)); + border: 1px solid color-mix(in oklab, var(--color-warning) 55%, var(--color-base-300)); + border-radius: var(--radius-md); + align-items: flex-start; +} +.dash-poison-banner-glyph { + width: 22px; + height: 22px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--color-warning); + color: #fff; + font-weight: 700; + flex-shrink: 0; +} +.dash-poison-banner-title { + margin: 0 0 2px; + font-weight: 600; + color: var(--color-base-content); +} +.dash-poison-banner-body { + margin: 0; + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 75%, transparent); +} + +/* Memory explorer primitives. */ +.dash-split-pane { + display: grid; + grid-template-columns: minmax(320px, 38%) 1fr; + gap: var(--space-4); + margin-top: var(--space-4); + min-height: 520px; +} +.dash-split-pane-rail { + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-100); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.dash-split-pane-main { + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-100); + padding: var(--space-4); + overflow: auto; + min-height: 520px; +} +.dash-memory-rail-head { + padding: var(--space-3); + border-bottom: 1px solid var(--color-base-300); +} +.dash-memory-list { + overflow-y: auto; + flex: 1; + padding: var(--space-2); +} +.dash-memory-row { + padding: var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--motion-fast); +} +.dash-memory-row:hover { background: var(--color-base-200); } +.dash-memory-row[aria-current="true"] { + background: color-mix(in oklab, var(--color-primary) 8%, var(--color-base-200)); +} +.dash-memory-row:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} +.dash-memory-row-contradicted { opacity: 0.55; } +.dash-memory-row-title { + font-weight: 600; + font-size: 13px; + color: var(--color-base-content); + margin: 0 0 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dash-memory-row-sub { + font-size: 11px; + color: color-mix(in oklab, var(--color-base-content) 60%, transparent); + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.dash-memory-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-top: var(--space-3); + margin-bottom: var(--space-3); + flex-wrap: wrap; +} +.dash-memory-detail-id { + margin: 4px 0 0; + font: 500 11px 'JetBrains Mono', ui-monospace, monospace; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + word-break: break-all; +} +.dash-memory-detail-actions { + display: flex; + gap: var(--space-2); + flex-shrink: 0; +} +.dash-memory-back-btn { + display: none; + margin-bottom: var(--space-3); +} +.dash-memory-meta-grid { + display: grid; + grid-template-columns: minmax(120px, max-content) 1fr; + gap: 6px 16px; + font-size: 12px; + margin: var(--space-3) 0; +} +.dash-memory-meta-grid-key { + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); +} +.dash-memory-text { + font: 400 12px 'JetBrains Mono', ui-monospace, monospace; + background: var(--color-base-200); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-sm); + padding: var(--space-3); + max-height: 360px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} +.dash-memory-steps { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.dash-memory-step { + border: 1px solid var(--color-base-300); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + background: var(--color-base-200); + display: flex; + flex-direction: column; + gap: 6px; +} +.dash-memory-step-head { + display: flex; + align-items: center; + gap: 8px; +} +.dash-memory-step-num { + font: 600 11px 'JetBrains Mono', ui-monospace, monospace; + color: color-mix(in oklab, var(--color-base-content) 65%, transparent); +} +.dash-confidence-bar { + display: inline-block; + width: 64px; + height: 6px; + border-radius: 3px; + background: var(--color-base-300); + overflow: hidden; + vertical-align: middle; +} +.dash-confidence-bar-fill { + display: block; + height: 100%; + background: color-mix(in oklab, var(--color-primary) 80%, transparent); + border-radius: 3px; +} + +@media (max-width: 720px) { + .dash-split-pane { grid-template-columns: 1fr; min-height: 0; } + .dash-split-pane[data-detail-open="true"] .dash-split-pane-rail { display: none; } + .dash-split-pane:not([data-detail-open="true"]) .dash-split-pane-main { display: none; } + .dash-memory-back-btn { display: inline-flex; } +} + @media (prefers-reduced-motion: reduce) { .dash-sched-inline-spinner { animation: none; } } diff --git a/public/dashboard/dashboard.js b/public/dashboard/dashboard.js index dba7aa0..aafd540 100644 --- a/public/dashboard/dashboard.js +++ b/public/dashboard/dashboard.js @@ -260,8 +260,8 @@ var name = parsed.route; deactivateAllRoutes(); - var liveRoutes = ["skills", "memory-files", "plugins", "subagents", "hooks", "settings", "sessions", "cost", "scheduler"]; - var comingSoon = ["evolution", "memory"]; + var liveRoutes = ["skills", "memory-files", "plugins", "subagents", "hooks", "settings", "sessions", "cost", "scheduler", "evolution", "memory"]; + var comingSoon = []; if (liveRoutes.indexOf(name) >= 0 && routes[name]) { var containerId = "route-" + name; diff --git a/public/dashboard/evolution.js b/public/dashboard/evolution.js new file mode 100644 index 0000000..dfb9c5d --- /dev/null +++ b/public/dashboard/evolution.js @@ -0,0 +1,634 @@ +// Evolution tab (Phase A, read-only): self-improvement timeline over +// phantom-config/meta/version.json + evolution-log.jsonl + metrics.json. +// +// Module contract: registers with PhantomDashboard via +// registerRoute("evolution", { mount }). mount(container, arg, ctx) is called +// on hash change. When arg looks like "v" or "" the card for that +// version auto-expands if it's in the current timeline page. Snapshot +// storage and rollback ship in Phase B; this module is pure read. +// +// All values from the API flow through ctx.esc() or textContent. Diff content +// previews render as textContent inside
 nodes because the payload is
+// the actual file body from phantom-config/ and may contain any characters.
+
+(function () {
+	var DEFAULT_LIMIT = 20;
+	var FILE_PREVIEW_MAX_CHARS = 8000;
+
+	var state = {
+		overview: null,
+		overviewLoading: false,
+		overviewError: null,
+		entries: [],
+		timelineLoading: false,
+		timelineError: null,
+		hasMore: false,
+		expanded: {},
+		versionCache: {},
+		versionLoading: {},
+		versionErrors: {},
+		deepLink: null,
+	};
+	var ctx = null;
+	var root = null;
+
+	function esc(s) { return ctx ? ctx.esc(s) : ""; }
+
+	function formatCost(n) {
+		if (typeof n !== "number" || !isFinite(n)) return "$0.00";
+		if (n > 0 && n < 0.01) return "<$0.01";
+		return "$" + n.toFixed(2);
+	}
+
+	function formatInt(n) {
+		if (typeof n !== "number" || !isFinite(n)) return "0";
+		return Math.round(n).toLocaleString();
+	}
+
+	function formatRate(n) {
+		if (typeof n !== "number" || !isFinite(n)) return "0%";
+		return Math.round(n * 100) + "%";
+	}
+
+	function formatBytes(n) {
+		if (typeof n !== "number" || !isFinite(n) || n < 0) return "0 B";
+		if (n < 1024) return n + " B";
+		if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
+		return (n / (1024 * 1024)).toFixed(2) + " MB";
+	}
+
+	function parseIsoDate(s) {
+		if (!s) return null;
+		var d = new Date(s);
+		if (isNaN(d.getTime())) return null;
+		return d;
+	}
+
+	function relativeTime(s) {
+		var d = parseIsoDate(s);
+		if (!d) return "";
+		var diff = Date.now() - d.getTime();
+		if (diff < 0) diff = 0;
+		var sec = Math.floor(diff / 1000);
+		if (sec < 60) return sec + "s ago";
+		var min = Math.floor(sec / 60);
+		if (min < 60) return min + "m ago";
+		var hr = Math.floor(min / 60);
+		if (hr < 24) return hr + "h ago";
+		var day = Math.floor(hr / 24);
+		if (day < 30) return day + "d ago";
+		var mo = Math.floor(day / 30);
+		if (mo < 12) return mo + "mo ago";
+		return Math.floor(day / 365) + "y ago";
+	}
+
+	function absoluteTime(s) {
+		var d = parseIsoDate(s);
+		if (!d) return "";
+		return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
+	}
+
+	function statusChipClass(status) {
+		if (status === "ok") return "dash-status-chip-active";
+		if (status === "skip") return "dash-status-chip-paused";
+		if (status === "escalate") return "dash-status-chip-error";
+		return "";
+	}
+
+	function tierLabel(tier) {
+		return tier || "skip";
+	}
+
+	function typeChipClass(type) {
+		if (type === "new") return "dash-status-chip-active";
+		if (type === "delete") return "dash-status-chip-error";
+		if (type === "compact") return "dash-status-chip-info";
+		return "dash-status-chip-paused";
+	}
+
+	function parseDeepLink(arg) {
+		if (!arg) return null;
+		var raw = String(arg).replace(/^v/i, "");
+		var n = Number.parseInt(raw, 10);
+		if (!Number.isInteger(n) || n < 0) return null;
+		return n;
+	}
+
+	function render() {
+		if (!root) return;
+		var out = [];
+		out.push(renderHeader());
+		out.push(renderMetricStrip());
+		out.push(renderPoisonBanner());
+		out.push(renderSparklineSection());
+		out.push(renderTimelineSection());
+		root.innerHTML = out.join("");
+		wireHeader();
+		wireTimeline();
+		paintDiffContent();
+	}
+
+	function renderHeader() {
+		return (
+			'
' + + '

Evolution

' + + '

Evolution

' + + '

The agent\u0027s self-improvement history. Every generation, every file change, every judge decision. Click a card to see the diff and the sessions that triggered it.

' + + '
Phase A \u00B7 read-only
' + + '
' + ); + } + + function metricCard(label, value, delta) { + var deltaHtml = delta ? '

' + esc(delta) + '

' : ""; + return ( + '
' + + '

' + esc(label) + '

' + + '

' + esc(value) + '

' + + deltaHtml + + '
' + ); + } + + function skeletonMetric() { + return ( + '' + ); + } + + function renderMetricStrip() { + if (state.overviewLoading && !state.overview) { + return '
' + + skeletonMetric() + skeletonMetric() + skeletonMetric() + skeletonMetric() + skeletonMetric() + skeletonMetric() + '
'; + } + if (state.overviewError) { + return ( + '
' + + '

Could not load evolution state

' + + '

' + esc(state.overviewError) + '

' + + '' + + '
' + ); + } + if (!state.overview) return ""; + var o = state.overview; + var m = o.metrics; + var rs = m.reflection_stats; + var sinceLabel = o.current.timestamp ? relativeTime(o.current.timestamp) : "never"; + var tiersLabel = rs.tiers.haiku + " haiku / " + rs.tiers.sonnet + " sonnet / " + rs.tiers.opus + " opus"; + return ( + '
' + + metricCard("Current version", "v" + o.current.version, "since " + sinceLabel) + + metricCard("Total sessions", formatInt(m.session_count)) + + metricCard("Success 7d", formatRate(m.success_rate_7d)) + + metricCard("Drains", formatInt(rs.drains), tiersLabel) + + metricCard("Reflection cost", formatCost(rs.cost_usd)) + + metricCard("Invariant fails", formatInt(rs.invariant_fails)) + + '
' + ); + } + + function renderPoisonBanner() { + if (!state.overview || !state.overview.poison_count) return ""; + var n = state.overview.poison_count; + var word = n === 1 ? "drain" : "drains"; + return ( + '
' + + '' + + '
' + + '

' + esc(n + " poisoned " + word) + '

' + + '

Reflections that exceeded the retry ceiling. Inspect them directly on the VM via the evolution-queue-poison table.

' + + '
' + + '
' + ); + } + + function buildSparklineData() { + if (!state.entries || state.entries.length === 0) return []; + var counts = {}; + state.entries.forEach(function (e) { + if (!e.timestamp) return; + var day = e.timestamp.slice(0, 10); + counts[day] = (counts[day] || 0) + 1; + }); + var days = Object.keys(counts).sort(); + return days.map(function (d) { return { day: d, count: counts[d] }; }); + } + + function renderSparklineSvg(points, width, height) { + if (points.length === 0) { + return ''; + } + var padL = 36, padR = 10, padT = 8, padB = 20; + var innerW = Math.max(1, width - padL - padR); + var innerH = Math.max(1, height - padT - padB); + var max = 0; + for (var i = 0; i < points.length; i++) if (points[i].count > max) max = points[i].count; + if (max === 0) max = 1; + var gap = points.length > 30 ? 1 : 2; + var barW = Math.max(2, (innerW - gap * (points.length - 1)) / points.length); + var out = ['']; + var ticks = 3; + for (var t = 0; t <= ticks; t++) { + var yv = (max * t) / ticks; + var yP = padT + innerH - (yv / max) * innerH; + out.push(''); + out.push('' + esc(String(Math.round(yv))) + ''); + } + out.push(''); + var labelEvery = Math.max(1, Math.ceil(points.length / 6)); + for (var j = 0; j < points.length; j++) { + var p = points[j]; + var h = (p.count / max) * innerH; + var x = padL + j * (barW + gap); + var y = padT + innerH - h; + out.push('' + esc(p.day + ": " + p.count) + ''); + if (j % labelEvery === 0 || j === points.length - 1) { + out.push('' + esc(p.day.slice(5)) + ''); + } + } + out.push(''); + return out.join(""); + } + + function renderSparklineSection() { + var points = buildSparklineData(); + if (state.timelineLoading && state.entries.length === 0) { + return ( + '
' + + '

Drains per day (from timeline)

' + + '' + + '
' + ); + } + if (points.length === 0) return ""; + var width = Math.max(480, points.length * 26); + var svg = renderSparklineSvg(points, width, 120); + return ( + '
' + + '

Drains per day (from timeline)

' + + '
' + svg + '
' + + '
' + ); + } + + function renderTimelineSection() { + if (state.timelineLoading && state.entries.length === 0) { + return renderTimelineSkeleton(); + } + if (state.timelineError) { + return ( + '
' + + '

Could not load timeline

' + + '

' + esc(state.timelineError) + '

' + + '' + + '
' + ); + } + if (state.entries.length === 0) { + return renderEmpty(); + } + var cards = state.entries.map(renderCard).join(""); + var loadMore = state.hasMore + ? '' + : ""; + return '
' + cards + '
' + loadMore; + } + + function renderTimelineSkeleton() { + var pill = '
'; + var out = []; + for (var i = 0; i < 4; i++) { + out.push( + '' + ); + } + return '
' + out.join("") + '
'; + } + + function renderEmpty() { + return ( + '
' + + '' + + '

Your agent hasn\u0027t evolved yet

' + + '

After a few sessions and a drain, the first generation lands here. Each card will show the file changes and the sessions that motivated them.

' + + '
' + ); + } + + function renderCard(entry) { + var isOpen = !!state.expanded[entry.version]; + var openClass = isOpen ? ' dash-timeline-card-open' : ''; + var statusCls = statusChipClass(entry.status); + var tierLbl = tierLabel(entry.tier); + var timeLabel = entry.timestamp ? absoluteTime(entry.timestamp) : ""; + var relLabel = entry.timestamp ? relativeTime(entry.timestamp) : ""; + var summary = deriveSummary(entry); + var headerAria = 'aria-expanded="' + (isOpen ? 'true' : 'false') + '" aria-controls="evolution-card-body-' + entry.version + '"'; + var bodyHtml = isOpen ? renderCardBody(entry) : ""; + return ( + '
' + + '
' + + '
' + + 'v' + esc(String(entry.version)) + '' + + '' + esc(relLabel) + '' + + '' + esc(tierLbl) + '' + + (statusCls ? '' + esc(entry.status) + '' : '' + esc(entry.status) + '') + + '' + esc(entry.changes_applied + " change" + (entry.changes_applied === 1 ? "" : "s")) + '' + + '' + + '
' + + '

' + esc(summary) + '

' + + '
' + + '
' + bodyHtml + '
' + + '
' + ); + } + + function deriveSummary(entry) { + if (entry.status === "skip" || entry.changes_applied === 0) return "Nothing worth codifying."; + if (!entry.details || entry.details.length === 0) return "Drain " + entry.drain_id; + var names = entry.details.map(function (d) { return d.file; }); + if (names.length === 1) return names[0]; + if (names.length <= 3) return names.join(" \u00B7 "); + return names.slice(0, 2).join(" \u00B7 ") + " + " + (names.length - 2) + " more"; + } + + function renderCardBody(entry) { + var cached = state.versionCache[entry.version]; + if (state.versionLoading[entry.version] && !cached) { + var pill = '
'; + return '
' + pill + pill + pill + '
'; + } + if (state.versionErrors[entry.version] && !cached) { + return ( + '
' + + '

Could not load generation v' + esc(String(entry.version)) + '

' + + '

' + esc(state.versionErrors[entry.version]) + '

' + + '' + + '
' + ); + } + if (!cached) return ""; + var sessionsRow = renderSessionsPills(entry.session_ids || []); + var diffs = (cached.diff || []).map(function (d, i) { return renderDiffFile(d, entry.version, i); }).join(""); + var drainLine = '

Drain ' + esc(entry.drain_id) + ' \u00B7 ' + esc(absoluteTime(entry.timestamp)) + '

'; + return drainLine + sessionsRow + '
' + diffs + '
'; + } + + function renderSessionsPills(sessionIds) { + if (!sessionIds || sessionIds.length === 0) return ""; + var pills = sessionIds.map(function (id) { + var encoded = encodeURIComponent(id); + return '' + esc(id) + ''; + }).join(""); + return '
Sessions' + pills + '
'; + } + + function renderDiffFile(diff, version, idx) { + var typeCls = typeChipClass(diff.type); + var previewId = 'evolution-preview-' + version + '-' + idx; + var sessionsPills = renderSessionsPills(diff.session_ids || []); + var sizeLine = diff.type === "delete" ? "deleted" : formatBytes(diff.current_size) + (diff.current_content && diff.current_size > diff.current_content.length ? " (preview truncated)" : ""); + var previewBlock = diff.type === "delete" + ? '

No current content on disk.

' + : '
';
+		return (
+			'
' + + '
' + + '' + esc(diff.file) + '' + + '' + esc(diff.type) + '' + + '' + esc(sizeLine) + '' + + '
' + + (diff.summary ? '

Summary' + esc(diff.summary) + '

' : '') + + (diff.rationale ? '

Rationale' + esc(diff.rationale) + '

' : '') + + sessionsPills + + previewBlock + + '
' + ); + } + + function paintDiffContent() { + if (!root) return; + var nodes = root.querySelectorAll(".dash-diff-preview[data-preview-version]"); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var version = Number(node.getAttribute("data-preview-version")); + var idx = Number(node.getAttribute("data-preview-idx")); + var cached = state.versionCache[version]; + if (!cached || !cached.diff || !cached.diff[idx]) continue; + var content = cached.diff[idx].current_content || ""; + if (content.length > FILE_PREVIEW_MAX_CHARS) content = content.slice(0, FILE_PREVIEW_MAX_CHARS) + "\n\u2026"; + node.textContent = content || "(empty file)"; + } + } + + function wireHeader() { + var retryOverview = document.getElementById("evolution-retry-overview"); + if (retryOverview) retryOverview.addEventListener("click", loadOverview); + } + + function wireTimeline() { + var retry = document.getElementById("evolution-retry-timeline"); + if (retry) retry.addEventListener("click", function () { loadTimeline(true); }); + var loadMore = document.getElementById("evolution-load-more"); + if (loadMore) loadMore.addEventListener("click", onLoadMore); + + var headers = root.querySelectorAll("[data-expand]"); + for (var i = 0; i < headers.length; i++) { + headers[i].addEventListener("click", onHeaderClick); + headers[i].addEventListener("keydown", onHeaderKeyDown); + } + var retries = root.querySelectorAll("[data-retry-version]"); + for (var j = 0; j < retries.length; j++) { + retries[j].addEventListener("click", onRetryVersionClick); + } + var pills = root.querySelectorAll(".dash-session-pill"); + for (var k = 0; k < pills.length; k++) { + pills[k].addEventListener("click", onSessionPillClick); + } + } + + function onHeaderClick(e) { + e.preventDefault(); + var node = e.currentTarget; + var version = Number(node.getAttribute("data-expand")); + if (!Number.isInteger(version)) return; + toggleCard(version); + } + + function onHeaderKeyDown(e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onHeaderClick(e); + } + } + + function onRetryVersionClick(e) { + e.preventDefault(); + e.stopPropagation(); + var version = Number(e.currentTarget.getAttribute("data-retry-version")); + state.versionErrors[version] = null; + fetchVersion(version); + } + + function onSessionPillClick(e) { + e.preventDefault(); + e.stopPropagation(); + var key = e.currentTarget.getAttribute("data-session-key"); + if (!key) return; + ctx.navigate("#/sessions/" + encodeURIComponent(key)); + } + + function toggleCard(version) { + var nowOpen = !state.expanded[version]; + state.expanded[version] = nowOpen; + if (nowOpen && !state.versionCache[version] && !state.versionLoading[version]) { + fetchVersion(version); + return; + } + renderTimelineOnly(); + } + + function renderTimelineOnly() { + var container = root.querySelector(".dash-timeline"); + var existingFooter = root.querySelector(".dash-timeline-footer"); + var fresh = document.createElement("div"); + fresh.innerHTML = renderTimelineSection(); + if (container && container.parentNode) { + container.parentNode.replaceChild(fresh, container); + if (existingFooter && existingFooter.parentNode) existingFooter.parentNode.removeChild(existingFooter); + wireTimeline(); + paintDiffContent(); + } else { + render(); + } + } + + function loadOverview() { + state.overviewLoading = true; + state.overviewError = null; + render(); + return ctx.api("GET", "/ui/api/evolution") + .then(function (res) { + state.overviewLoading = false; + state.overview = res; + render(); + }) + .catch(function (err) { + state.overviewLoading = false; + state.overviewError = err.message || String(err); + render(); + ctx.toast("error", "Failed to load evolution", state.overviewError); + }); + } + + function loadTimeline(reset) { + if (reset) { + state.entries = []; + state.hasMore = false; + state.timelineError = null; + } + state.timelineLoading = true; + render(); + var params = ["limit=" + DEFAULT_LIMIT]; + return ctx.api("GET", "/ui/api/evolution/timeline?" + params.join("&")) + .then(function (res) { + state.timelineLoading = false; + state.entries = res.entries || []; + state.hasMore = !!res.has_more; + if (state.deepLink !== null) tryExpandDeepLink(); + render(); + }) + .catch(function (err) { + state.timelineLoading = false; + state.timelineError = err.message || String(err); + render(); + ctx.toast("error", "Failed to load timeline", state.timelineError); + }); + } + + function onLoadMore() { + if (state.entries.length === 0) return; + var oldest = state.entries[state.entries.length - 1]; + var btn = document.getElementById("evolution-load-more"); + if (btn) btn.setAttribute("disabled", "disabled"); + ctx.api("GET", "/ui/api/evolution/timeline?limit=" + DEFAULT_LIMIT + "&before_version=" + oldest.version) + .then(function (res) { + var next = res.entries || []; + state.entries = state.entries.concat(next); + state.hasMore = !!res.has_more; + render(); + }) + .catch(function (err) { + if (btn) btn.removeAttribute("disabled"); + ctx.toast("error", "Failed to load more", err.message || String(err)); + }); + } + + function fetchVersion(version) { + state.versionLoading[version] = true; + renderTimelineOnly(); + return ctx.api("GET", "/ui/api/evolution/version/" + encodeURIComponent(String(version))) + .then(function (res) { + state.versionLoading[version] = false; + state.versionCache[version] = res; + renderTimelineOnly(); + }) + .catch(function (err) { + state.versionLoading[version] = false; + state.versionErrors[version] = err.message || String(err); + renderTimelineOnly(); + }); + } + + function tryExpandDeepLink() { + if (state.deepLink === null) return; + var target = state.deepLink; + var found = state.entries.some(function (e) { return e.version === target; }); + if (found) { + state.expanded[target] = true; + if (!state.versionCache[target] && !state.versionLoading[target]) fetchVersion(target); + setTimeout(function () { + var node = root && root.querySelector('[data-version="' + target + '"]'); + if (node && typeof node.scrollIntoView === "function") { + try { node.scrollIntoView({ block: "start", behavior: "smooth" }); } catch (_) { /* ignore */ } + } + }, 100); + } else { + ctx.toast("info", "Generation v" + target + " not in the current page", "Click Load more to scroll back further in history."); + } + state.deepLink = null; + } + + function resetState() { + state.overview = null; + state.overviewLoading = false; + state.overviewError = null; + state.entries = []; + state.timelineLoading = false; + state.timelineError = null; + state.hasMore = false; + state.expanded = {}; + state.versionCache = {}; + state.versionLoading = {}; + state.versionErrors = {}; + state.deepLink = null; + } + + function mount(container, arg, dashCtx) { + ctx = dashCtx; + root = container; + ctx.setBreadcrumb("Evolution"); + resetState(); + state.deepLink = parseDeepLink(arg); + render(); + return Promise.all([loadOverview(), loadTimeline(true)]); + } + + if (window.PhantomDashboard && window.PhantomDashboard.registerRoute) { + window.PhantomDashboard.registerRoute("evolution", { mount: mount }); + } +})(); diff --git a/public/dashboard/index.html b/public/dashboard/index.html index a4b6c60..db3dca7 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -72,24 +72,18 @@ Cost - - -
Coming soon
- @@ -104,6 +98,8 @@ + + @@ -121,6 +117,8 @@ + + diff --git a/public/dashboard/memory.js b/public/dashboard/memory.js new file mode 100644 index 0000000..dd72ad3 --- /dev/null +++ b/public/dashboard/memory.js @@ -0,0 +1,582 @@ +// Memory explorer tab: read and delete across episodes, facts, and +// procedures stored in Qdrant. +// +// Module contract: registers via PhantomDashboard.registerRoute("memory"). +// mount(container, arg, ctx). arg is `[/]`; default tab episodes. +// +// All agent-authored text (summary, detail, trigger, natural_language, +// lessons, step action) renders via textContent inside
 nodes because
+// payloads may contain any characters. esc() guards shorter identifiers.
+// Delete routes through ctx.openModal; Qdrant deletes are irreversible.
+
+(function () {
+	var TYPES = ["episodes", "facts", "procedures"];
+	var LIMIT = 30;
+	var SEARCH_DEBOUNCE_MS = 250;
+
+	var state = {
+		health: null, healthError: null,
+		activeType: "episodes", query: "",
+		list: { items: [], nextOffset: null, loading: false, error: null },
+		selectedId: null,
+		detail: { item: null, loading: false, error: null },
+	};
+	var ctx = null, root = null, searchTimer = null, documentKeyHandler = null;
+
+	function esc(s) { return ctx ? ctx.esc(s) : ""; }
+	function parseIso(iso) { if (!iso) return null; var d = new Date(iso); return isNaN(d.getTime()) ? null : d; }
+	function formatDate(iso) { var d = parseIso(iso); return d ? d.toISOString().slice(0, 10) : (iso ? String(iso) : ""); }
+	function formatIsoShort(iso) { var d = parseIso(iso); return d ? d.toISOString().replace("T", " ").slice(0, 16) + " UTC" : (iso ? String(iso) : ""); }
+	function relativeTime(iso) {
+		var d = parseIso(iso);
+		if (!d) return "";
+		var diff = Math.max(0, Date.now() - d.getTime());
+		var sec = Math.floor(diff / 1000);
+		if (sec < 60) return sec + "s ago";
+		var min = Math.floor(sec / 60); if (min < 60) return min + "m ago";
+		var hr = Math.floor(min / 60); if (hr < 24) return hr + "h ago";
+		var day = Math.floor(hr / 24); if (day < 30) return day + "d ago";
+		return Math.floor(day / 30) + "mo ago";
+	}
+	function truncate(s, n) { if (!s) return ""; var x = String(s); return x.length <= n ? x : x.slice(0, n - 1) + "\u2026"; }
+	function formatInt(n) { return (typeof n === "number" && isFinite(n)) ? Math.round(n).toLocaleString() : "0"; }
+
+	function parseArg(arg) {
+		var out = { type: "episodes", id: null };
+		if (!arg) return out;
+		var parts = String(arg).split("/");
+		if (TYPES.indexOf(parts[0]) >= 0) out.type = parts[0];
+		if (parts.length >= 2) out.id = parts.slice(1).join("/");
+		return out;
+	}
+	function buildHash() {
+		var base = "#/memory/" + state.activeType;
+		return state.selectedId ? base + "/" + encodeURIComponent(state.selectedId) : base;
+	}
+
+	function render() {
+		if (!root) return;
+		root.innerHTML = renderHeader() + renderHealth() + renderSplit();
+		wireAll();
+		paintDetailText();
+	}
+
+	function renderHeader() {
+		return '
' + + '

Memory

' + + '

Memory explorer

' + + '

Every episode, fact, and procedure your agent remembers. Search to find one, then inspect the source or remove anything that looks wrong.

' + + '
'; + } + + function metricCard(label, value) { + return '

' + esc(label) + '

' + esc(value) + '

'; + } + function statusCard(label, ok) { + var cls = ok ? "dash-status-chip-active" : "dash-status-chip-error"; + var txt = ok ? "healthy" : "unavailable"; + return '

' + esc(label) + '

' + esc(txt) + '

'; + } + function skMetric() { return ''; } + + function renderHealth() { + if (state.healthError) { + return '
' + + '

Could not load memory health

' + + '

' + esc(state.healthError) + '

' + + '
'; + } + if (!state.health) return '
' + skMetric() + skMetric() + skMetric() + skMetric() + skMetric() + '
'; + var h = state.health; + return '
' + + statusCard("Qdrant", h.qdrant) + statusCard("Ollama", h.ollama) + + metricCard("Episodes", formatInt(h.counts.episodes)) + + metricCard("Facts", formatInt(h.counts.facts)) + + metricCard("Procedures", formatInt(h.counts.procedures)) + + '
'; + } + + function renderSplit() { + var open = state.selectedId ? "true" : "false"; + return '
' + renderRail() + renderMain() + '
'; + } + + function renderRail() { + var tabs = TYPES.map(function (t) { + var label = t === "episodes" ? "Episodes" : t === "facts" ? "Facts" : "Procedures"; + return ''; + }).join(""); + var search = ''; + return ''; + } + + function skPill() { return '
'; } + function listSkeleton() { + var out = []; + for (var i = 0; i < 6; i++) out.push(''); + return out.join(""); + } + + function renderList() { + if (state.list.loading && state.list.items.length === 0) return '
' + listSkeleton() + '
'; + if (state.list.error) return '
' + + '

Failed to load memories

' + + '

' + esc(state.list.error) + '

' + + '
'; + if (state.health && !state.health.qdrant) return '
' + + '

Memory system not available

' + + '

Qdrant is not reachable. Check docker compose ps qdrant on the VM.

'; + if (state.list.items.length === 0) { + var label = state.query + ? ("No " + state.activeType + " match \u201C" + state.query + "\u201D.") + : ("No " + state.activeType + " yet. Your agent will populate these after a few sessions."); + return '

' + esc(label) + '

'; + } + var items = sortListItems(state.list.items); + var rows = items.map(renderListRow).join(""); + var more = state.list.nextOffset + ? '
' + : ""; + return '
' + rows + more + '
'; + } + + function sortListItems(items) { + if (state.activeType !== "facts") return items; + var live = [], stale = []; + items.forEach(function (it) { (it.valid_until ? stale : live).push(it); }); + return live.concat(stale); + } + + function renderListRow(item) { + var sel = state.selectedId === item.id ? "true" : "false"; + var extra = state.activeType === "facts" && item.valid_until ? " dash-memory-row-contradicted" : ""; + var body = state.activeType === "episodes" ? episodeRow(item) : state.activeType === "facts" ? factRow(item) : procedureRow(item); + return '
' + body + '
'; + } + + function episodeRow(ep) { + var cls = ep.outcome === "success" ? "dash-status-chip-active" : ep.outcome === "failure" ? "dash-status-chip-error" : "dash-status-chip-paused"; + var tools = (ep.tools_used || []).length; + return '

' + esc(truncate(ep.summary || "(no summary)", 80)) + '

' + + '

' + + '' + esc(formatDate(ep.started_at)) + '' + + '' + esc(ep.outcome) + '' + + '' + esc(tools + " tool" + (tools === 1 ? "" : "s")) + '' + + '' + esc(truncate(ep.session_id || "", 20)) + '' + + '

'; + } + function factRow(fact) { + var pct = Math.max(0, Math.min(100, Math.round((fact.confidence || 0) * 100))); + var contradicted = fact.valid_until ? 'contradicted' : ""; + return '

' + esc(truncate(fact.natural_language || "(no text)", 90)) + '

' + + '

' + + '' + esc(fact.category || "fact") + '' + + '' + + '' + esc(formatDate(fact.valid_from)) + '' + contradicted + '

'; + } + function procedureRow(proc) { + var ratio = (proc.success_count || 0) + "/" + (proc.failure_count || 0); + return '

' + esc(proc.name || "(unnamed)") + '

' + + '

' + + '' + esc(truncate(proc.trigger || "", 60)) + '' + + '' + esc(ratio) + '' + + '' + esc(relativeTime(proc.last_used_at)) + '' + + '

'; + } + + function renderMain() { + if (state.health && !state.health.qdrant && !state.selectedId) { + return '
' + + '

Memory system not available

' + + '

Qdrant is not reachable. Once it\u0027s back up, memories will appear here.

'; + } + if (!state.selectedId) { + return '
' + + '

Select a memory

' + + '

Pick any row on the left to see the full record, its source sessions, and the delete action.

'; + } + return '
' + renderMainBody() + '
'; + } + + function renderMainBody() { + var back = ''; + if (state.detail.loading && !state.detail.item) { + return back + '
' + skPill() + skPill() + skPill() + skPill() + '
'; + } + if (state.detail.error && !state.detail.item) { + return back + '
' + + '

Could not load memory

' + + '

' + esc(state.detail.error) + '

' + + '
'; + } + if (!state.detail.item) return back + '

No memory loaded.

'; + var item = state.detail.item; + return back + detailHeader(item) + (state.activeType === "episodes" ? episodeDetail(item) : state.activeType === "facts" ? factDetail(item) : procedureDetail(item)); + } + + function detailHeader(item) { + var title = state.activeType === "episodes" ? truncate(item.summary || "(no summary)", 120) + : state.activeType === "facts" ? truncate(item.natural_language || "(no text)", 120) + : (item.name || "(unnamed procedure)"); + return '
' + + '

' + esc(state.activeType) + '

' + + '

' + esc(title) + '

' + + '

' + esc(item.id) + '

' + + '
' + + '' + + '' + + '
'; + } + + function section(label, inner) { + return '
' + inner + '
'; + } + function metaRow(k, v) { return '' + esc(k) + '' + esc(v) + ''; } + function sessionsPills(ids) { + if (!ids || ids.length === 0) return 'none'; + return ids.map(function (id) { + return '' + esc(id) + ''; + }).join(" "); + } + function chipList(values) { + if (!values || values.length === 0) return 'none'; + return values.map(function (v) { return '' + esc(v) + ''; }).join(" "); + } + function textPre(field) { return '
'; }
+
+	function episodeDetail(ep) {
+		var meta = '
' + + metaRow("Type", ep.type || "task") + metaRow("Outcome", ep.outcome) + + metaRow("Importance", (ep.importance || 0).toFixed(2)) + + metaRow("Access count", String(ep.access_count || 0)) + + metaRow("Decay rate", (ep.decay_rate || 1).toFixed(2)) + + metaRow("Started", formatIsoShort(ep.started_at)) + + metaRow("Ended", formatIsoShort(ep.ended_at)) + + metaRow("Duration", (ep.duration_seconds || 0) + "s") + + '
'; + var lessons = (ep.lessons && ep.lessons.length > 0) + ? section("Lessons", '
    ' + + ep.lessons.map(function (_, i) { return '
  1. '; }).join("") + + '
') + : ""; + return meta + + section("Session", ep.session_id ? sessionsPills([ep.session_id]) : 'none') + + section("Summary", textPre("summary")) + + section("Detail", textPre("detail")) + + section("Tools used", chipList(ep.tools_used)) + + section("Files touched", chipList(ep.files_touched)) + + lessons; + } + + function factDetail(fact) { + var pct = Math.max(0, Math.min(100, Math.round((fact.confidence || 0) * 100))); + var triple = '
' + + metaRow("Subject", fact.subject || "") + metaRow("Predicate", fact.predicate || "") + + metaRow("Object", fact.object || "") + metaRow("Category", fact.category || "") + + metaRow("Version", String(fact.version || 1)) + + metaRow("Valid from", formatIsoShort(fact.valid_from)) + + metaRow("Valid until", fact.valid_until ? formatIsoShort(fact.valid_until) : "present") + + '
'; + var conf = section("Confidence", + '
' + + '' + + '' + esc(pct + "%") + '
'); + var contradicted = fact.valid_until + ? section("Status", 'contradicted superseded at ' + esc(formatIsoShort(fact.valid_until)) + '') + : ""; + return section("Natural language", textPre("natural_language")) + + triple + conf + contradicted + + section("Source episodes", sessionsPills(fact.source_episode_ids)) + + section("Tags", chipList(fact.tags)); + } + + function procedureDetail(proc) { + var total = (proc.success_count || 0) + (proc.failure_count || 0); + var pct = total === 0 ? 0 : Math.round((proc.success_count / total) * 100); + var meta = '
' + + metaRow("Name", proc.name || "") + metaRow("Version", String(proc.version || 1)) + + metaRow("Confidence", (proc.confidence || 0).toFixed(2)) + + metaRow("Last used", formatIsoShort(proc.last_used_at)) + + metaRow("Success", String(proc.success_count || 0)) + + metaRow("Failure", String(proc.failure_count || 0)) + + '
'; + var ratio = section("Success ratio", + '
' + + '' + + '' + esc(pct + "% (" + (proc.success_count || 0) + " / " + total + ")") + '
'); + var steps; + if (!proc.steps || proc.steps.length === 0) { + steps = section("Steps", 'none'); + } else { + var body = proc.steps.map(function (s, i) { + return '
  • ' + + '
    ' + + '' + esc(String(s.order || i + 1)) + '' + + (s.tool ? '' + esc(s.tool) + '' : "") + + (s.decision_point ? 'decision' : "") + + '
    ' + + '
    ' +
    +					(s.expected_outcome ? '
    ' : "") +
    +					'
  • '; + }).join(""); + steps = section("Steps", '
      ' + body + '
    '); + } + return meta + section("Description", textPre("description")) + section("Trigger", textPre("trigger")) + ratio + steps; + } + + function paintDetailText() { + if (!state.detail.item || !root) return; + var item = state.detail.item; + var nodes = root.querySelectorAll("[data-memory-text]"); + for (var i = 0; i < nodes.length; i++) { + var field = nodes[i].getAttribute("data-memory-text"); + nodes[i].textContent = (item && item[field]) ? String(item[field]) : "(empty)"; + } + if (Array.isArray(item.lessons)) { + var lessons = root.querySelectorAll("[data-memory-lesson-idx]"); + for (var j = 0; j < lessons.length; j++) { + lessons[j].textContent = item.lessons[Number(lessons[j].getAttribute("data-memory-lesson-idx"))] || ""; + } + } + if (Array.isArray(item.steps)) { + var stepNodes = root.querySelectorAll("[data-memory-step-idx]"); + for (var k = 0; k < stepNodes.length; k++) { + var step = item.steps[Number(stepNodes[k].getAttribute("data-memory-step-idx"))]; + var f = stepNodes[k].getAttribute("data-memory-step-field"); + stepNodes[k].textContent = (step && step[f]) ? String(step[f]) : ""; + } + } + } + + function wireAll() { + var retryHealth = document.getElementById("memory-retry-health"); + if (retryHealth) retryHealth.addEventListener("click", loadHealth); + + var tabs = root.querySelectorAll("[data-memory-tab]"); + for (var i = 0; i < tabs.length; i++) tabs[i].addEventListener("click", onTabClick); + + var search = document.getElementById("memory-search"); + if (search) { + search.addEventListener("input", function (e) { + var val = e.currentTarget.value; + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(function () { state.query = val; loadList(true); }, SEARCH_DEBOUNCE_MS); + }); + search.addEventListener("keydown", function (e) { + if (e.key === "Escape") { search.value = ""; state.query = ""; loadList(true); } + }); + } + + var rows = root.querySelectorAll("[data-memory-id]"); + for (var j = 0; j < rows.length; j++) { + rows[j].addEventListener("click", onRowClick); + rows[j].addEventListener("keydown", onRowKey); + } + var more = document.getElementById("memory-load-more"); + if (more) more.addEventListener("click", onLoadMore); + var retryList = document.getElementById("memory-retry-list"); + if (retryList) retryList.addEventListener("click", function () { loadList(true); }); + var back = document.getElementById("memory-back-btn"); + if (back) back.addEventListener("click", function () { + state.selectedId = null; + state.detail = { item: null, loading: false, error: null }; + ctx.navigate(buildHash()); + render(); + }); + var copy = document.getElementById("memory-copy-json"); + if (copy) copy.addEventListener("click", onCopyJson); + var del = document.getElementById("memory-delete-btn"); + if (del) del.addEventListener("click", onDeleteClick); + var retryDetail = document.getElementById("memory-retry-detail"); + if (retryDetail) retryDetail.addEventListener("click", function () { loadDetail(state.selectedId); }); + var pills = root.querySelectorAll(".dash-session-pill"); + for (var p = 0; p < pills.length; p++) { + pills[p].addEventListener("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + var key = e.currentTarget.getAttribute("data-session-key"); + if (key) ctx.navigate("#/sessions/" + encodeURIComponent(key)); + }); + } + } + + function onTabClick(e) { + var tab = e.currentTarget.getAttribute("data-memory-tab"); + if (!tab || tab === state.activeType) return; + state.activeType = tab; + state.selectedId = null; + state.detail = { item: null, loading: false, error: null }; + state.query = ""; + ctx.navigate(buildHash()); + loadList(true); + } + function onRowClick(e) { + e.preventDefault(); + var id = e.currentTarget.getAttribute("data-memory-id"); + if (!id) return; + state.selectedId = id; + ctx.navigate(buildHash()); + loadDetail(id); + } + function onRowKey(e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(e); } + } + function onLoadMore() { + if (!state.list.nextOffset) return; + var prev = state.list.nextOffset; + state.list.loading = true; + ctx.api("GET", listUrl(prev)) + .then(function (res) { + state.list.loading = false; + state.list.items = state.list.items.concat(res.items || []); + state.list.nextOffset = res.nextOffset || null; + render(); + }) + .catch(function (err) { + state.list.loading = false; + ctx.toast("error", "Failed to load more", err.message || String(err)); + }); + } + function onCopyJson() { + if (!state.detail.item) return; + var text = JSON.stringify(state.detail.item, null, 2); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text) + .then(function () { ctx.toast("success", "Copied to clipboard"); }) + .catch(function () { ctx.toast("error", "Copy failed", "Clipboard API rejected the write."); }); + } else { + ctx.toast("error", "Copy failed", "Clipboard API is unavailable."); + } + } + function onDeleteClick() { + if (!state.detail.item) return; + var item = state.detail.item; + var body = document.createElement("div"); + var p1 = document.createElement("p"); + p1.style.margin = "0 0 var(--space-2)"; + p1.textContent = "This cannot be undone. The memory will be permanently removed from Qdrant and will no longer inform the agent\u2019s responses."; + var idLine = document.createElement("p"); + idLine.className = "phantom-muted"; + idLine.style.margin = "0"; + idLine.style.fontSize = "12px"; + idLine.textContent = state.activeType.slice(0, -1) + " id: " + item.id; + body.appendChild(p1); + body.appendChild(idLine); + ctx.openModal({ + title: "Delete this memory?", + body: body, + actions: [ + { label: "Cancel", className: "dash-btn-ghost" }, + { + label: "Delete memory", + className: "dash-btn-danger", + onClick: function () { return performDelete(item.id); }, + }, + ], + }); + } + function performDelete(id) { + return ctx.api("DELETE", "/ui/api/memory/" + state.activeType + "/" + encodeURIComponent(id)) + .then(function () { + ctx.toast("success", "Memory deleted"); + state.list.items = state.list.items.filter(function (it) { return it.id !== id; }); + if (state.selectedId === id) { + state.selectedId = null; + state.detail = { item: null, loading: false, error: null }; + ctx.navigate(buildHash()); + } + render(); + return true; + }) + .catch(function (err) { + ctx.toast("error", "Failed to delete", err.message || String(err)); + return false; + }); + } + + function listUrl(offset) { + var base = "/ui/api/memory/" + state.activeType; + var params = new URLSearchParams(); + params.set("limit", String(LIMIT)); + var q = (state.query || "").trim(); + if (q) params.set("q", q); + if (offset) params.set("offset", String(offset)); + var s = params.toString(); + return s ? base + "?" + s : base; + } + + function loadHealth() { + state.healthError = null; + ctx.api("GET", "/ui/api/memory/health") + .then(function (res) { state.health = res; render(); }) + .catch(function (err) { state.healthError = err.message || String(err); render(); }); + } + function loadList(reset) { + if (reset) { + state.list.items = []; + state.list.nextOffset = null; + state.list.error = null; + } + state.list.loading = true; + render(); + return ctx.api("GET", listUrl(null)) + .then(function (res) { + state.list.loading = false; + state.list.items = res.items || []; + state.list.nextOffset = res.nextOffset || null; + render(); + }) + .catch(function (err) { + state.list.loading = false; + state.list.error = err.message || String(err); + render(); + ctx.toast("error", "Failed to load memories", state.list.error); + }); + } + function loadDetail(id) { + if (!id) return Promise.resolve(); + state.detail = { item: null, loading: true, error: null }; + render(); + return ctx.api("GET", "/ui/api/memory/" + state.activeType + "/" + encodeURIComponent(id)) + .then(function (res) { state.detail.loading = false; state.detail.item = res.item; render(); }) + .catch(function (err) { state.detail.loading = false; state.detail.error = err.message || String(err); render(); }); + } + + function installGlobalKeys() { + if (documentKeyHandler) return; + documentKeyHandler = function (e) { + if (e.key !== "/") return; + var tag = (document.activeElement && document.activeElement.tagName) || ""; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if ((window.location.hash || "").indexOf("#/memory") !== 0) return; + var search = document.getElementById("memory-search"); + if (search) { e.preventDefault(); search.focus(); search.select(); } + }; + document.addEventListener("keydown", documentKeyHandler); + } + + function mount(container, arg, dashCtx) { + ctx = dashCtx; + root = container; + ctx.setBreadcrumb("Memory"); + installGlobalKeys(); + var parsed = parseArg(arg); + state.activeType = parsed.type; + state.selectedId = parsed.id; + state.query = ""; + state.list = { items: [], nextOffset: null, loading: true, error: null }; + state.detail = { item: null, loading: false, error: null }; + render(); + loadHealth(); + return loadList(true).then(function () { if (state.selectedId) return loadDetail(state.selectedId); }); + } + + if (window.PhantomDashboard && window.PhantomDashboard.registerRoute) { + window.PhantomDashboard.registerRoute("memory", { mount: mount }); + } +})(); diff --git a/src/index.ts b/src/index.ts index d025825..50aed7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,9 @@ import { closePreviewResources, createPreviewToolServer, getOrCreatePreviewConte import { setBootstrapDb, setDashboardDb, + setEvolutionEngine, + setEvolutionQueue, + setMemorySystem, setPublicDir, setSchedulerInstance, setSecretSavedCallback, @@ -112,6 +115,7 @@ async function main(): Promise { await memory.initialize(); setMemoryHealthProvider(() => memory.healthCheck()); + setMemorySystem(memory); // Runtime is created before evolution so we can wire it into the engine. // Evolution judges run through the same Agent SDK subprocess as the main @@ -136,6 +140,8 @@ async function main(): Promise { const cadenceConfig = loadCadenceConfig(engine.getEvolutionConfig()); evolutionCadence = new EvolutionCadence(engine, queue, engine.getEvolutionConfig(), cadenceConfig); engine.setQueueWiring(queue, () => evolutionCadence?.onEnqueue()); + setEvolutionEngine(engine); + setEvolutionQueue(queue); // The cadence drains the queue out-of-band, so the runtime's in-memory // evolved config snapshot must be refreshed from disk after each // applied change. Without this callback the queued path would rewrite diff --git a/src/memory/__tests__/qdrant-client.test.ts b/src/memory/__tests__/qdrant-client.test.ts index 3ae90fb..89ead0a 100644 --- a/src/memory/__tests__/qdrant-client.test.ts +++ b/src/memory/__tests__/qdrant-client.test.ts @@ -238,4 +238,175 @@ describe("QdrantClient", () => { expect(deleteBody).not.toBeNull(); expect(deleteBody.points).toEqual(["point-123"]); }); + + test("scroll sends POST to points/scroll with limit and with_payload defaults", async () => { + let capturedUrl = ""; + let capturedBody: Record | null = null; + globalThis.fetch = mock((url: string | Request, init?: RequestInit) => { + capturedUrl = typeof url === "string" ? url : url.url; + if (init?.body) capturedBody = JSON.parse(init.body as string); + return Promise.resolve( + new Response( + JSON.stringify({ + result: { + points: [{ id: "p1", payload: { a: 1 } }], + next_page_offset: "cursor-1", + }, + status: "ok", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + const res = await client.scroll("episodes", { limit: 20 }); + + expect(capturedUrl).toContain("/collections/episodes/points/scroll"); + const body = capturedBody as unknown as Record; + expect(body.limit).toBe(20); + expect(body.with_payload).toBe(true); + expect(body.offset).toBeUndefined(); + expect(body.filter).toBeUndefined(); + expect(body.order_by).toBeUndefined(); + expect(res.points.length).toBe(1); + expect(res.points[0].id).toBe("p1"); + expect(res.points[0].score).toBe(0); + expect(res.points[0].payload.a).toBe(1); + expect(res.nextOffset).toBe("cursor-1"); + }); + + test("scroll passes offset, filter, and order_by through", async () => { + let capturedBody: Record | null = null; + globalThis.fetch = mock((_url: string | Request, init?: RequestInit) => { + if (init?.body) capturedBody = JSON.parse(init.body as string); + return Promise.resolve( + new Response(JSON.stringify({ result: { points: [], next_page_offset: null } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + await client.scroll("facts", { + limit: 10, + offset: "cursor-abc", + filter: { must: [{ key: "category", match: { value: "domain_knowledge" } }] }, + orderBy: { key: "valid_from", direction: "desc" }, + withPayload: true, + }); + + const body = capturedBody as unknown as Record; + expect(body.limit).toBe(10); + expect(body.offset).toBe("cursor-abc"); + expect((body.order_by as Record).key).toBe("valid_from"); + expect((body.order_by as Record).direction).toBe("desc"); + const filter = body.filter as { must: Array> }; + expect(filter.must[0].key).toBe("category"); + }); + + test("scroll returns nextOffset null when response lacks cursor", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ result: { points: [{ id: 42, payload: {} }] } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + const res = await client.scroll("episodes", { limit: 5 }); + expect(res.nextOffset).toBeNull(); + expect(res.points[0].id).toBe("42"); + }); + + test("scroll paginates through pages", async () => { + const pages = [ + { result: { points: [{ id: "a", payload: {} }], next_page_offset: "cursor-2" } }, + { result: { points: [{ id: "b", payload: {} }], next_page_offset: null } }, + ]; + let call = 0; + globalThis.fetch = mock(() => { + const body = pages[call]; + call += 1; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + const page1 = await client.scroll("episodes", { limit: 1 }); + expect(page1.nextOffset).toBe("cursor-2"); + const page2 = await client.scroll("episodes", { limit: 1, offset: page1.nextOffset as string }); + expect(page2.nextOffset).toBeNull(); + expect(page2.points[0].id).toBe("b"); + }); + + test("scroll throws on Qdrant error", async () => { + globalThis.fetch = mock(() => Promise.resolve(new Response("boom", { status: 500 }))) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + await expect(client.scroll("episodes", { limit: 5 })).rejects.toThrow(/scroll/); + }); + + test("scroll with_payload=false sends the override", async () => { + let capturedBody: Record | null = null; + globalThis.fetch = mock((_url: string | Request, init?: RequestInit) => { + if (init?.body) capturedBody = JSON.parse(init.body as string); + return Promise.resolve( + new Response(JSON.stringify({ result: { points: [], next_page_offset: null } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + await client.scroll("episodes", { limit: 5, withPayload: false }); + expect(capturedBody).not.toBeNull(); + const body = capturedBody as unknown as Record; + expect(body.with_payload).toBe(false); + }); + + test("countPoints returns the count from the exact response", async () => { + let capturedUrl = ""; + let capturedBody: Record | null = null; + globalThis.fetch = mock((url: string | Request, init?: RequestInit) => { + capturedUrl = typeof url === "string" ? url : url.url; + if (init?.body) capturedBody = JSON.parse(init.body as string); + return Promise.resolve( + new Response(JSON.stringify({ result: { count: 412 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + const n = await client.countPoints("episodes"); + expect(n).toBe(412); + expect(capturedUrl).toContain("/collections/episodes/points/count"); + expect(capturedBody).not.toBeNull(); + const countBody = capturedBody as unknown as Record; + expect(countBody.exact).toBe(true); + }); + + test("countPoints returns 0 when result is missing", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + + const client = new QdrantClient(TEST_CONFIG); + expect(await client.countPoints("episodes")).toBe(0); + }); }); diff --git a/src/memory/episodic.ts b/src/memory/episodic.ts index 64c0674..30c2512 100644 --- a/src/memory/episodic.ts +++ b/src/memory/episodic.ts @@ -188,6 +188,37 @@ export class EpisodicStore { .sort((a, b) => b.score - a.score); } + async scroll(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: Episode[]; nextOffset: string | number | null }> { + const { points, nextOffset } = await this.qdrant.scroll(this.collectionName, { + limit: opts.limit, + offset: opts.offset, + orderBy: { key: "started_at", direction: "desc" }, + withPayload: true, + }); + return { items: points.map((p) => this.payloadToEpisode(p)), nextOffset }; + } + + async getById(id: string): Promise { + const { points } = await this.qdrant.scroll(this.collectionName, { + limit: 1, + filter: { must: [{ has_id: [id] }] }, + withPayload: true, + }); + if (points.length === 0) return null; + return this.payloadToEpisode(points[0]); + } + + async deleteById(id: string): Promise { + await this.qdrant.deletePoint(this.collectionName, id); + } + + async count(): Promise { + return this.qdrant.countPoints(this.collectionName); + } + private payloadToEpisode(result: QdrantSearchResult): Episode { const p = result.payload; return { diff --git a/src/memory/procedural.ts b/src/memory/procedural.ts index db69d67..038cbab 100644 --- a/src/memory/procedural.ts +++ b/src/memory/procedural.ts @@ -103,6 +103,37 @@ export class ProceduralStore { }); } + async scroll(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: Procedure[]; nextOffset: string | number | null }> { + const { points, nextOffset } = await this.qdrant.scroll(this.collectionName, { + limit: opts.limit, + offset: opts.offset, + orderBy: { key: "last_used_at", direction: "desc" }, + withPayload: true, + }); + return { items: points.map((p) => this.payloadToProcedure(p)), nextOffset }; + } + + async getById(id: string): Promise { + const { points } = await this.qdrant.scroll(this.collectionName, { + limit: 1, + filter: { must: [{ has_id: [id] }] }, + withPayload: true, + }); + if (points.length === 0) return null; + return this.payloadToProcedure(points[0]); + } + + async deleteById(id: string): Promise { + await this.qdrant.deletePoint(this.collectionName, id); + } + + async count(): Promise { + return this.qdrant.countPoints(this.collectionName); + } + private payloadToProcedure(result: QdrantSearchResult): Procedure { const p = result.payload; return { diff --git a/src/memory/qdrant-client.ts b/src/memory/qdrant-client.ts index ecf599f..8f5c9d5 100644 --- a/src/memory/qdrant-client.ts +++ b/src/memory/qdrant-client.ts @@ -139,6 +139,48 @@ export class QdrantClient { }); } + async scroll( + collection: string, + opts: { + limit: number; + offset?: string | number; + filter?: Record; + orderBy?: { key: string; direction: "asc" | "desc" }; + withPayload?: boolean; + }, + ): Promise<{ points: QdrantSearchResult[]; nextOffset: string | number | null }> { + const body: Record = { + limit: opts.limit, + with_payload: opts.withPayload ?? true, + }; + if (opts.offset !== undefined) body.offset = opts.offset; + if (opts.filter) body.filter = opts.filter; + if (opts.orderBy) body.order_by = { key: opts.orderBy.key, direction: opts.orderBy.direction }; + + const response = (await this.request("POST", `/collections/${collection}/points/scroll`, body)) as { + result?: { + points?: Array<{ id: string | number; payload?: Record; vector?: unknown }>; + next_page_offset?: string | number | null; + }; + }; + + const rawPoints = response.result?.points ?? []; + const points: QdrantSearchResult[] = rawPoints.map((p) => ({ + id: String(p.id), + score: 0, + payload: p.payload ?? {}, + })); + const nextOffset = response.result?.next_page_offset ?? null; + return { points, nextOffset }; + } + + async countPoints(collection: string, exact = true): Promise { + const response = (await this.request("POST", `/collections/${collection}/points/count`, { + exact, + })) as { result?: { count?: number } }; + return response.result?.count ?? 0; + } + async updatePayload(collection: string, id: string, payload: Record): Promise { await this.request("POST", `/collections/${collection}/points/payload`, { payload, diff --git a/src/memory/semantic.ts b/src/memory/semantic.ts index 619d8e4..e332acb 100644 --- a/src/memory/semantic.ts +++ b/src/memory/semantic.ts @@ -174,6 +174,37 @@ export class SemanticStore { return { must }; } + async scroll(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: SemanticFact[]; nextOffset: string | number | null }> { + const { points, nextOffset } = await this.qdrant.scroll(this.collectionName, { + limit: opts.limit, + offset: opts.offset, + orderBy: { key: "valid_from", direction: "desc" }, + withPayload: true, + }); + return { items: points.map((p) => this.payloadToFact(p)), nextOffset }; + } + + async getById(id: string): Promise { + const { points } = await this.qdrant.scroll(this.collectionName, { + limit: 1, + filter: { must: [{ has_id: [id] }] }, + withPayload: true, + }); + if (points.length === 0) return null; + return this.payloadToFact(points[0]); + } + + async deleteById(id: string): Promise { + await this.qdrant.deletePoint(this.collectionName, id); + } + + async count(): Promise { + return this.qdrant.countPoints(this.collectionName); + } + private payloadToFact(result: QdrantSearchResult): SemanticFact { const p = result.payload; return { diff --git a/src/memory/system.ts b/src/memory/system.ts index 8800674..c561982 100644 --- a/src/memory/system.ts +++ b/src/memory/system.ts @@ -113,6 +113,66 @@ export class MemorySystem { return { episodesCreated: 0, factsExtracted: 0, proceduresDetected: 0, durationMs: 0 }; } + // Dashboard read/delete surface. Scroll paginates by recency; getById fetches + // one item; deleteById is the operator-confirmed write path used by the + // Memory explorer tab. Counts power the health strip. + async scrollEpisodes(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: Episode[]; nextOffset: string | number | null }> { + return this.episodic.scroll(opts); + } + + async scrollFacts(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: SemanticFact[]; nextOffset: string | number | null }> { + return this.semantic.scroll(opts); + } + + async scrollProcedures(opts: { + limit: number; + offset?: string | number; + }): Promise<{ items: Procedure[]; nextOffset: string | number | null }> { + return this.procedural.scroll(opts); + } + + async getEpisodeById(id: string): Promise { + return this.episodic.getById(id); + } + + async getFactById(id: string): Promise { + return this.semantic.getById(id); + } + + async getProcedureById(id: string): Promise { + return this.procedural.getById(id); + } + + async deleteEpisode(id: string): Promise { + return this.episodic.deleteById(id); + } + + async deleteFact(id: string): Promise { + return this.semantic.deleteById(id); + } + + async deleteProcedure(id: string): Promise { + return this.procedural.deleteById(id); + } + + async countEpisodes(): Promise { + return this.episodic.count(); + } + + async countFacts(): Promise { + return this.semantic.count(); + } + + async countProcedures(): Promise { + return this.procedural.count(); + } + getEpisodicStore(): EpisodicStore { return this.episodic; } diff --git a/src/ui/api/__tests__/cost.test.ts b/src/ui/api/__tests__/cost.test.ts index 9d80894..5e02d85 100644 --- a/src/ui/api/__tests__/cost.test.ts +++ b/src/ui/api/__tests__/cost.test.ts @@ -84,13 +84,26 @@ function seedCostEvent( ); } +// When h < 24, clamp the result to the same UTC date as "now" so the test is +// stable across the UTC-day boundary. hoursAgo(3) run at 02:53 UTC would +// otherwise resolve to 23:53 the previous day, which SQLite's +// date(created_at) = date('now') would then bucket as yesterday instead of +// today. For h >= 24 (daysAgo callers), the caller intends to cross the +// boundary, so no clamp. function hoursAgo(h: number): string { - const d = new Date(Date.now() - h * 3600 * 1000); - return d.toISOString().replace("T", " ").slice(0, 19); + const now = new Date(); + const requested = new Date(now.getTime() - h * 3600 * 1000); + if (h < 24 && requested.toISOString().slice(0, 10) !== now.toISOString().slice(0, 10)) { + // Clamp to 5 minutes ago so the event is unambiguously today. + const clamped = new Date(now.getTime() - 5 * 60 * 1000); + return clamped.toISOString().replace("T", " ").slice(0, 19); + } + return requested.toISOString().replace("T", " ").slice(0, 19); } function daysAgo(d: number): string { - return hoursAgo(d * 24); + const ts = new Date(Date.now() - d * 24 * 3600 * 1000); + return ts.toISOString().replace("T", " ").slice(0, 19); } beforeEach(() => { diff --git a/src/ui/api/__tests__/evolution.test.ts b/src/ui/api/__tests__/evolution.test.ts new file mode 100644 index 0000000..44c50b8 --- /dev/null +++ b/src/ui/api/__tests__/evolution.test.ts @@ -0,0 +1,621 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { EvolutionConfig } from "../../../evolution/config.ts"; +import { EvolutionConfigSchema } from "../../../evolution/config.ts"; +import type { EvolutionEngine } from "../../../evolution/engine.ts"; +import type { EvolutionQueue, PoisonedRow } from "../../../evolution/queue.ts"; +import type { EvolutionLogEntry, EvolutionMetrics, ReflectionStats } from "../../../evolution/types.ts"; +import { handleEvolutionApi } from "../evolution.ts"; + +type MetricsShape = EvolutionMetrics & { reflection_stats?: Partial }; + +let tmp = ""; + +function makeConfig(root: string): EvolutionConfig { + const metaDir = join(root, "meta"); + const memoryDir = join(root, "memory"); + mkdirSync(metaDir, { recursive: true }); + mkdirSync(memoryDir, { recursive: true }); + writeFileSync(join(root, "constitution.md"), "immutable principles", "utf-8"); + return EvolutionConfigSchema.parse({ + paths: { + config_dir: root, + constitution: join(root, "constitution.md"), + version_file: join(metaDir, "version.json"), + metrics_file: join(metaDir, "metrics.json"), + evolution_log: join(metaDir, "evolution-log.jsonl"), + session_log: join(memoryDir, "session-log.jsonl"), + }, + }); +} + +function seedVersion( + config: EvolutionConfig, + data: { version: number; parent: number | null; timestamp?: string }, +): void { + const payload = { + version: data.version, + parent: data.parent, + timestamp: data.timestamp ?? "2026-04-15T10:00:00.000Z", + changes: [], + metrics_at_change: { session_count: 0, success_rate_7d: 0 }, + }; + writeFileSync(config.paths.version_file, `${JSON.stringify(payload, null, 2)}\n`, "utf-8"); +} + +function seedMetrics(config: EvolutionConfig, metrics: MetricsShape): void { + writeFileSync(config.paths.metrics_file, `${JSON.stringify(metrics, null, 2)}\n`, "utf-8"); +} + +function seedLog(config: EvolutionConfig, entries: EvolutionLogEntry[]): void { + const body = entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""); + writeFileSync(config.paths.evolution_log, body, "utf-8"); +} + +function buildStubEngine(config: EvolutionConfig): EvolutionEngine { + return { + getEvolutionConfig: () => config, + getCurrentVersion: () => { + try { + const txt = require("node:fs").readFileSync(config.paths.version_file, "utf-8"); + return JSON.parse(txt).version ?? 0; + } catch { + return 0; + } + }, + getEvolutionLog: (limit: number) => { + try { + const txt = (require("node:fs").readFileSync(config.paths.evolution_log, "utf-8") as string).trim(); + if (!txt) return []; + const lines = txt.split("\n").filter(Boolean) as string[]; + return lines.slice(-limit).map((l: string) => JSON.parse(l) as EvolutionLogEntry); + } catch { + return []; + } + }, + getMetrics: () => { + try { + const txt = require("node:fs").readFileSync(config.paths.metrics_file, "utf-8"); + return JSON.parse(txt); + } catch { + return { + session_count: 0, + success_count: 0, + failure_count: 0, + evolution_count: 0, + last_session_at: null, + last_evolution_at: null, + success_rate_7d: 0, + }; + } + }, + } as unknown as EvolutionEngine; +} + +function buildPoisonQueue(rows: PoisonedRow[]): EvolutionQueue { + return { listPoisonPile: () => rows } as unknown as EvolutionQueue; +} + +function req(path: string, init?: RequestInit): Request { + return new Request(`http://localhost${path}`, init); +} + +function url(path: string): URL { + return new URL(`http://localhost${path}`); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "phantom-evolution-test-")); +}); + +afterEach(() => { + if (tmp) rmSync(tmp, { recursive: true, force: true }); + tmp = ""; +}); + +describe("evolution API overview", () => { + test("returns current version, metrics, and poison_count", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 7, parent: 6, timestamp: "2026-04-14T14:02:00.000Z" }); + seedMetrics(config, { + session_count: 42, + success_count: 38, + failure_count: 4, + evolution_count: 7, + last_session_at: "2026-04-14T13:59:00.000Z", + last_evolution_at: "2026-04-14T14:02:00.000Z", + success_rate_7d: 0.91, + reflection_stats: { + drains: 12, + stage_haiku_runs: 9, + stage_sonnet_runs: 2, + stage_opus_runs: 1, + status_ok: 7, + status_skip: 4, + status_escalate_cap: 1, + total_cost_usd: 0.42, + sigkill_before_write: 0, + sigkill_mid_write: 1, + invariant_failed_hard: 2, + files_touched: { "persona.md": 3, "strategies/tool-preferences.md": 5, "domain-knowledge.md": 1 }, + }, + }); + const engine = buildStubEngine(config); + const queue = buildPoisonQueue([ + { + id: 1, + session_id: "s1", + session_key: "chat:abc", + gate_decision: { fire: true, source: "failsafe", reason: "", haiku_cost_usd: 0 }, + session_summary: { + session_id: "s1", + session_key: "chat:abc", + user_id: "u1", + user_messages: [], + assistant_messages: [], + tools_used: [], + files_tracked: [], + outcome: "success", + cost_usd: 0, + started_at: "2026-04-14T13:00:00.000Z", + ended_at: "2026-04-14T13:02:00.000Z", + }, + original_enqueued_at: "2026-04-14T13:02:00.000Z", + poisoned_at: "2026-04-14T13:05:00.000Z", + failure_reason: "invariant fail", + }, + ]); + + const res = await handleEvolutionApi(req("/ui/api/evolution"), url("/ui/api/evolution"), { engine, queue }); + expect(res).not.toBeNull(); + const r = res as Response; + expect(r.status).toBe(200); + const body = (await r.json()) as Record; + expect((body.current as Record).version).toBe(7); + expect((body.current as Record).parent).toBe(6); + expect(body.poison_count).toBe(1); + const metrics = body.metrics as Record; + expect(metrics.session_count).toBe(42); + const stats = metrics.reflection_stats as Record; + expect(stats.drains).toBe(12); + expect(stats.cost_usd).toBe(0.42); + const tiers = stats.tiers as Record; + expect(tiers.haiku).toBe(9); + expect(tiers.sonnet).toBe(2); + expect(tiers.opus).toBe(1); + expect(stats.sigkills).toBe(1); + expect(stats.invariant_fails).toBe(2); + const files = stats.files_touched as Array<{ file: string; count: number }>; + expect(files[0].file).toBe("strategies/tool-preferences.md"); + expect(files[0].count).toBe(5); + expect(files.length).toBeLessThanOrEqual(10); + }); + + test("returns poison_count 0 when queue is absent", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 0, parent: null }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution"), url("/ui/api/evolution"), { + engine, + })) as Response; + const body = (await res.json()) as Record; + expect(body.poison_count).toBe(0); + }); + + test("defaults reflection_stats block when metrics.json lacks it", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 2, parent: 1 }); + seedMetrics(config, { + session_count: 0, + success_count: 0, + failure_count: 0, + evolution_count: 0, + last_session_at: null, + last_evolution_at: null, + success_rate_7d: 0, + }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution"), url("/ui/api/evolution"), { + engine, + })) as Response; + const body = (await res.json()) as Record; + const stats = (body.metrics as Record).reflection_stats as Record; + expect(stats.drains).toBe(0); + expect((stats.tiers as Record).haiku).toBe(0); + expect(stats.files_touched).toEqual([]); + }); + + test("405 on non-GET to overview", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution", { method: "POST" }), url("/ui/api/evolution"), { + engine, + })) as Response; + expect(res.status).toBe(405); + }); +}); + +describe("evolution API timeline", () => { + function entries(count: number): EvolutionLogEntry[] { + const out: EvolutionLogEntry[] = []; + for (let v = 1; v <= count; v++) { + out.push({ + timestamp: `2026-04-${String(v).padStart(2, "0")}T10:00:00.000Z`, + version: v, + drain_id: `drain-${v}`, + session_ids: [`sess-${v}`], + tier: v % 3 === 0 ? "opus" : v % 2 === 0 ? "sonnet" : "haiku", + status: "ok", + changes_applied: 1, + details: [ + { + file: `meta/file-${v}.md`, + type: "edit", + summary: `Change ${v}`, + rationale: "operator signal", + session_ids: [`sess-${v}`], + }, + ], + }); + } + return out; + } + + test("returns default limit and newest-first ordering", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 15, parent: 14 }); + seedLog(config, entries(15)); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/timeline"), url("/ui/api/evolution/timeline"), { + engine, + })) as Response; + const body = (await res.json()) as { entries: EvolutionLogEntry[]; has_more: boolean }; + expect(body.entries.length).toBe(15); + expect(body.entries[0].version).toBe(15); + expect(body.entries[14].version).toBe(1); + expect(body.has_more).toBe(false); + }); + + test("paginates with has_more when limit less than count", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 30, parent: 29 }); + seedLog(config, entries(30)); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/timeline?limit=10"), + url("/ui/api/evolution/timeline?limit=10"), + { engine }, + )) as Response; + const body = (await res.json()) as { entries: EvolutionLogEntry[]; has_more: boolean }; + expect(body.entries.length).toBe(10); + expect(body.entries[0].version).toBe(30); + expect(body.has_more).toBe(true); + }); + + test("before_version filters to older entries", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 12, parent: 11 }); + seedLog(config, entries(12)); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/timeline?before_version=5"), + url("/ui/api/evolution/timeline?before_version=5"), + { engine }, + )) as Response; + const body = (await res.json()) as { entries: EvolutionLogEntry[]; has_more: boolean }; + expect(body.entries.length).toBe(4); + expect(body.entries[0].version).toBe(4); + expect(body.entries[3].version).toBe(1); + expect(body.has_more).toBe(false); + }); + + test("422 on non-integer limit", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/timeline?limit=abc"), + url("/ui/api/evolution/timeline?limit=abc"), + { engine }, + )) as Response; + expect(res.status).toBe(422); + }); + + test("422 on limit > 100", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/timeline?limit=150"), + url("/ui/api/evolution/timeline?limit=150"), + { engine }, + )) as Response; + expect(res.status).toBe(422); + }); + + test("405 on non-GET to timeline", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/timeline", { method: "POST" }), + url("/ui/api/evolution/timeline"), + { engine }, + )) as Response; + expect(res.status).toBe(405); + }); +}); + +describe("evolution API version detail", () => { + test("current version returns full version record with diff content", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 3, parent: 2 }); + const log: EvolutionLogEntry[] = [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 3, + drain_id: "drain-3", + session_ids: ["s3"], + tier: "sonnet", + status: "ok", + changes_applied: 1, + details: [ + { + file: "persona.md", + type: "edit", + summary: "sharpened tone", + rationale: "operator feedback", + session_ids: ["s3"], + }, + ], + }, + ]; + seedLog(config, log); + writeFileSync(join(tmp, "persona.md"), "# Persona\nHello world.\n", "utf-8"); + + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/3"), url("/ui/api/evolution/version/3"), { + engine, + })) as Response; + expect(res.status).toBe(200); + const body = (await res.json()) as { + version: { version: number; parent: number | null }; + diff: Array<{ + file: string; + type: string; + summary: string; + rationale: string; + current_content: string; + current_size: number; + session_ids: string[]; + }>; + has_snapshot: boolean; + }; + expect(body.version.version).toBe(3); + expect(body.version.parent).toBe(2); + expect(body.has_snapshot).toBe(false); + expect(body.diff.length).toBe(1); + expect(body.diff[0].file).toBe("persona.md"); + expect(body.diff[0].current_content).toContain("Hello world"); + expect(body.diff[0].current_size).toBeGreaterThan(0); + }); + + test("historical version synthesizes parent as n-1", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 5, parent: 4 }); + const log: EvolutionLogEntry[] = [ + { + timestamp: "2026-04-10T10:00:00.000Z", + version: 2, + drain_id: "d2", + session_ids: ["s2"], + tier: "haiku", + status: "skip", + changes_applied: 0, + details: [], + }, + ]; + seedLog(config, log); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/2"), url("/ui/api/evolution/version/2"), { + engine, + })) as Response; + expect(res.status).toBe(200); + const body = (await res.json()) as { version: { version: number; parent: number | null }; diff: unknown[] }; + expect(body.version.version).toBe(2); + expect(body.version.parent).toBe(1); + expect(body.diff).toEqual([]); + }); + + test("unknown version returns 404", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 5, parent: 4 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/99"), url("/ui/api/evolution/version/99"), { + engine, + })) as Response; + expect(res.status).toBe(404); + }); + + test("deleted file yields empty current_content", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 2, parent: 1 }); + seedLog(config, [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 2, + drain_id: "d2", + session_ids: ["s2"], + tier: "haiku", + status: "ok", + changes_applied: 1, + details: [ + { + file: "removed-file.md", + type: "delete", + summary: "retired stub", + rationale: "no longer needed", + session_ids: ["s2"], + }, + ], + }, + ]); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/2"), url("/ui/api/evolution/version/2"), { + engine, + })) as Response; + const body = (await res.json()) as { diff: Array<{ current_content: string; current_size: number }> }; + expect(body.diff[0].current_content).toBe(""); + expect(body.diff[0].current_size).toBe(0); + }); + + test("path traversal in details[].file yields empty preview, does not read outside config_dir", async () => { + const config = makeConfig(tmp); + // Write a sentinel file outside config_dir so we would notice if the + // traversal accidentally succeeded. + const outsidePath = `${tmp}/SECRET_outside_config_dir.txt`; + writeFileSync(outsidePath, "SHOULD-NOT-LEAK"); + seedVersion(config, { version: 9, parent: 8 }); + seedLog(config, [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 9, + drain_id: "d9", + session_ids: ["s9"], + tier: "haiku", + status: "ok", + changes_applied: 1, + details: [ + { + file: "../SECRET_outside_config_dir.txt", + type: "edit", + summary: "attempted traversal", + rationale: "should be blocked", + session_ids: ["s9"], + }, + ], + }, + ]); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/9"), url("/ui/api/evolution/version/9"), { + engine, + })) as Response; + const body = (await res.json()) as { diff: Array<{ current_content: string; current_size: number }> }; + expect(body.diff[0].current_content).toBe(""); + expect(body.diff[0].current_size).toBe(0); + }); + + test("file content exceeding 64 KB is truncated, size is the full byte length", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 3, parent: 2 }); + seedLog(config, [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 3, + drain_id: "d3", + session_ids: ["s3"], + tier: "sonnet", + status: "ok", + changes_applied: 1, + details: [ + { + file: "big.md", + type: "edit", + summary: "x", + rationale: "y", + session_ids: ["s3"], + }, + ], + }, + ]); + const big = "a".repeat(70 * 1024); + writeFileSync(join(tmp, "big.md"), big, "utf-8"); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/3"), url("/ui/api/evolution/version/3"), { + engine, + })) as Response; + const body = (await res.json()) as { diff: Array<{ current_content: string; current_size: number }> }; + expect(body.diff[0].current_size).toBe(70 * 1024); + expect(body.diff[0].current_content.length).toBe(64 * 1024); + }); + + test("has_snapshot is always false in Phase A", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + seedLog(config, [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 1, + drain_id: "d1", + session_ids: [], + tier: "haiku", + status: "ok", + changes_applied: 0, + details: [], + }, + ]); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/1"), url("/ui/api/evolution/version/1"), { + engine, + })) as Response; + const body = (await res.json()) as { has_snapshot: boolean }; + expect(body.has_snapshot).toBe(false); + }); + + test("400 on non-integer path parameter", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/version/abc"), url("/ui/api/evolution/version/abc"), { + engine, + })) as Response; + expect(res.status).toBe(400); + }); + + test("405 on non-GET to version", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + seedLog(config, [ + { + timestamp: "2026-04-14T10:00:00.000Z", + version: 1, + drain_id: "d1", + session_ids: [], + tier: "haiku", + status: "ok", + changes_applied: 0, + details: [], + }, + ]); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi( + req("/ui/api/evolution/version/1", { method: "POST" }), + url("/ui/api/evolution/version/1"), + { engine }, + )) as Response; + expect(res.status).toBe(405); + }); +}); + +describe("evolution API misrouting", () => { + test("returns null for unrelated path", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = await handleEvolutionApi(req("/ui/api/other"), url("/ui/api/other"), { engine }); + expect(res).toBeNull(); + }); + + test("returns 404 for unknown /ui/api/evolution/ sub-path", async () => { + const config = makeConfig(tmp); + seedVersion(config, { version: 1, parent: 0 }); + const engine = buildStubEngine(config); + const res = (await handleEvolutionApi(req("/ui/api/evolution/nonsense"), url("/ui/api/evolution/nonsense"), { + engine, + })) as Response; + expect(res.status).toBe(404); + }); +}); diff --git a/src/ui/api/__tests__/memory.test.ts b/src/ui/api/__tests__/memory.test.ts new file mode 100644 index 0000000..966ec41 --- /dev/null +++ b/src/ui/api/__tests__/memory.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { MemorySystem } from "../../../memory/system.ts"; +import type { Episode, Procedure, SemanticFact } from "../../../memory/types.ts"; +import { handleMemoryApi } from "../memory.ts"; + +function req(path: string, init?: RequestInit): Request { + return new Request(`http://localhost${path}`, init); +} + +function makeEpisode(id: string, overrides: Partial = {}): Episode { + return { + id, + type: "task", + summary: "Refactored payments module", + detail: "Cleaned up duplicate retry logic in src/payments/*.ts", + parent_id: null, + session_id: "sess-123", + user_id: "u-1", + tools_used: ["Read", "Edit"], + files_touched: ["src/payments/index.ts"], + outcome: "success", + outcome_detail: "", + lessons: ["stripe wants idempotency keys"], + started_at: "2026-04-12T11:30:00.000Z", + ended_at: "2026-04-12T11:42:00.000Z", + duration_seconds: 720, + importance: 0.82, + access_count: 3, + last_accessed_at: "2026-04-14T09:12:00.000Z", + decay_rate: 0.95, + ...overrides, + }; +} + +function makeFact(id: string, overrides: Partial = {}): SemanticFact { + return { + id, + subject: "user", + predicate: "prefers", + object: "vim bindings", + natural_language: "User prefers vim bindings in the editor.", + source_episode_ids: ["ep-1"], + confidence: 0.88, + valid_from: "2026-04-10T09:00:00.000Z", + valid_until: null, + version: 1, + previous_version_id: null, + category: "user_preference", + tags: ["editor", "input"], + ...overrides, + }; +} + +function makeProcedure(id: string, overrides: Partial = {}): Procedure { + return { + id, + name: "deploy phantom", + description: "rsync + restart systemd", + trigger: "deploy to production", + steps: [], + preconditions: [], + postconditions: [], + parameters: {}, + source_episode_ids: [], + success_count: 4, + failure_count: 0, + last_used_at: "2026-04-13T10:00:00.000Z", + confidence: 0.9, + version: 1, + ...overrides, + }; +} + +function memoryStub(overrides: Partial> = {}): MemorySystem { + const base = { + healthCheck: mock(async () => ({ qdrant: true, ollama: true, configured: true })), + countEpisodes: mock(async () => 412), + countFacts: mock(async () => 128), + countProcedures: mock(async () => 18), + recallEpisodes: mock(async () => [makeEpisode("ep-1")]), + recallFacts: mock(async () => [makeFact("fact-1")]), + findProcedure: mock(async () => makeProcedure("proc-1")), + scrollEpisodes: mock(async () => ({ items: [makeEpisode("ep-1")], nextOffset: "cursor-2" })), + scrollFacts: mock(async () => ({ items: [makeFact("fact-1")], nextOffset: null })), + scrollProcedures: mock(async () => ({ items: [makeProcedure("proc-1")], nextOffset: null })), + getEpisodeById: mock(async (id: string) => (id === "ep-1" ? makeEpisode("ep-1") : null)), + getFactById: mock(async (id: string) => (id === "fact-1" ? makeFact("fact-1") : null)), + getProcedureById: mock(async (id: string) => (id === "proc-1" ? makeProcedure("proc-1") : null)), + deleteEpisode: mock(async () => undefined), + deleteFact: mock(async () => undefined), + deleteProcedure: mock(async () => undefined), + ...overrides, + }; + return base as unknown as MemorySystem; +} + +describe("memory API health", () => { + test("returns counts when Qdrant and Ollama are healthy", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi(req("/ui/api/memory/health"), new URL("http://localhost/ui/api/memory/health"), { + memory, + })) as Response; + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.qdrant).toBe(true); + expect(body.ollama).toBe(true); + const counts = body.counts as Record; + expect(counts.episodes).toBe(412); + expect(counts.facts).toBe(128); + expect(counts.procedures).toBe(18); + }); + + test("returns zero counts when Qdrant is down", async () => { + const memory = memoryStub({ + healthCheck: mock(async () => ({ qdrant: false, ollama: true, configured: true })), + }); + const res = (await handleMemoryApi(req("/ui/api/memory/health"), new URL("http://localhost/ui/api/memory/health"), { + memory, + })) as Response; + const body = (await res.json()) as { qdrant: boolean; counts: Record }; + expect(body.qdrant).toBe(false); + expect(body.counts.episodes).toBe(0); + expect(body.counts.facts).toBe(0); + expect(body.counts.procedures).toBe(0); + }); + + test("tolerates individual count failures", async () => { + const memory = memoryStub({ + countFacts: mock(async () => { + throw new Error("boom"); + }), + }); + const res = (await handleMemoryApi(req("/ui/api/memory/health"), new URL("http://localhost/ui/api/memory/health"), { + memory, + })) as Response; + const body = (await res.json()) as { counts: Record }; + expect(body.counts.episodes).toBe(412); + expect(body.counts.facts).toBe(0); + expect(body.counts.procedures).toBe(18); + }); + + test("405 on non-GET", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/health", { method: "POST" }), + new URL("http://localhost/ui/api/memory/health"), + { memory }, + )) as Response; + expect(res.status).toBe(405); + }); +}); + +describe("memory API list", () => { + test("empty search uses scroll", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes"), + new URL("http://localhost/ui/api/memory/episodes"), + { memory }, + )) as Response; + expect(res.status).toBe(200); + const body = (await res.json()) as { items: Episode[]; nextOffset: string | null }; + expect(body.items.length).toBe(1); + expect(body.nextOffset).toBe("cursor-2"); + expect( + (memory as unknown as { scrollEpisodes: { mock: { calls: unknown[][] } } }).scrollEpisodes.mock.calls.length, + ).toBe(1); + expect( + (memory as unknown as { recallEpisodes: { mock: { calls: unknown[][] } } }).recallEpisodes.mock.calls.length, + ).toBe(0); + }); + + test("with q uses recall for episodes", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes?q=payments"), + new URL("http://localhost/ui/api/memory/episodes?q=payments"), + { memory }, + )) as Response; + const body = (await res.json()) as { items: Episode[]; nextOffset: string | null }; + expect(body.items.length).toBe(1); + expect(body.nextOffset).toBeNull(); + const recallCalls = (memory as unknown as { recallEpisodes: { mock: { calls: unknown[][] } } }).recallEpisodes.mock + .calls; + expect(recallCalls.length).toBe(1); + expect(recallCalls[0][0]).toBe("payments"); + }); + + test("with q uses recall for facts", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/facts?q=vim"), + new URL("http://localhost/ui/api/memory/facts?q=vim"), + { memory }, + )) as Response; + const body = (await res.json()) as { items: SemanticFact[]; nextOffset: null }; + expect(body.items.length).toBe(1); + expect(body.nextOffset).toBeNull(); + }); + + test("with q uses findProcedure for procedures", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/procedures?q=deploy"), + new URL("http://localhost/ui/api/memory/procedures?q=deploy"), + { memory }, + )) as Response; + const body = (await res.json()) as { items: Procedure[]; nextOffset: null }; + expect(body.items.length).toBe(1); + }); + + test("findProcedure returns null yields empty list", async () => { + const memory = memoryStub({ findProcedure: mock(async () => null) }); + const res = (await handleMemoryApi( + req("/ui/api/memory/procedures?q=nope"), + new URL("http://localhost/ui/api/memory/procedures?q=nope"), + { memory }, + )) as Response; + const body = (await res.json()) as { items: Procedure[] }; + expect(body.items).toEqual([]); + }); + + test("passes offset to scroll", async () => { + const memory = memoryStub(); + await handleMemoryApi( + req("/ui/api/memory/episodes?offset=cursor-123&limit=5"), + new URL("http://localhost/ui/api/memory/episodes?offset=cursor-123&limit=5"), + { memory }, + ); + const calls = (memory as unknown as { scrollEpisodes: { mock: { calls: unknown[][] } } }).scrollEpisodes.mock.calls; + expect(calls.length).toBe(1); + const firstArg = calls[0][0] as { limit: number; offset?: string }; + expect(firstArg.limit).toBe(5); + expect(firstArg.offset).toBe("cursor-123"); + }); + + test("422 on limit > 100", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes?limit=200"), + new URL("http://localhost/ui/api/memory/episodes?limit=200"), + { memory }, + )) as Response; + expect(res.status).toBe(422); + }); + + test("422 on non-integer limit", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/facts?limit=abc"), + new URL("http://localhost/ui/api/memory/facts?limit=abc"), + { memory }, + )) as Response; + expect(res.status).toBe(422); + }); + + test("404 on unknown type in list", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi(req("/ui/api/memory/dreams"), new URL("http://localhost/ui/api/memory/dreams"), { + memory, + })) as Response; + expect(res.status).toBe(404); + }); + + test("405 on non-GET to list", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes", { method: "POST" }), + new URL("http://localhost/ui/api/memory/episodes"), + { memory }, + )) as Response; + expect(res.status).toBe(405); + }); +}); + +describe("memory API detail", () => { + test("happy path returns item", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes/ep-1"), + new URL("http://localhost/ui/api/memory/episodes/ep-1"), + { memory }, + )) as Response; + const body = (await res.json()) as { item: Episode }; + expect(body.item.id).toBe("ep-1"); + }); + + test("404 on unknown id", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes/nope"), + new URL("http://localhost/ui/api/memory/episodes/nope"), + { memory }, + )) as Response; + expect(res.status).toBe(404); + }); + + test("400 on id containing control characters", async () => { + const memory = memoryStub(); + const encoded = encodeURIComponent("bad\u0000id"); + const res = (await handleMemoryApi( + req(`/ui/api/memory/facts/${encoded}`), + new URL(`http://localhost/ui/api/memory/facts/${encoded}`), + { memory }, + )) as Response; + expect(res.status).toBe(400); + }); + + test("handles URL-encoded id with colon", async () => { + const memory = memoryStub({ + getEpisodeById: mock(async (id: string) => (id === "chat:abc" ? makeEpisode("chat:abc") : null)), + }); + const encoded = encodeURIComponent("chat:abc"); + const res = (await handleMemoryApi( + req(`/ui/api/memory/episodes/${encoded}`), + new URL(`http://localhost/ui/api/memory/episodes/${encoded}`), + { memory }, + )) as Response; + expect(res.status).toBe(200); + }); +}); + +describe("memory API delete", () => { + test("happy path removes and returns deleted:true", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes/ep-1", { method: "DELETE" }), + new URL("http://localhost/ui/api/memory/episodes/ep-1"), + { memory }, + )) as Response; + expect(res.status).toBe(200); + const body = (await res.json()) as { deleted: boolean; id: string }; + expect(body.deleted).toBe(true); + expect(body.id).toBe("ep-1"); + const deleteCalls = (memory as unknown as { deleteEpisode: { mock: { calls: unknown[][] } } }).deleteEpisode.mock + .calls; + expect(deleteCalls.length).toBe(1); + expect(deleteCalls[0][0]).toBe("ep-1"); + }); + + test("404 on unknown id returns without calling deletePoint", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/facts/missing", { method: "DELETE" }), + new URL("http://localhost/ui/api/memory/facts/missing"), + { memory }, + )) as Response; + expect(res.status).toBe(404); + const deleteCalls = (memory as unknown as { deleteFact: { mock: { calls: unknown[][] } } }).deleteFact.mock.calls; + expect(deleteCalls.length).toBe(0); + }); + + test("procedures delete works", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/procedures/proc-1", { method: "DELETE" }), + new URL("http://localhost/ui/api/memory/procedures/proc-1"), + { memory }, + )) as Response; + expect(res.status).toBe(200); + }); +}); + +describe("memory API misrouting", () => { + test("returns null on unrelated path", async () => { + const memory = memoryStub(); + const res = await handleMemoryApi(req("/ui/api/sessions"), new URL("http://localhost/ui/api/sessions"), { memory }); + expect(res).toBeNull(); + }); + + test("PUT method not allowed on detail", async () => { + const memory = memoryStub(); + const res = (await handleMemoryApi( + req("/ui/api/memory/episodes/ep-1", { method: "PUT" }), + new URL("http://localhost/ui/api/memory/episodes/ep-1"), + { memory }, + )) as Response; + expect(res.status).toBe(405); + }); +}); diff --git a/src/ui/api/evolution.ts b/src/ui/api/evolution.ts new file mode 100644 index 0000000..875ab45 --- /dev/null +++ b/src/ui/api/evolution.ts @@ -0,0 +1,297 @@ +// UI API routes for the Evolution dashboard tab (Phase A, read-only). +// +// All routes live under /ui/api/evolution and are cookie-auth gated by the +// dispatcher in src/ui/serve.ts. +// +// GET /ui/api/evolution -> current + metrics + poison_count +// GET /ui/api/evolution/timeline?limit=&before_version= -> paginated log +// GET /ui/api/evolution/version/:n -> version + diff +// +// Read-only over phantom-config/meta/version.json, evolution-log.jsonl, +// metrics.json, plus the live config files under phantom-config/ for diff +// "current content" previews. NO rollback, NO writes. Snapshot storage and +// rollback ship in Phase B. + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { z } from "zod"; +import type { EvolutionEngine } from "../../evolution/engine.ts"; +import { emptyReflectionStats } from "../../evolution/metrics.ts"; +import type { EvolutionQueue } from "../../evolution/queue.ts"; +import type { EvolutionLogEntry, EvolutionMetrics, EvolutionVersion, ReflectionStats } from "../../evolution/types.ts"; +import { readVersion } from "../../evolution/versioning.ts"; + +export type EvolutionApiDeps = { + engine: EvolutionEngine; + queue?: EvolutionQueue | null; +}; + +const TIMELINE_DEFAULT_LIMIT = 20; +const TIMELINE_MAX_LIMIT = 100; +// Cap the in-memory scan window generously. Entries are small JSONL rows +// (~1-2 KB each), so reading 100k costs a few hundred MB of RAM briefly. +// Without this size, pagination via before_version silently cut off history +// past the last 500 entries on long-running deployments. If an agent ever +// crosses this ceiling, switch to a streaming reader. +const TIMELINE_SCAN_CAP = 100_000; +const FILE_PREVIEW_BYTE_CAP = 64 * 1024; +const TOP_FILES_LIMIT = 10; + +const TimelineQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(TIMELINE_MAX_LIMIT).optional(), + before_version: z.coerce.number().int().min(1).optional(), +}); + +type TimelineQuery = z.infer; + +type OverviewResponse = { + current: { version: number; timestamp: string; parent: number | null }; + metrics: { + session_count: number; + evolution_count: number; + success_rate_7d: number; + last_session_at: string | null; + last_evolution_at: string | null; + reflection_stats: { + drains: number; + cost_usd: number; + tiers: { haiku: number; sonnet: number; opus: number }; + status: { ok: number; skip: number; escalate_cap: number }; + invariant_fails: number; + sigkills: number; + files_touched: Array<{ file: string; count: number }>; + }; + }; + poison_count: number; +}; + +type DiffEntry = { + file: string; + type: "edit" | "compact" | "new" | "delete"; + summary: string; + rationale: string; + current_content: string; + current_size: number; + session_ids: string[]; +}; + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +function zodMessage(error: z.ZodError): string { + const issue = error.issues[0]; + const path = issue.path.length > 0 ? issue.path.join(".") : "query"; + return `${path}: ${issue.message}`; +} + +function parseTimelineQuery(url: URL): { ok: true; value: TimelineQuery } | { ok: false; error: string } { + const raw: Record = {}; + const limit = url.searchParams.get("limit"); + const before = url.searchParams.get("before_version"); + if (limit !== null && limit.length > 0) raw.limit = limit; + if (before !== null && before.length > 0) raw.before_version = before; + const parsed = TimelineQuerySchema.safeParse(raw); + if (!parsed.success) return { ok: false, error: zodMessage(parsed.error) }; + return { ok: true, value: parsed.data }; +} + +function readReflectionStatsFromMetrics(metrics: EvolutionMetrics): ReflectionStats { + const block = (metrics as unknown as { reflection_stats?: Partial }).reflection_stats; + if (!block) return emptyReflectionStats(); + return { ...emptyReflectionStats(), ...block, files_touched: { ...(block.files_touched ?? {}) } }; +} + +function topFilesTouched(stats: ReflectionStats, limit: number): Array<{ file: string; count: number }> { + const entries = Object.entries(stats.files_touched); + entries.sort((a, b) => b[1] - a[1]); + return entries.slice(0, limit).map(([file, count]) => ({ file, count })); +} + +function buildOverview(deps: EvolutionApiDeps): OverviewResponse { + const config = deps.engine.getEvolutionConfig(); + const version = readVersion(config); + const metrics = deps.engine.getMetrics(); + const stats = readReflectionStatsFromMetrics(metrics); + + let poisonCount = 0; + if (deps.queue) { + try { + poisonCount = deps.queue.listPoisonPile().length; + } catch { + poisonCount = 0; + } + } + + return { + current: { version: version.version, timestamp: version.timestamp, parent: version.parent }, + metrics: { + session_count: metrics.session_count, + evolution_count: metrics.evolution_count, + success_rate_7d: metrics.success_rate_7d, + last_session_at: metrics.last_session_at, + last_evolution_at: metrics.last_evolution_at, + reflection_stats: { + drains: stats.drains, + cost_usd: stats.total_cost_usd, + tiers: { + haiku: stats.stage_haiku_runs, + sonnet: stats.stage_sonnet_runs, + opus: stats.stage_opus_runs, + }, + status: { + ok: stats.status_ok, + skip: stats.status_skip, + escalate_cap: stats.status_escalate_cap, + }, + invariant_fails: stats.invariant_failed_hard, + sigkills: stats.sigkill_before_write + stats.sigkill_mid_write, + files_touched: topFilesTouched(stats, TOP_FILES_LIMIT), + }, + }, + poison_count: poisonCount, + }; +} + +// Read the evolution log in newest-first order. The underlying store holds +// append-only rows in chronological order; the engine helper returns the last +// N entries oldest-first. We reverse for the UI. We read `TIMELINE_SCAN_CAP` +// entries as the outer window so `before_version` pagination works without a +// full disk walk. +function readTimelineWindow(engine: EvolutionEngine): EvolutionLogEntry[] { + const log = engine.getEvolutionLog(TIMELINE_SCAN_CAP); + const copy = log.slice(); + copy.reverse(); + return copy; +} + +function buildTimeline( + engine: EvolutionEngine, + query: TimelineQuery, +): { entries: EvolutionLogEntry[]; has_more: boolean } { + const limit = query.limit ?? TIMELINE_DEFAULT_LIMIT; + const window = readTimelineWindow(engine); + const filtered = query.before_version ? window.filter((e) => e.version < (query.before_version as number)) : window; + const page = filtered.slice(0, limit); + const has_more = filtered.length > page.length; + return { entries: page, has_more }; +} + +function overviewHandler(deps: EvolutionApiDeps): Response { + return json(buildOverview(deps)); +} + +function timelineHandler(deps: EvolutionApiDeps, query: TimelineQuery): Response { + return json(buildTimeline(deps.engine, query)); +} + +function readFilePreview(configDir: string, relPath: string): { content: string; size: number } { + // Evolution log rows carry agent-written "file" paths. Resolve the candidate + // absolute path and confirm it lands inside configDir before reading. + // Without this guard, a log entry like "../../etc/passwd" would let the + // dashboard disclose arbitrary host files up to FILE_PREVIEW_BYTE_CAP. + const base = resolve(configDir); + const absolute = resolve(base, relPath); + const rel = relative(base, absolute); + if (rel.startsWith("..") || rel === "" || resolve(base, rel) !== absolute) { + return { content: "", size: 0 }; + } + if (!existsSync(absolute)) return { content: "", size: 0 }; + let size = 0; + try { + size = statSync(absolute).size; + } catch { + size = 0; + } + try { + const raw = readFileSync(absolute); + const cap = FILE_PREVIEW_BYTE_CAP; + const sliced = raw.length <= cap ? raw : raw.subarray(0, cap); + return { content: sliced.toString("utf-8"), size }; + } catch { + return { content: "", size }; + } +} + +function versionHandler(deps: EvolutionApiDeps, versionNumber: number): Response { + const config = deps.engine.getEvolutionConfig(); + const current = readVersion(config); + const allLog = deps.engine.getEvolutionLog(TIMELINE_SCAN_CAP); + const match = allLog.find((e) => e.version === versionNumber) ?? null; + + if (!match && versionNumber !== current.version) { + return json({ error: "Version not found" }, { status: 404 }); + } + + let versionRecord: EvolutionVersion; + if (versionNumber === current.version) { + versionRecord = current; + } else if (match) { + versionRecord = { + version: match.version, + parent: match.version > 0 ? match.version - 1 : null, + timestamp: match.timestamp, + changes: match.details, + metrics_at_change: { session_count: 0, success_rate_7d: 0 }, + }; + } else { + return json({ error: "Version not found" }, { status: 404 }); + } + + const diffSource = match ? match.details : versionRecord.changes; + const diff: DiffEntry[] = diffSource.map((change) => { + const preview = + change.type === "delete" ? { content: "", size: 0 } : readFilePreview(config.paths.config_dir, change.file); + return { + file: change.file, + type: change.type, + summary: change.summary, + rationale: change.rationale, + current_content: preview.content, + current_size: preview.size, + session_ids: change.session_ids, + }; + }); + + return json({ version: versionRecord, diff, has_snapshot: false }); +} + +export async function handleEvolutionApi(req: Request, url: URL, deps: EvolutionApiDeps): Promise { + const pathname = url.pathname; + + if (pathname === "/ui/api/evolution") { + if (req.method !== "GET") return json({ error: "Method not allowed" }, { status: 405 }); + return overviewHandler(deps); + } + + if (pathname === "/ui/api/evolution/timeline") { + if (req.method !== "GET") return json({ error: "Method not allowed" }, { status: 405 }); + const parsed = parseTimelineQuery(url); + if (!parsed.ok) return json({ error: parsed.error }, { status: 422 }); + return timelineHandler(deps, parsed.value); + } + + const versionMatch = pathname.match(/^\/ui\/api\/evolution\/version\/([^/]+)$/); + if (versionMatch) { + if (req.method !== "GET") return json({ error: "Method not allowed" }, { status: 405 }); + const raw = versionMatch[1]; + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== raw) { + return json({ error: "Version must be a non-negative integer" }, { status: 400 }); + } + return versionHandler(deps, parsed); + } + + if (pathname.startsWith("/ui/api/evolution/")) { + return json({ error: "Not found" }, { status: 404 }); + } + + return null; +} diff --git a/src/ui/api/memory.ts b/src/ui/api/memory.ts new file mode 100644 index 0000000..ad3599b --- /dev/null +++ b/src/ui/api/memory.ts @@ -0,0 +1,219 @@ +// UI API routes for the Memory explorer dashboard tab. +// +// All routes live under /ui/api/memory and are cookie-auth gated by the +// dispatcher in src/ui/serve.ts. +// +// GET /ui/api/memory/health +// GET /ui/api/memory/:type?q=&limit=&offset= +// GET /ui/api/memory/:type/:id +// DELETE /ui/api/memory/:type/:id +// +// When q is present the list endpoint runs a hybrid recall via MemorySystem. +// When q is absent it falls back to Qdrant scroll ordered by recency. Detail +// and delete route through MemorySystem helpers that wrap the per-store +// scroll/get/delete primitives. Memory is the only tab with a write action +// (DELETE), gated through an operator-facing confirmation modal on the +// frontend. + +import { z } from "zod"; +import type { MemorySystem } from "../../memory/system.ts"; +import type { Episode, Procedure, SemanticFact } from "../../memory/types.ts"; + +export type MemoryApiDeps = { + memory: MemorySystem; +}; + +type MemoryType = "episodes" | "facts" | "procedures"; +type MemoryItem = Episode | SemanticFact | Procedure; + +// Scroll mode (empty query) orders by a payload date field, which disables +// Qdrant's cursor pagination: next_page_offset is null on every call. Load +// More therefore never renders in scroll mode and the operator sees at most +// LIST_DEFAULT_LIMIT items until they search. Default to the hard cap so the +// browse view shows the freshest 100. Cursor-style pagination across +// order_by (filter by { key: order_field, range: { lt: lastSeenValue } } on +// each subsequent call) is a documented follow-up. +const LIST_DEFAULT_LIMIT = 100; +const LIST_MAX_LIMIT = 100; +const Q_MAX = 200; +const ID_MAX = 200; + +const TypeSchema = z.enum(["episodes", "facts", "procedures"]); + +const ListQuerySchema = z.object({ + q: z.string().max(Q_MAX).optional(), + limit: z.coerce.number().int().min(1).max(LIST_MAX_LIMIT).optional(), + offset: z.string().min(1).max(ID_MAX).optional(), +}); + +function hasControlCharacter(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +const IdSchema = z + .string() + .min(1) + .max(ID_MAX) + .refine((s) => !hasControlCharacter(s), "id contains control characters"); + +type ListQuery = z.infer; + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...((init?.headers as Record) ?? {}), + }, + }); +} + +function zodMessage(error: z.ZodError): string { + const issue = error.issues[0]; + const path = issue.path.length > 0 ? issue.path.join(".") : "input"; + return `${path}: ${issue.message}`; +} + +function parseType(raw: string): MemoryType | null { + const parsed = TypeSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + +function parseListQuery(url: URL): { ok: true; value: ListQuery } | { ok: false; error: string } { + const raw: Record = {}; + const q = url.searchParams.get("q"); + const limit = url.searchParams.get("limit"); + const offset = url.searchParams.get("offset"); + if (q !== null && q.length > 0) raw.q = q; + if (limit !== null && limit.length > 0) raw.limit = limit; + if (offset !== null && offset.length > 0) raw.offset = offset; + const parsed = ListQuerySchema.safeParse(raw); + if (!parsed.success) return { ok: false, error: zodMessage(parsed.error) }; + return { ok: true, value: parsed.data }; +} + +async function handleHealth(deps: MemoryApiDeps): Promise { + const health = await deps.memory.healthCheck(); + let episodes = 0; + let facts = 0; + let procedures = 0; + if (health.qdrant) { + const results = await Promise.allSettled([ + deps.memory.countEpisodes(), + deps.memory.countFacts(), + deps.memory.countProcedures(), + ]); + if (results[0].status === "fulfilled") episodes = results[0].value; + if (results[1].status === "fulfilled") facts = results[1].value; + if (results[2].status === "fulfilled") procedures = results[2].value; + } + return json({ + qdrant: health.qdrant, + ollama: health.ollama, + counts: { episodes, facts, procedures }, + }); +} + +async function runList( + deps: MemoryApiDeps, + type: MemoryType, + query: ListQuery, +): Promise<{ items: MemoryItem[]; nextOffset: string | number | null }> { + const limit = query.limit ?? LIST_DEFAULT_LIMIT; + if (query.q && query.q.trim().length > 0) { + const qstr = query.q.trim(); + if (type === "episodes") { + const items = await deps.memory.recallEpisodes(qstr, { limit }); + return { items, nextOffset: null }; + } + if (type === "facts") { + const items = await deps.memory.recallFacts(qstr, { limit }); + return { items, nextOffset: null }; + } + const procedure = await deps.memory.findProcedure(qstr); + return { items: procedure ? [procedure] : [], nextOffset: null }; + } + const opts = query.offset ? { limit, offset: query.offset } : { limit }; + if (type === "episodes") return deps.memory.scrollEpisodes(opts); + if (type === "facts") return deps.memory.scrollFacts(opts); + return deps.memory.scrollProcedures(opts); +} + +async function handleList(deps: MemoryApiDeps, type: MemoryType, url: URL): Promise { + const parsed = parseListQuery(url); + if (!parsed.ok) return json({ error: parsed.error }, { status: 422 }); + const { items, nextOffset } = await runList(deps, type, parsed.value); + return json({ items, nextOffset }); +} + +async function getItemById(deps: MemoryApiDeps, type: MemoryType, id: string): Promise { + if (type === "episodes") return deps.memory.getEpisodeById(id); + if (type === "facts") return deps.memory.getFactById(id); + return deps.memory.getProcedureById(id); +} + +async function deleteItemById(deps: MemoryApiDeps, type: MemoryType, id: string): Promise { + if (type === "episodes") return deps.memory.deleteEpisode(id); + if (type === "facts") return deps.memory.deleteFact(id); + return deps.memory.deleteProcedure(id); +} + +async function handleDetail(deps: MemoryApiDeps, type: MemoryType, rawId: string): Promise { + const idParsed = IdSchema.safeParse(rawId); + if (!idParsed.success) return json({ error: zodMessage(idParsed.error) }, { status: 400 }); + const item = await getItemById(deps, type, idParsed.data); + if (!item) return json({ error: "Memory not found" }, { status: 404 }); + return json({ item }); +} + +async function handleDelete(deps: MemoryApiDeps, type: MemoryType, rawId: string): Promise { + const idParsed = IdSchema.safeParse(rawId); + if (!idParsed.success) return json({ error: zodMessage(idParsed.error) }, { status: 400 }); + const existing = await getItemById(deps, type, idParsed.data); + if (!existing) return json({ error: "Memory not found" }, { status: 404 }); + await deleteItemById(deps, type, idParsed.data); + return json({ deleted: true, id: idParsed.data }); +} + +export async function handleMemoryApi(req: Request, url: URL, deps: MemoryApiDeps): Promise { + const pathname = url.pathname; + + if (pathname === "/ui/api/memory/health") { + if (req.method !== "GET") return json({ error: "Method not allowed" }, { status: 405 }); + return handleHealth(deps); + } + + const detailMatch = pathname.match(/^\/ui\/api\/memory\/(episodes|facts|procedures)\/(.+)$/); + if (detailMatch) { + const type = parseType(detailMatch[1]); + if (!type) return json({ error: "Unknown memory type" }, { status: 404 }); + let rawId: string; + try { + rawId = decodeURIComponent(detailMatch[2]); + } catch { + return json({ error: "Invalid URL-encoded id" }, { status: 400 }); + } + if (req.method === "GET") return handleDetail(deps, type, rawId); + if (req.method === "DELETE") return handleDelete(deps, type, rawId); + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const listMatch = pathname.match(/^\/ui\/api\/memory\/(episodes|facts|procedures)$/); + if (listMatch) { + const type = parseType(listMatch[1]); + if (!type) return json({ error: "Unknown memory type" }, { status: 404 }); + if (req.method !== "GET") return json({ error: "Method not allowed" }, { status: 405 }); + return handleList(deps, type, url); + } + + if (pathname.startsWith("/ui/api/memory/")) { + return json({ error: "Unknown memory type" }, { status: 404 }); + } + + return null; +} diff --git a/src/ui/serve.ts b/src/ui/serve.ts index a2f0e06..d6afa19 100644 --- a/src/ui/serve.ts +++ b/src/ui/serve.ts @@ -6,13 +6,18 @@ import { loginPageHtml } from "./login-page.ts"; import { consumeMagicLink, createSession, isValidSession } from "./session.ts"; import type { AgentRuntime } from "../agent/runtime.ts"; +import type { EvolutionEngine } from "../evolution/engine.ts"; +import type { EvolutionQueue } from "../evolution/queue.ts"; +import type { MemorySystem } from "../memory/system.ts"; import type { ParseResult } from "../scheduler/parse-with-sonnet.ts"; import type { Scheduler } from "../scheduler/service.ts"; import { secretsExpiredHtml, secretsFormHtml } from "../secrets/form-page.ts"; import { getSecretRequest, saveSecrets, validateMagicToken } from "../secrets/store.ts"; import { handleCostApi } from "./api/cost.ts"; +import { handleEvolutionApi } from "./api/evolution.ts"; import { handleHooksApi } from "./api/hooks.ts"; import { handleMemoryFilesApi } from "./api/memory-files.ts"; +import { handleMemoryApi } from "./api/memory.ts"; import { type PluginsApiDeps, handlePluginsApi } from "./api/plugins.ts"; import { handleSchedulerApi } from "./api/scheduler.ts"; import { handleSessionsApi } from "./api/sessions.ts"; @@ -31,6 +36,9 @@ let schedulerInstance: Scheduler | null = null; let schedulerRuntime: AgentRuntime | null = null; let schedulerParserOverride: ((description: string) => Promise) | null = null; let pluginsApiOverrides: Pick = {}; +let evolutionEngine: EvolutionEngine | null = null; +let evolutionQueue: EvolutionQueue | null = null; +let memorySystem: MemorySystem | null = null; type SecretSavedCallback = (requestId: string, secretNames: string[]) => Promise; let onSecretSaved: SecretSavedCallback | null = null; @@ -58,6 +66,36 @@ export function clearSchedulerInstanceForTests(): void { schedulerParserOverride = null; } +export function setEvolutionEngine(engine: EvolutionEngine): void { + evolutionEngine = engine; +} + +export function setEvolutionQueue(queue: EvolutionQueue): void { + evolutionQueue = queue; +} + +export function clearEvolutionForTests(): void { + evolutionEngine = null; + evolutionQueue = null; +} + +export function setEvolutionEngineForTests(engine: EvolutionEngine, queue?: EvolutionQueue): void { + evolutionEngine = engine; + evolutionQueue = queue ?? null; +} + +export function setMemorySystem(memory: MemorySystem): void { + memorySystem = memory; +} + +export function clearMemorySystemForTests(): void { + memorySystem = null; +} + +export function setMemorySystemForTests(memory: MemorySystem): void { + memorySystem = memory; +} + // Test-only seam. Production wiring leaves this null so the handler falls // back to the default parseJobDescription, which routes through the Agent // SDK subprocess (runJudgeQuery) so subscription auth or API key auth both @@ -270,6 +308,23 @@ export async function handleUiRequest(req: Request): Promise { }); if (apiResponse) return apiResponse; } + if (url.pathname.startsWith("/ui/api/evolution")) { + if (!evolutionEngine) { + return Response.json({ error: "Evolution engine not initialized" }, { status: 503 }); + } + const apiResponse = await handleEvolutionApi(req, url, { + engine: evolutionEngine, + queue: evolutionQueue, + }); + if (apiResponse) return apiResponse; + } + if (url.pathname.startsWith("/ui/api/memory/")) { + if (!memorySystem) { + return Response.json({ error: "Memory system not initialized" }, { status: 503 }); + } + const apiResponse = await handleMemoryApi(req, url, { memory: memorySystem }); + if (apiResponse) return apiResponse; + } // Static files const filePath = isPathSafe(url.pathname);