@warlock.js/ai-panoptic 4.8.1 → 4.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/cjs/index.cjs +312 -14
- package/cjs/index.cjs.map +1 -1
- package/esm/config/apply-panoptic-config.d.mts.map +1 -1
- package/esm/config/apply-panoptic-config.mjs +3 -1
- package/esm/config/apply-panoptic-config.mjs.map +1 -1
- package/esm/config/panoptic-config.type.d.mts +11 -0
- package/esm/config/panoptic-config.type.d.mts.map +1 -1
- package/esm/dashboard/dashboard.mjs +2 -1
- package/esm/dashboard/dashboard.mjs.map +1 -1
- package/esm/dashboard/dashboard.type.d.mts +15 -2
- package/esm/dashboard/dashboard.type.d.mts.map +1 -1
- package/esm/dashboard/index.mjs +7 -0
- package/esm/dashboard/serve.mjs +123 -8
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs +133 -4
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/esm/evaluate/evaluate-system-prompt.d.mts +19 -0
- package/esm/evaluate/evaluate-system-prompt.d.mts.map +1 -0
- package/esm/evaluate/evaluate-system-prompt.mjs +22 -0
- package/esm/evaluate/evaluate-system-prompt.mjs.map +1 -0
- package/esm/evaluate/evaluate.type.d.mts +47 -0
- package/esm/evaluate/evaluate.type.d.mts.map +1 -0
- package/esm/evaluate/extract-last-system-prompt.d.mts +19 -0
- package/esm/evaluate/extract-last-system-prompt.d.mts.map +1 -0
- package/esm/evaluate/extract-last-system-prompt.mjs +24 -0
- package/esm/evaluate/extract-last-system-prompt.mjs.map +1 -0
- package/esm/evaluate/find-span-by-id.d.mts +8 -0
- package/esm/evaluate/find-span-by-id.d.mts.map +1 -0
- package/esm/evaluate/find-span-by-id.mjs +13 -0
- package/esm/evaluate/find-span-by-id.mjs.map +1 -0
- package/esm/evaluate/index.d.mts +4 -0
- package/esm/evaluate/index.mjs +5 -0
- package/esm/index.d.mts +5 -1
- package/esm/index.mjs +6 -1
- package/llms-full.txt +95 -3
- package/llms.txt +1 -0
- package/package.json +4 -3
- package/skills/README.md +4 -0
- package/skills/evaluate-system-prompt/SKILL.md +86 -0
- package/skills/use-local-dashboard/SKILL.md +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `@warlock.js/ai-panoptic` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 4.8.2 - 2026-07-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`DashboardOptions.evaluate`** — an "Evaluate system prompt" drawer action that grades a trace's last captured system prompt via an LLM judge, editable per-run instructions included; the dashboard's first and only write-capable route (`POST .../spans/:spanId/evaluate`), off unless configured and gated by the same `authToken` / `allowedHosts` checks as every other route
|
|
12
|
+
- `evaluateSystemPrompt` / `extractLastSystemPrompt` / `findSpanById` — the building blocks behind the drawer action, exported for scripting a grade outside the UI
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- The dashboard's client-side poll (`GET {basePath}api/aggregate` / `api/traces`) now carries the page's `?token=` as an `Authorization: Bearer` header on every request — previously, once `authToken` was configured the initial page load succeeded (the browser's navigation request carries the query string) but every subsequent 2s poll had no auth attached and 401'd forever, leaving the dashboard stuck on an empty/error state despite loading successfully
|
|
17
|
+
- `PanopticConfig.cache` failures (hydrate-on-startup or write-through — bad URL, unreachable Redis, auth failure) are no longer swallowed into total silence: a new `PanopticConfig.onError` hook fires on every failure, defaulting to `log.error("ai-panoptic", "cacheStore", error)` when not supplied, so a misconfigured cache driver now surfaces in logs instead of leaving the dashboard permanently empty with zero diagnostic
|
|
18
|
+
|
|
7
19
|
## 4.5.0 - 2026-07-01
|
|
8
20
|
|
|
9
21
|
### Added
|
package/cjs/index.cjs
CHANGED
|
@@ -3,6 +3,7 @@ let _warlock_js_ai = require("@warlock.js/ai");
|
|
|
3
3
|
let node_fs_promises = require("node:fs/promises");
|
|
4
4
|
let node_path = require("node:path");
|
|
5
5
|
let node_http = require("node:http");
|
|
6
|
+
let _warlock_js_logger = require("@warlock.js/logger");
|
|
6
7
|
|
|
7
8
|
//#region ../@warlock.js/ai-panoptic/src/exporters/utils/walk-spans.ts
|
|
8
9
|
/**
|
|
@@ -1917,6 +1918,57 @@ function panoptic(options = {}) {
|
|
|
1917
1918
|
return new PanopticSubscriber(options);
|
|
1918
1919
|
}
|
|
1919
1920
|
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region ../@warlock.js/ai-panoptic/src/evaluate/evaluate-system-prompt.ts
|
|
1923
|
+
/**
|
|
1924
|
+
* Grade `systemPrompt` with the configured judge model, reusing
|
|
1925
|
+
* `ai.prompts().validate()`'s own `judgePromptBody` — never a second
|
|
1926
|
+
* judging implementation. `instructionsOverride` (the dashboard's
|
|
1927
|
+
* per-run textarea) wins over `config.instructions`; with neither, the
|
|
1928
|
+
* judge falls back to `judgePromptBody`'s built-in prompt-quality rubric.
|
|
1929
|
+
*
|
|
1930
|
+
* The judge itself never throws (`judgePromptBody` degrades to an
|
|
1931
|
+
* issues-only outcome on failure) — the only thing that CAN throw here is
|
|
1932
|
+
* resolving `config.model` (a factory constructing an SDK client), which
|
|
1933
|
+
* the caller (the dashboard route) is expected to catch.
|
|
1934
|
+
*/
|
|
1935
|
+
async function evaluateSystemPrompt(systemPrompt, config, instructionsOverride) {
|
|
1936
|
+
return (0, _warlock_js_ai.judgePromptBody)(systemPrompt, typeof config.model === "function" ? await config.model() : config.model, instructionsOverride?.trim() || config.instructions);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
//#endregion
|
|
1940
|
+
//#region ../@warlock.js/ai-panoptic/src/evaluate/extract-last-system-prompt.ts
|
|
1941
|
+
/**
|
|
1942
|
+
* Pull the LAST `{role: "system"}` message off a span's captured `input`.
|
|
1943
|
+
*
|
|
1944
|
+
* Only present under `captureContent` (off by default), and only shaped
|
|
1945
|
+
* this way for non-tool spans — either the `[system, user]` first-trip pair
|
|
1946
|
+
* or, under `fullHistory`, the full `CapturedMessage[]` conversation (see
|
|
1947
|
+
* `collector/report-to-span.ts`). "Last" (not "only") matters for
|
|
1948
|
+
* `fullHistory`: a long-running agent can carry more than one system-role
|
|
1949
|
+
* turn, and the most recent one is the one actually in effect. Returns
|
|
1950
|
+
* `undefined` for a tool span, an agent with no system prompt, or when
|
|
1951
|
+
* content capture is off — the caller treats that as "nothing to evaluate."
|
|
1952
|
+
*/
|
|
1953
|
+
function extractLastSystemPrompt(span) {
|
|
1954
|
+
if (!Array.isArray(span.input)) return;
|
|
1955
|
+
for (let index = span.input.length - 1; index >= 0; index -= 1) {
|
|
1956
|
+
const entry = span.input[index];
|
|
1957
|
+
if (entry && typeof entry === "object" && entry.role === "system") return typeof entry.content === "string" ? entry.content : void 0;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
//#endregion
|
|
1962
|
+
//#region ../@warlock.js/ai-panoptic/src/evaluate/find-span-by-id.ts
|
|
1963
|
+
/** Depth-first search for the span with `spanId` inside a trace's span tree. */
|
|
1964
|
+
function findSpanById(root, spanId) {
|
|
1965
|
+
if (root.spanId === spanId) return root;
|
|
1966
|
+
for (const child of root.children) {
|
|
1967
|
+
const found = findSpanById(child, spanId);
|
|
1968
|
+
if (found !== void 0) return found;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1920
1972
|
//#endregion
|
|
1921
1973
|
//#region ../@warlock.js/ai-panoptic/src/dashboard/parse-query.ts
|
|
1922
1974
|
/**
|
|
@@ -2021,7 +2073,7 @@ const WARLOCK_LOGO_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEA
|
|
|
2021
2073
|
* @param basePath Normalized mount path ending in `/` (e.g. `"/"`).
|
|
2022
2074
|
* @param title Header title shown in the page.
|
|
2023
2075
|
*/
|
|
2024
|
-
function dashboardHtml(basePath, title) {
|
|
2076
|
+
function dashboardHtml(basePath, title, evaluateEnabled = false, evaluateDefaultInstructions = "") {
|
|
2025
2077
|
const apiBase = `${basePath}api`;
|
|
2026
2078
|
const safeTitle = escapeHtml(title);
|
|
2027
2079
|
return `<!doctype html>
|
|
@@ -2202,6 +2254,18 @@ function dashboardHtml(basePath, title) {
|
|
|
2202
2254
|
.legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }
|
|
2203
2255
|
.legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }
|
|
2204
2256
|
.gantt { padding: 8px 10px; }
|
|
2257
|
+
|
|
2258
|
+
/* Evaluate — the drawer's one write action (config-gated). */
|
|
2259
|
+
.eval-btn { background: transparent; border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; margin: 10px 0 0; }
|
|
2260
|
+
.eval-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border2); }
|
|
2261
|
+
.eval-btn:disabled { opacity: .6; cursor: default; }
|
|
2262
|
+
.eval-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); }
|
|
2263
|
+
.eval-panel textarea { width: 100%; min-height: 64px; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; resize: vertical; }
|
|
2264
|
+
.eval-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
2265
|
+
.eval-error { color: var(--fail); font-size: 12px; }
|
|
2266
|
+
.eval-result { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; }
|
|
2267
|
+
.eval-score { font-weight: 600; }
|
|
2268
|
+
.eval-issues { margin: 6px 0 0; padding-left: 18px; }
|
|
2205
2269
|
.grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
|
|
2206
2270
|
.grow:hover { background: var(--surface); }
|
|
2207
2271
|
.grow.selected { background: var(--sel-bg); }
|
|
@@ -2266,7 +2330,21 @@ function dashboardHtml(basePath, title) {
|
|
|
2266
2330
|
<script>
|
|
2267
2331
|
(function () {
|
|
2268
2332
|
var API = ${JSON.stringify(apiBase)};
|
|
2333
|
+
var EVALUATE_ENABLED = ${JSON.stringify(evaluateEnabled)};
|
|
2269
2334
|
var BT = String.fromCharCode(96);
|
|
2335
|
+
var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};
|
|
2336
|
+
|
|
2337
|
+
// Carry the ?token= the page itself was loaded with onto every
|
|
2338
|
+
// subsequent poll — otherwise the API calls below inherit no auth and
|
|
2339
|
+
// 401 forever once authToken is configured (the initial page load is
|
|
2340
|
+
// the only request the URL's query string naturally reaches).
|
|
2341
|
+
var TOKEN = new URLSearchParams(window.location.search).get("token");
|
|
2342
|
+
function fetchAuthed(url, options) {
|
|
2343
|
+
var opts = options || {};
|
|
2344
|
+
var headers = opts.headers || {};
|
|
2345
|
+
if (TOKEN) headers = Object.assign({}, headers, { Authorization: "Bearer " + TOKEN });
|
|
2346
|
+
return fetch(url, Object.assign({}, opts, { headers: headers }));
|
|
2347
|
+
}
|
|
2270
2348
|
|
|
2271
2349
|
var state = {
|
|
2272
2350
|
traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,
|
|
@@ -2278,7 +2356,8 @@ function dashboardHtml(basePath, title) {
|
|
|
2278
2356
|
showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)
|
|
2279
2357
|
collapsedGroups: {}, // collapsed group headers (session, prompt, or type)
|
|
2280
2358
|
view: "tree", // drawer left pane: "tree" | "timeline"
|
|
2281
|
-
hashApplied: false
|
|
2359
|
+
hashApplied: false, // guards one-time deep-link open on load
|
|
2360
|
+
evaluate: {} // per-span evaluate UI state, keyed by spanId
|
|
2282
2361
|
};
|
|
2283
2362
|
|
|
2284
2363
|
var STATUS_FILTERS = ["completed", "failed", "cancelled"];
|
|
@@ -2987,6 +3066,80 @@ function dashboardHtml(basePath, title) {
|
|
|
2987
3066
|
return '<div class="meta-sec"><div class="meta-title">metadata</div><div class="kv">' + kv + "</div></div>";
|
|
2988
3067
|
}
|
|
2989
3068
|
|
|
3069
|
+
// --- Evaluate: grade a span's last captured system prompt -------------
|
|
3070
|
+
// Config-gated (EVALUATE_ENABLED) — the drawer's only write action, POSTing
|
|
3071
|
+
// to a route that itself only exists when the server was configured with
|
|
3072
|
+
// evaluate. UI state lives in state.evaluate, keyed by spanId, so it
|
|
3073
|
+
// survives a re-render (e.g. switching to a sibling span and back).
|
|
3074
|
+
function extractLastSystemPrompt(span) {
|
|
3075
|
+
if (!Array.isArray(span.input)) return null;
|
|
3076
|
+
for (var i = span.input.length - 1; i >= 0; i--) {
|
|
3077
|
+
var m = span.input[i];
|
|
3078
|
+
if (m && typeof m === "object" && m.role === "system" && typeof m.content === "string") return m.content;
|
|
3079
|
+
}
|
|
3080
|
+
return null;
|
|
3081
|
+
}
|
|
3082
|
+
function evalState(spanId) {
|
|
3083
|
+
return state.evaluate[spanId] || (state.evaluate[spanId] = {
|
|
3084
|
+
open: false, instructions: EVALUATE_DEFAULT_INSTRUCTIONS, status: "idle", result: null, error: null
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
function evalResultHtml(result) {
|
|
3088
|
+
var score = typeof result.score === "number" ? Math.round(result.score * 100) + "%" : "n/a";
|
|
3089
|
+
var issues = (result.issues || []).map(function (i) { return "<li>" + esc(i) + "</li>"; }).join("");
|
|
3090
|
+
return '<div class="eval-result"><span class="eval-score">Score: ' + score + "</span>"
|
|
3091
|
+
+ (issues ? '<ul class="eval-issues">' + issues + "</ul>" : "") + "</div>";
|
|
3092
|
+
}
|
|
3093
|
+
function evalSectionHtml(span) {
|
|
3094
|
+
if (!EVALUATE_ENABLED) return "";
|
|
3095
|
+
var sysPrompt = extractLastSystemPrompt(span);
|
|
3096
|
+
if (!sysPrompt) return "";
|
|
3097
|
+
var st = evalState(span.spanId);
|
|
3098
|
+
var btn = '<button type="button" class="eval-btn" data-evaluate-toggle="' + esc(span.spanId) + '">'
|
|
3099
|
+
+ (st.open ? "Hide evaluate" : "Evaluate system prompt") + "</button>";
|
|
3100
|
+
if (!st.open) return btn;
|
|
3101
|
+
var running = st.status === "running";
|
|
3102
|
+
return btn + '<div class="eval-panel">'
|
|
3103
|
+
+ '<textarea id="eval-instructions" placeholder="Grading instructions (optional — falls back to the configured default)"' + (running ? " disabled" : "") + ">" + esc(st.instructions || "") + "</textarea>"
|
|
3104
|
+
+ '<div class="eval-actions">'
|
|
3105
|
+
+ '<button type="button" class="eval-btn" data-evaluate-run="' + esc(span.spanId) + '"' + (running ? " disabled" : "") + ">" + (running ? "Evaluating…" : "Run") + "</button>"
|
|
3106
|
+
+ (st.error ? '<span class="eval-error">' + esc(st.error) + "</span>" : "")
|
|
3107
|
+
+ "</div>"
|
|
3108
|
+
+ (st.result ? evalResultHtml(st.result) : "")
|
|
3109
|
+
+ "</div>";
|
|
3110
|
+
}
|
|
3111
|
+
function rerenderDetail() {
|
|
3112
|
+
var t = findTrace(state.selectedId);
|
|
3113
|
+
if (!t) return;
|
|
3114
|
+
var sel = findSpan(t.root, state.selectedSpanId) || t.root;
|
|
3115
|
+
document.getElementById("drawer-detail").innerHTML = renderDetail(sel, t);
|
|
3116
|
+
}
|
|
3117
|
+
function toggleEvalPanel(spanId) {
|
|
3118
|
+
evalState(spanId).open = !evalState(spanId).open;
|
|
3119
|
+
rerenderDetail();
|
|
3120
|
+
}
|
|
3121
|
+
function runEvaluate(traceId, spanId) {
|
|
3122
|
+
var st = evalState(spanId);
|
|
3123
|
+
var textarea = document.getElementById("eval-instructions");
|
|
3124
|
+
if (textarea) st.instructions = textarea.value;
|
|
3125
|
+
st.status = "running"; st.error = null;
|
|
3126
|
+
rerenderDetail();
|
|
3127
|
+
fetchAuthed(API + "/traces/" + encodeURIComponent(traceId) + "/spans/" + encodeURIComponent(spanId) + "/evaluate", {
|
|
3128
|
+
method: "POST",
|
|
3129
|
+
headers: { "content-type": "application/json" },
|
|
3130
|
+
body: JSON.stringify({ instructions: st.instructions })
|
|
3131
|
+
}).then(function (r) {
|
|
3132
|
+
return r.json().then(function (data) { return { ok: r.ok, data: data }; });
|
|
3133
|
+
}).then(function (res) {
|
|
3134
|
+
if (res.ok) { st.status = "done"; st.result = res.data; st.error = null; }
|
|
3135
|
+
else { st.status = "error"; st.error = (res.data && (res.data.message || res.data.error)) || "evaluate failed"; st.result = null; }
|
|
3136
|
+
rerenderDetail();
|
|
3137
|
+
}).catch(function () {
|
|
3138
|
+
st.status = "error"; st.error = "network error"; st.result = null;
|
|
3139
|
+
rerenderDetail();
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
2990
3143
|
function renderDetail(span, trace) {
|
|
2991
3144
|
var path = findPath(trace.root, span.spanId) || [span];
|
|
2992
3145
|
var crumb = path.map(function (p, i) { return (i ? '<span class="sep"> › </span>' : "") + "<span>" + esc(p.name) + "</span>"; }).join("");
|
|
@@ -3009,6 +3162,7 @@ function dashboardHtml(basePath, title) {
|
|
|
3009
3162
|
+ '<span class="dim">· ' + dur(span.duration) + "</span></div>"
|
|
3010
3163
|
+ metaLine
|
|
3011
3164
|
+ io
|
|
3165
|
+
+ evalSectionHtml(span, trace)
|
|
3012
3166
|
+ renderMeta(span, trace);
|
|
3013
3167
|
}
|
|
3014
3168
|
|
|
@@ -3171,9 +3325,20 @@ function dashboardHtml(basePath, title) {
|
|
|
3171
3325
|
if (vb) { setView(vb.getAttribute("data-view")); return; }
|
|
3172
3326
|
var tog = e.target.closest ? e.target.closest("[data-toggle]") : null;
|
|
3173
3327
|
if (tog) { toggleSpan(tog.getAttribute("data-toggle")); return; }
|
|
3328
|
+
var evalToggle = e.target.closest ? e.target.closest("[data-evaluate-toggle]") : null;
|
|
3329
|
+
if (evalToggle) { toggleEvalPanel(evalToggle.getAttribute("data-evaluate-toggle")); return; }
|
|
3330
|
+
var evalRun = e.target.closest ? e.target.closest("[data-evaluate-run]") : null;
|
|
3331
|
+
if (evalRun && state.selectedId) { runEvaluate(state.selectedId, evalRun.getAttribute("data-evaluate-run")); return; }
|
|
3174
3332
|
var node = e.target.closest ? e.target.closest(".tnode[data-span], .grow[data-span]") : null;
|
|
3175
3333
|
if (node) selectSpan(node.getAttribute("data-span"));
|
|
3176
3334
|
});
|
|
3335
|
+
// Track the instructions textarea live (no re-render on keystroke, so
|
|
3336
|
+
// typing never loses focus/cursor position).
|
|
3337
|
+
document.getElementById("drawer-body").addEventListener("input", function (e) {
|
|
3338
|
+
if (e.target && e.target.id === "eval-instructions" && state.selectedSpanId) {
|
|
3339
|
+
evalState(state.selectedSpanId).instructions = e.target.value;
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3177
3342
|
document.getElementById("backdrop").addEventListener("click", closeDrawer);
|
|
3178
3343
|
document.addEventListener("keydown", function (e) { if (e.key === "Escape" || e.keyCode === 27) closeDrawer(); });
|
|
3179
3344
|
|
|
@@ -3269,8 +3434,8 @@ function dashboardHtml(basePath, title) {
|
|
|
3269
3434
|
|
|
3270
3435
|
function poll() {
|
|
3271
3436
|
Promise.all([
|
|
3272
|
-
|
|
3273
|
-
|
|
3437
|
+
fetchAuthed(API + "/aggregate").then(function (r) { return r.json(); }),
|
|
3438
|
+
fetchAuthed(API + "/traces").then(function (r) { return r.json(); })
|
|
3274
3439
|
]).then(function (res) {
|
|
3275
3440
|
renderStats(res[0]);
|
|
3276
3441
|
state.traces = res[1] || [];
|
|
@@ -3305,9 +3470,27 @@ function dashboardHtml(basePath, title) {
|
|
|
3305
3470
|
function escapeHtml(value) {
|
|
3306
3471
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3307
3472
|
}
|
|
3473
|
+
/**
|
|
3474
|
+
* Encode free-form text (e.g. `evaluate.instructions`, which an operator
|
|
3475
|
+
* could type anything into) as a JS EXPRESSION that reconstructs it at
|
|
3476
|
+
* runtime, WITHOUT ever emitting a literal backtick into the served page —
|
|
3477
|
+
* the client script is itself built from a TS template literal, so a raw
|
|
3478
|
+
* backtick in the output would be a real syntax hazard (see the `BT =
|
|
3479
|
+
* String.fromCharCode(96)` construction already in the client script for
|
|
3480
|
+
* the same reason). `JSON.stringify` alone doesn't escape backticks (they
|
|
3481
|
+
* aren't JSON-significant), so a value containing one is split around it
|
|
3482
|
+
* and rejoined with the client's own `BT` constant. No backticks in the
|
|
3483
|
+
* input ⇒ a single plain `JSON.stringify(value)` — no unnecessary
|
|
3484
|
+
* concatenation in the common case.
|
|
3485
|
+
*/
|
|
3486
|
+
function encodeForInlineScript(value) {
|
|
3487
|
+
return value.split("`").map((part) => JSON.stringify(part)).join(" + BT + ");
|
|
3488
|
+
}
|
|
3308
3489
|
|
|
3309
3490
|
//#endregion
|
|
3310
3491
|
//#region ../@warlock.js/ai-panoptic/src/dashboard/serve.ts
|
|
3492
|
+
/** Hard cap on the evaluate route's request body — well beyond a real instructions string. */
|
|
3493
|
+
const MAX_EVALUATE_BODY_BYTES = 64 * 1024;
|
|
3311
3494
|
/**
|
|
3312
3495
|
* Security response headers added to every dashboard response (S4):
|
|
3313
3496
|
* block MIME-sniffing, framing, referrer leakage, and lock the page's
|
|
@@ -3337,15 +3520,22 @@ function isAuthorized(req, url, token) {
|
|
|
3337
3520
|
* trace store. Kept separate from the server lifecycle so it can be unit
|
|
3338
3521
|
* tested by feeding it a fake `req`/`res` without binding a port.
|
|
3339
3522
|
*
|
|
3340
|
-
* Routes (all under `config.basePath
|
|
3523
|
+
* Routes (all under `config.basePath`):
|
|
3341
3524
|
*
|
|
3342
3525
|
* - `GET api/traces` → `store.query(parseQuery(searchParams))`
|
|
3343
3526
|
* - `GET api/traces/:id` → `store.get(id)` or `404`
|
|
3344
3527
|
* - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`
|
|
3345
3528
|
* - `GET {basePath}` → the self-contained HTML page
|
|
3529
|
+
* - `POST api/traces/:traceId/spans/:spanId/evaluate` → ONLY when
|
|
3530
|
+
* `config.evaluate` is set; grades the span's last captured system
|
|
3531
|
+
* prompt and returns a {@link EvaluateVerdict}. The dashboard's one
|
|
3532
|
+
* write route — absent config, POST 405s like it would against any
|
|
3533
|
+
* other route (the path is never even pattern-matched).
|
|
3346
3534
|
*
|
|
3347
|
-
* Anything else → `404`.
|
|
3348
|
-
*
|
|
3535
|
+
* Anything else → `404`. A method the matched route doesn't accept → `405`.
|
|
3536
|
+
* Host allowlist + bearer-token auth (S4) are checked for EVERY request,
|
|
3537
|
+
* regardless of method, before any routing. The store shapes
|
|
3538
|
+
* ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so GET responses
|
|
3349
3539
|
* are a plain `JSON.stringify` with no serializer.
|
|
3350
3540
|
*
|
|
3351
3541
|
* @example
|
|
@@ -3356,10 +3546,6 @@ function createRequestHandler(store, config) {
|
|
|
3356
3546
|
const base = config.basePath;
|
|
3357
3547
|
const apiPrefix = `${base}api`;
|
|
3358
3548
|
return function handle(req, res) {
|
|
3359
|
-
if (req.method !== "GET") {
|
|
3360
|
-
sendJson(res, 405, { error: "method_not_allowed" });
|
|
3361
|
-
return;
|
|
3362
|
-
}
|
|
3363
3549
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3364
3550
|
const pathname = url.pathname;
|
|
3365
3551
|
const host = hostHeaderName(req.headers.host);
|
|
@@ -3371,6 +3557,17 @@ function createRequestHandler(store, config) {
|
|
|
3371
3557
|
sendJson(res, 401, { error: "unauthorized" });
|
|
3372
3558
|
return;
|
|
3373
3559
|
}
|
|
3560
|
+
if (req.method === "POST" && config.evaluate) {
|
|
3561
|
+
const match = matchEvaluatePath(pathname, apiPrefix);
|
|
3562
|
+
if (match) {
|
|
3563
|
+
handleEvaluate(store, config.evaluate, match.traceId, match.spanId, req, res);
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
if (req.method !== "GET") {
|
|
3568
|
+
sendJson(res, 405, { error: "method_not_allowed" });
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3374
3571
|
if (pathname === `${apiPrefix}/traces`) {
|
|
3375
3572
|
sendJson(res, 200, store.query(parseQuery(url.searchParams)));
|
|
3376
3573
|
return;
|
|
@@ -3397,7 +3594,7 @@ function createRequestHandler(store, config) {
|
|
|
3397
3594
|
"content-type": "text/html; charset=utf-8",
|
|
3398
3595
|
...SECURITY_HEADERS
|
|
3399
3596
|
});
|
|
3400
|
-
res.end(dashboardHtml(base, config.title));
|
|
3597
|
+
res.end(dashboardHtml(base, config.title, Boolean(config.evaluate), config.evaluate?.instructions ?? ""));
|
|
3401
3598
|
return;
|
|
3402
3599
|
}
|
|
3403
3600
|
sendJson(res, 404, { error: "not_found" });
|
|
@@ -3411,6 +3608,102 @@ function sendJson(res, status, body) {
|
|
|
3411
3608
|
});
|
|
3412
3609
|
res.end(JSON.stringify(body));
|
|
3413
3610
|
}
|
|
3611
|
+
/**
|
|
3612
|
+
* Match `{apiPrefix}/traces/:traceId/spans/:spanId/evaluate` — the one
|
|
3613
|
+
* write route. Returns `undefined` for anything else, including the plain
|
|
3614
|
+
* `{apiPrefix}/traces/:id` read route (no `/spans/.../evaluate` suffix),
|
|
3615
|
+
* so the two never collide.
|
|
3616
|
+
*/
|
|
3617
|
+
function matchEvaluatePath(pathname, apiPrefix) {
|
|
3618
|
+
const prefix = `${apiPrefix}/traces/`;
|
|
3619
|
+
if (!pathname.startsWith(prefix)) return;
|
|
3620
|
+
const match = /^([^/]+)\/spans\/([^/]+)\/evaluate$/.exec(pathname.slice(prefix.length));
|
|
3621
|
+
if (!match) return;
|
|
3622
|
+
return {
|
|
3623
|
+
traceId: decodeURIComponent(match[1]),
|
|
3624
|
+
spanId: decodeURIComponent(match[2])
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Collect and JSON-parse a request body, capped at
|
|
3629
|
+
* {@link MAX_EVALUATE_BODY_BYTES} so an oversized body can't hold the
|
|
3630
|
+
* connection open indefinitely. An empty body resolves to `undefined` —
|
|
3631
|
+
* the evaluate route treats that as "no per-run override".
|
|
3632
|
+
*/
|
|
3633
|
+
function readJsonBody(req) {
|
|
3634
|
+
return new Promise((resolve, reject) => {
|
|
3635
|
+
const chunks = [];
|
|
3636
|
+
let size = 0;
|
|
3637
|
+
req.on("data", (chunk) => {
|
|
3638
|
+
size += chunk.length;
|
|
3639
|
+
if (size > MAX_EVALUATE_BODY_BYTES) {
|
|
3640
|
+
req.destroy();
|
|
3641
|
+
reject(/* @__PURE__ */ new Error("payload_too_large"));
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
chunks.push(chunk);
|
|
3645
|
+
});
|
|
3646
|
+
req.on("end", () => {
|
|
3647
|
+
if (chunks.length === 0) {
|
|
3648
|
+
resolve(void 0);
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
try {
|
|
3652
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
3653
|
+
} catch {
|
|
3654
|
+
reject(/* @__PURE__ */ new Error("invalid_json"));
|
|
3655
|
+
}
|
|
3656
|
+
});
|
|
3657
|
+
req.on("error", reject);
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Handle `POST {apiPrefix}/traces/:traceId/spans/:spanId/evaluate`. Looks
|
|
3662
|
+
* up the trace + span, extracts its last captured system prompt, and
|
|
3663
|
+
* grades it via {@link evaluateSystemPrompt}. `judgePromptBody` itself
|
|
3664
|
+
* never throws (a broken judge degrades to an issues-only outcome) — the
|
|
3665
|
+
* try/catch here only guards `config.evaluate.model` resolution, the one
|
|
3666
|
+
* step that CAN throw (e.g. a factory constructing an SDK client).
|
|
3667
|
+
*/
|
|
3668
|
+
async function handleEvaluate(store, evaluate, traceId, spanId, req, res) {
|
|
3669
|
+
const trace = store.get(traceId);
|
|
3670
|
+
if (trace === void 0) {
|
|
3671
|
+
sendJson(res, 404, {
|
|
3672
|
+
error: "trace_not_found",
|
|
3673
|
+
traceId
|
|
3674
|
+
});
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
const span = findSpanById(trace.root, spanId);
|
|
3678
|
+
if (span === void 0) {
|
|
3679
|
+
sendJson(res, 404, {
|
|
3680
|
+
error: "span_not_found",
|
|
3681
|
+
spanId
|
|
3682
|
+
});
|
|
3683
|
+
return;
|
|
3684
|
+
}
|
|
3685
|
+
const systemPrompt = extractLastSystemPrompt(span);
|
|
3686
|
+
if (systemPrompt === void 0) {
|
|
3687
|
+
sendJson(res, 422, { error: "no_system_prompt" });
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
let body;
|
|
3691
|
+
try {
|
|
3692
|
+
body = await readJsonBody(req);
|
|
3693
|
+
} catch (error) {
|
|
3694
|
+
const message = error instanceof Error ? error.message : "invalid_json";
|
|
3695
|
+
sendJson(res, message === "payload_too_large" ? 413 : 400, { error: message });
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
try {
|
|
3699
|
+
sendJson(res, 200, await evaluateSystemPrompt(systemPrompt, evaluate, body?.instructions));
|
|
3700
|
+
} catch (error) {
|
|
3701
|
+
sendJson(res, 502, {
|
|
3702
|
+
error: "evaluate_failed",
|
|
3703
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3704
|
+
});
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3414
3707
|
|
|
3415
3708
|
//#endregion
|
|
3416
3709
|
//#region ../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts
|
|
@@ -3452,7 +3745,8 @@ function dashboard(store, options = {}) {
|
|
|
3452
3745
|
basePath,
|
|
3453
3746
|
title,
|
|
3454
3747
|
authToken: options.authToken,
|
|
3455
|
-
allowedHosts
|
|
3748
|
+
allowedHosts,
|
|
3749
|
+
evaluate: options.evaluate
|
|
3456
3750
|
}));
|
|
3457
3751
|
return new Promise((resolve, reject) => {
|
|
3458
3752
|
const onError = (error) => {
|
|
@@ -3839,7 +4133,8 @@ function applyPanopticConfig(config) {
|
|
|
3839
4133
|
let cacheStore;
|
|
3840
4134
|
if (store === void 0 && config.dashboard) {
|
|
3841
4135
|
if (config.cache !== void 0) {
|
|
3842
|
-
|
|
4136
|
+
const onError = config.onError ?? ((error) => _warlock_js_logger.log.error("ai-panoptic", "cacheStore", error));
|
|
4137
|
+
cacheStore = createCacheTraceStore(config.cache, { onError });
|
|
3843
4138
|
store = cacheStore;
|
|
3844
4139
|
} else store = createInMemoryTraceStore();
|
|
3845
4140
|
exporters.push(store);
|
|
@@ -3910,9 +4205,12 @@ exports.createInMemoryTraceStore = createInMemoryTraceStore;
|
|
|
3910
4205
|
exports.createPanopticMiddleware = createPanopticMiddleware;
|
|
3911
4206
|
exports.dashboard = dashboard;
|
|
3912
4207
|
exports.emptyUsage = emptyUsage;
|
|
4208
|
+
exports.evaluateSystemPrompt = evaluateSystemPrompt;
|
|
4209
|
+
exports.extractLastSystemPrompt = extractLastSystemPrompt;
|
|
3913
4210
|
exports.extractSpanAttributes = extractSpanAttributes;
|
|
3914
4211
|
exports.fileExporter = fileExporter;
|
|
3915
4212
|
exports.filterTraces = filterTraces;
|
|
4213
|
+
exports.findSpanById = findSpanById;
|
|
3916
4214
|
exports.formatSpanIO = formatSpanIO;
|
|
3917
4215
|
exports.formatSpanLine = formatSpanLine;
|
|
3918
4216
|
exports.groupByPrompt = groupByPrompt;
|