@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/cjs/index.cjs +312 -14
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/config/apply-panoptic-config.d.mts.map +1 -1
  5. package/esm/config/apply-panoptic-config.mjs +3 -1
  6. package/esm/config/apply-panoptic-config.mjs.map +1 -1
  7. package/esm/config/panoptic-config.type.d.mts +11 -0
  8. package/esm/config/panoptic-config.type.d.mts.map +1 -1
  9. package/esm/dashboard/dashboard.mjs +2 -1
  10. package/esm/dashboard/dashboard.mjs.map +1 -1
  11. package/esm/dashboard/dashboard.type.d.mts +15 -2
  12. package/esm/dashboard/dashboard.type.d.mts.map +1 -1
  13. package/esm/dashboard/index.mjs +7 -0
  14. package/esm/dashboard/serve.mjs +123 -8
  15. package/esm/dashboard/serve.mjs.map +1 -1
  16. package/esm/dashboard/ui.html.mjs +133 -4
  17. package/esm/dashboard/ui.html.mjs.map +1 -1
  18. package/esm/evaluate/evaluate-system-prompt.d.mts +19 -0
  19. package/esm/evaluate/evaluate-system-prompt.d.mts.map +1 -0
  20. package/esm/evaluate/evaluate-system-prompt.mjs +22 -0
  21. package/esm/evaluate/evaluate-system-prompt.mjs.map +1 -0
  22. package/esm/evaluate/evaluate.type.d.mts +47 -0
  23. package/esm/evaluate/evaluate.type.d.mts.map +1 -0
  24. package/esm/evaluate/extract-last-system-prompt.d.mts +19 -0
  25. package/esm/evaluate/extract-last-system-prompt.d.mts.map +1 -0
  26. package/esm/evaluate/extract-last-system-prompt.mjs +24 -0
  27. package/esm/evaluate/extract-last-system-prompt.mjs.map +1 -0
  28. package/esm/evaluate/find-span-by-id.d.mts +8 -0
  29. package/esm/evaluate/find-span-by-id.d.mts.map +1 -0
  30. package/esm/evaluate/find-span-by-id.mjs +13 -0
  31. package/esm/evaluate/find-span-by-id.mjs.map +1 -0
  32. package/esm/evaluate/index.d.mts +4 -0
  33. package/esm/evaluate/index.mjs +5 -0
  34. package/esm/index.d.mts +5 -1
  35. package/esm/index.mjs +6 -1
  36. package/llms-full.txt +95 -3
  37. package/llms.txt +1 -0
  38. package/package.json +4 -3
  39. package/skills/README.md +4 -0
  40. package/skills/evaluate-system-prompt/SKILL.md +86 -0
  41. package/skills/use-local-dashboard/SKILL.md +5 -3
@@ -1 +1 @@
1
- {"version":3,"file":"apply-panoptic-config.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"mappings":";;;;AAgDA;;;;AAA2D;;;;;;;;;;;;;;;iBAA3C,mBAAA,CAAoB,MAAuB,GAAd,cAAc"}
1
+ {"version":3,"file":"apply-panoptic-config.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"mappings":";;;;;AAiDA;;;;AAA2D;;;;;;;;;;;;;;iBAA3C,mBAAA,CAAoB,MAAuB,GAAd,cAAc"}
@@ -3,6 +3,7 @@ import { createInMemoryTraceStore } from "../store/in-memory-trace-store.mjs";
3
3
  import { panoptic } from "../panoptic/panoptic.mjs";
4
4
  import { dashboard } from "../dashboard/dashboard.mjs";
5
5
  import { registerObserver, setObserveAll } from "@warlock.js/ai";
6
+ import { log } from "@warlock.js/logger";
6
7
 
7
8
  //#region ../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts
8
9
  let applied;
@@ -35,7 +36,8 @@ function applyPanopticConfig(config) {
35
36
  let cacheStore;
36
37
  if (store === void 0 && config.dashboard) {
37
38
  if (config.cache !== void 0) {
38
- cacheStore = createCacheTraceStore(config.cache);
39
+ const onError = config.onError ?? ((error) => log.error("ai-panoptic", "cacheStore", error));
40
+ cacheStore = createCacheTraceStore(config.cache, { onError });
39
41
  store = cacheStore;
40
42
  } else store = createInMemoryTraceStore();
41
43
  exporters.push(store);
@@ -1 +1 @@
1
- {"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"sourcesContent":["import type { ExecutionReport, Observer } from \"@warlock.js/ai\";\nimport { registerObserver, setObserveAll } from \"@warlock.js/ai\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport { dashboard } from \"../dashboard/dashboard\";\nimport type { DashboardHandle, DashboardOptions } from \"../dashboard/dashboard.type\";\nimport { panoptic } from \"../panoptic/panoptic\";\nimport type { Panoptic } from \"../panoptic/panoptic.type\";\nimport { createCacheTraceStore } from \"../store/cache-trace-store\";\nimport type { CacheTraceStoreHandle } from \"../store/cache-trace-store\";\nimport { createInMemoryTraceStore } from \"../store/in-memory-trace-store\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { PanopticConfig } from \"./panoptic-config.type\";\n\n/**\n * Module-level applied state. Applying panoptic config is idempotent:\n * the collector is registered as a core `Observer` exactly once, the\n * dashboard is started at most once, and repeat calls only refresh the\n * `observeAll` flag (the latest config wins) without double-registering.\n */\ntype AppliedState = {\n /** The single collector registered as a core `Observer`. */\n panopticInstance: Panoptic;\n /** The dashboard handle, once started. Started at most once. */\n dashboardHandle?: DashboardHandle;\n};\n\nlet applied: AppliedState | undefined;\n\n/**\n * Read a {@link PanopticConfig} and wire panoptic onto the core observe\n * seam — the bridge `ai.config({ panoptic })` resolves through. Builds a\n * collector via the EXISTING {@link panoptic} factory (reusing its\n * collection pipeline, not reinventing it), registers it once via core's\n * `registerObserver` (the subscriber's `collect(report)` structurally IS\n * an {@link Observer}, threaded through a thin wrapper), sets\n * `observeAll`, and — when `config.dashboard` is set — starts the\n * dashboard over the config's store (a store-shaped exporter, or a fresh\n * in-memory store panoptic registers when none was supplied).\n *\n * **Idempotent.** Safe to call on every `onConfigApplied` notification:\n * the observer is registered once, the dashboard started once. Repeat\n * calls update `observeAll` to the latest value but never double-register\n * or double-start. `undefined` config is a no-op (nothing wired).\n *\n * @returns the resolved store-shaped exporter when the dashboard needs\n * one and a fresh store was created — primarily for tests; callers can\n * ignore it.\n */\nexport function applyPanopticConfig(config?: PanopticConfig): void {\n if (config === undefined) {\n return;\n }\n\n // observeAll always tracks the latest config, even on a repeat call.\n setObserveAll(Boolean(config.observeAll));\n\n if (applied === undefined) {\n const exporters = [...(config.exporters ?? [])];\n let store = findStore(exporters);\n\n // Track a cache store separately so we can await its hydration before\n // the dashboard reads it — the in-memory store needs no warm-up.\n let cacheStore: CacheTraceStoreHandle | undefined;\n\n // The dashboard needs a queryable store. If none was supplied via\n // exporters but a dashboard is requested, create one and register it\n // as an exporter so the collector fills it. A configured `cache` picks\n // the durable cache-backed store; otherwise fall back to in-memory so\n // the dashboard works with no cache configured.\n if (store === undefined && config.dashboard) {\n if (config.cache !== undefined) {\n cacheStore = createCacheTraceStore(config.cache);\n store = cacheStore;\n } else {\n store = createInMemoryTraceStore();\n }\n\n exporters.push(store as unknown as ExporterContract);\n }\n\n const panopticInstance = panoptic({\n exporters,\n captureContent: config.captureContent,\n fullHistory: config.fullHistory,\n });\n registerObserver(toObserver(panopticInstance));\n\n applied = { panopticInstance };\n\n if (config.dashboard && store !== undefined) {\n const resolvedStore = store;\n\n // Hydrate the cache mirror (if any) before serving, so a restart\n // surfaces previously-persisted traces immediately. A bare in-memory\n // store resolves the readyGate instantly.\n const readyGate =\n cacheStore !== undefined ? cacheStore.ready() : Promise.resolve();\n\n void readyGate\n .catch(() => {\n // Hydration is best-effort — never block the dashboard on it.\n })\n .then(() => startDashboard(resolvedStore, config.dashboard ?? {}))\n .then((handle) => {\n if (applied !== undefined) {\n applied.dashboardHandle = handle;\n }\n });\n }\n }\n}\n\n/**\n * Reset the applied state — internal, test-only. Closes any running\n * dashboard and forgets the registered collector so a fresh\n * `applyPanopticConfig` starts clean. NOT part of the public surface and\n * does NOT unregister from the core observer registry (use core's\n * `clearObservers` for that in tests).\n */\nexport async function resetAppliedPanopticConfig(): Promise<void> {\n const handle = applied?.dashboardHandle;\n applied = undefined;\n\n if (handle !== undefined) {\n await handle.close();\n }\n}\n\n/**\n * The currently-running dashboard handle, or `undefined` when no\n * dashboard is active. Internal, test-only — lets a spec await the\n * asynchronously-started dashboard. NOT part of the public surface.\n */\nexport function getActiveDashboardHandle(): DashboardHandle | undefined {\n return applied?.dashboardHandle;\n}\n\n/**\n * Wrap a {@link Panoptic} subscriber as a core {@link Observer}. The\n * subscriber already exposes `collect(report)` with a matching signature,\n * but the thin wrapper makes the structural adaptation explicit and keeps\n * the registered object a minimal `Observer` rather than the whole\n * subscriber surface.\n */\nfunction toObserver(instance: Panoptic): Observer {\n return {\n collect(report: ExecutionReport): Promise<void> {\n return instance.collect(report);\n },\n };\n}\n\n/**\n * Find the first exporter that satisfies the {@link TraceStoreContract}\n * read surface (`query` / `get` / `aggregate`) — the in-memory store\n * doubles as an exporter, so a store passed via `exporters` is reused for\n * the dashboard rather than creating a second one.\n */\nfunction findStore(exporters: ExporterContract[]): TraceStoreContract | undefined {\n for (const exporter of exporters) {\n const candidate = exporter as unknown as Partial<TraceStoreContract>;\n\n if (\n typeof candidate.query === \"function\" &&\n typeof candidate.get === \"function\" &&\n typeof candidate.aggregate === \"function\"\n ) {\n return candidate as TraceStoreContract;\n }\n }\n\n return undefined;\n}\n\n/** Start the dashboard, normalizing the `true | DashboardOptions` switch. */\nfunction startDashboard(\n store: TraceStoreContract,\n dashboardConfig: boolean | DashboardOptions,\n): Promise<DashboardHandle> {\n const options: DashboardOptions =\n typeof dashboardConfig === \"object\" ? dashboardConfig : {};\n\n return dashboard(store, options);\n}\n"],"mappings":";;;;;;;AA0BA,IAAI;;;;;;;;;;;;;;;;;;;;;AAsBJ,SAAgB,oBAAoB,QAA+B;CACjE,IAAI,WAAW,QACb;CAIF,cAAc,QAAQ,OAAO,UAAU,CAAC;CAExC,IAAI,YAAY,QAAW;EACzB,MAAM,YAAY,CAAC,GAAI,OAAO,aAAa,CAAC,CAAE;EAC9C,IAAI,QAAQ,UAAU,SAAS;EAI/B,IAAI;EAOJ,IAAI,UAAU,UAAa,OAAO,WAAW;GAC3C,IAAI,OAAO,UAAU,QAAW;IAC9B,aAAa,sBAAsB,OAAO,KAAK;IAC/C,QAAQ;GACV,OACE,QAAQ,yBAAyB;GAGnC,UAAU,KAAK,KAAoC;EACrD;EAEA,MAAM,mBAAmB,SAAS;GAChC;GACA,gBAAgB,OAAO;GACvB,aAAa,OAAO;EACtB,CAAC;EACD,iBAAiB,WAAW,gBAAgB,CAAC;EAE7C,UAAU,EAAE,iBAAiB;EAE7B,IAAI,OAAO,aAAa,UAAU,QAAW;GAC3C,MAAM,gBAAgB;GAQtB,CAFE,eAAe,SAAY,WAAW,MAAM,IAAI,QAAQ,QAAQ,EAEpD,CACX,YAAY,CAEb,CAAC,CAAC,CACD,WAAW,eAAe,eAAe,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACjE,MAAM,WAAW;IAChB,IAAI,YAAY,QACd,QAAQ,kBAAkB;GAE9B,CAAC;EACL;CACF;AACF;;;;;;;;AAkCA,SAAS,WAAW,UAA8B;CAChD,OAAO,EACL,QAAQ,QAAwC;EAC9C,OAAO,SAAS,QAAQ,MAAM;CAChC,EACF;AACF;;;;;;;AAQA,SAAS,UAAU,WAA+D;CAChF,KAAK,MAAM,YAAY,WAAW;EAChC,MAAM,YAAY;EAElB,IACE,OAAO,UAAU,UAAU,cAC3B,OAAO,UAAU,QAAQ,cACzB,OAAO,UAAU,cAAc,YAE/B,OAAO;CAEX;AAGF;;AAGA,SAAS,eACP,OACA,iBAC0B;CAI1B,OAAO,UAAU,OAFf,OAAO,oBAAoB,WAAW,kBAAkB,CAAC,CAE5B;AACjC"}
1
+ {"version":3,"file":"apply-panoptic-config.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/apply-panoptic-config.ts"],"sourcesContent":["import type { ExecutionReport, Observer } from \"@warlock.js/ai\";\nimport { registerObserver, setObserveAll } from \"@warlock.js/ai\";\nimport { log } from \"@warlock.js/logger\";\nimport type { ExporterContract } from \"../contracts/exporter.contract\";\nimport { dashboard } from \"../dashboard/dashboard\";\nimport type { DashboardHandle, DashboardOptions } from \"../dashboard/dashboard.type\";\nimport { panoptic } from \"../panoptic/panoptic\";\nimport type { Panoptic } from \"../panoptic/panoptic.type\";\nimport { createCacheTraceStore } from \"../store/cache-trace-store\";\nimport type { CacheTraceStoreHandle } from \"../store/cache-trace-store\";\nimport { createInMemoryTraceStore } from \"../store/in-memory-trace-store\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { PanopticConfig } from \"./panoptic-config.type\";\n\n/**\n * Module-level applied state. Applying panoptic config is idempotent:\n * the collector is registered as a core `Observer` exactly once, the\n * dashboard is started at most once, and repeat calls only refresh the\n * `observeAll` flag (the latest config wins) without double-registering.\n */\ntype AppliedState = {\n /** The single collector registered as a core `Observer`. */\n panopticInstance: Panoptic;\n /** The dashboard handle, once started. Started at most once. */\n dashboardHandle?: DashboardHandle;\n};\n\nlet applied: AppliedState | undefined;\n\n/**\n * Read a {@link PanopticConfig} and wire panoptic onto the core observe\n * seam — the bridge `ai.config({ panoptic })` resolves through. Builds a\n * collector via the EXISTING {@link panoptic} factory (reusing its\n * collection pipeline, not reinventing it), registers it once via core's\n * `registerObserver` (the subscriber's `collect(report)` structurally IS\n * an {@link Observer}, threaded through a thin wrapper), sets\n * `observeAll`, and — when `config.dashboard` is set — starts the\n * dashboard over the config's store (a store-shaped exporter, or a fresh\n * in-memory store panoptic registers when none was supplied).\n *\n * **Idempotent.** Safe to call on every `onConfigApplied` notification:\n * the observer is registered once, the dashboard started once. Repeat\n * calls update `observeAll` to the latest value but never double-register\n * or double-start. `undefined` config is a no-op (nothing wired).\n *\n * @returns the resolved store-shaped exporter when the dashboard needs\n * one and a fresh store was created — primarily for tests; callers can\n * ignore it.\n */\nexport function applyPanopticConfig(config?: PanopticConfig): void {\n if (config === undefined) {\n return;\n }\n\n // observeAll always tracks the latest config, even on a repeat call.\n setObserveAll(Boolean(config.observeAll));\n\n if (applied === undefined) {\n const exporters = [...(config.exporters ?? [])];\n let store = findStore(exporters);\n\n // Track a cache store separately so we can await its hydration before\n // the dashboard reads it — the in-memory store needs no warm-up.\n let cacheStore: CacheTraceStoreHandle | undefined;\n\n // The dashboard needs a queryable store. If none was supplied via\n // exporters but a dashboard is requested, create one and register it\n // as an exporter so the collector fills it. A configured `cache` picks\n // the durable cache-backed store; otherwise fall back to in-memory so\n // the dashboard works with no cache configured.\n if (store === undefined && config.dashboard) {\n if (config.cache !== undefined) {\n const onError =\n config.onError ??\n ((error: unknown) => log.error(\"ai-panoptic\", \"cacheStore\", error));\n\n cacheStore = createCacheTraceStore(config.cache, { onError });\n store = cacheStore;\n } else {\n store = createInMemoryTraceStore();\n }\n\n exporters.push(store as unknown as ExporterContract);\n }\n\n const panopticInstance = panoptic({\n exporters,\n captureContent: config.captureContent,\n fullHistory: config.fullHistory,\n });\n registerObserver(toObserver(panopticInstance));\n\n applied = { panopticInstance };\n\n if (config.dashboard && store !== undefined) {\n const resolvedStore = store;\n\n // Hydrate the cache mirror (if any) before serving, so a restart\n // surfaces previously-persisted traces immediately. A bare in-memory\n // store resolves the readyGate instantly.\n const readyGate =\n cacheStore !== undefined ? cacheStore.ready() : Promise.resolve();\n\n void readyGate\n .catch(() => {\n // Hydration is best-effort — never block the dashboard on it.\n })\n .then(() => startDashboard(resolvedStore, config.dashboard ?? {}))\n .then((handle) => {\n if (applied !== undefined) {\n applied.dashboardHandle = handle;\n }\n });\n }\n }\n}\n\n/**\n * Reset the applied state — internal, test-only. Closes any running\n * dashboard and forgets the registered collector so a fresh\n * `applyPanopticConfig` starts clean. NOT part of the public surface and\n * does NOT unregister from the core observer registry (use core's\n * `clearObservers` for that in tests).\n */\nexport async function resetAppliedPanopticConfig(): Promise<void> {\n const handle = applied?.dashboardHandle;\n applied = undefined;\n\n if (handle !== undefined) {\n await handle.close();\n }\n}\n\n/**\n * The currently-running dashboard handle, or `undefined` when no\n * dashboard is active. Internal, test-only — lets a spec await the\n * asynchronously-started dashboard. NOT part of the public surface.\n */\nexport function getActiveDashboardHandle(): DashboardHandle | undefined {\n return applied?.dashboardHandle;\n}\n\n/**\n * Wrap a {@link Panoptic} subscriber as a core {@link Observer}. The\n * subscriber already exposes `collect(report)` with a matching signature,\n * but the thin wrapper makes the structural adaptation explicit and keeps\n * the registered object a minimal `Observer` rather than the whole\n * subscriber surface.\n */\nfunction toObserver(instance: Panoptic): Observer {\n return {\n collect(report: ExecutionReport): Promise<void> {\n return instance.collect(report);\n },\n };\n}\n\n/**\n * Find the first exporter that satisfies the {@link TraceStoreContract}\n * read surface (`query` / `get` / `aggregate`) — the in-memory store\n * doubles as an exporter, so a store passed via `exporters` is reused for\n * the dashboard rather than creating a second one.\n */\nfunction findStore(exporters: ExporterContract[]): TraceStoreContract | undefined {\n for (const exporter of exporters) {\n const candidate = exporter as unknown as Partial<TraceStoreContract>;\n\n if (\n typeof candidate.query === \"function\" &&\n typeof candidate.get === \"function\" &&\n typeof candidate.aggregate === \"function\"\n ) {\n return candidate as TraceStoreContract;\n }\n }\n\n return undefined;\n}\n\n/** Start the dashboard, normalizing the `true | DashboardOptions` switch. */\nfunction startDashboard(\n store: TraceStoreContract,\n dashboardConfig: boolean | DashboardOptions,\n): Promise<DashboardHandle> {\n const options: DashboardOptions =\n typeof dashboardConfig === \"object\" ? dashboardConfig : {};\n\n return dashboard(store, options);\n}\n"],"mappings":";;;;;;;;AA2BA,IAAI;;;;;;;;;;;;;;;;;;;;;AAsBJ,SAAgB,oBAAoB,QAA+B;CACjE,IAAI,WAAW,QACb;CAIF,cAAc,QAAQ,OAAO,UAAU,CAAC;CAExC,IAAI,YAAY,QAAW;EACzB,MAAM,YAAY,CAAC,GAAI,OAAO,aAAa,CAAC,CAAE;EAC9C,IAAI,QAAQ,UAAU,SAAS;EAI/B,IAAI;EAOJ,IAAI,UAAU,UAAa,OAAO,WAAW;GAC3C,IAAI,OAAO,UAAU,QAAW;IAC9B,MAAM,UACJ,OAAO,aACL,UAAmB,IAAI,MAAM,eAAe,cAAc,KAAK;IAEnE,aAAa,sBAAsB,OAAO,OAAO,EAAE,QAAQ,CAAC;IAC5D,QAAQ;GACV,OACE,QAAQ,yBAAyB;GAGnC,UAAU,KAAK,KAAoC;EACrD;EAEA,MAAM,mBAAmB,SAAS;GAChC;GACA,gBAAgB,OAAO;GACvB,aAAa,OAAO;EACtB,CAAC;EACD,iBAAiB,WAAW,gBAAgB,CAAC;EAE7C,UAAU,EAAE,iBAAiB;EAE7B,IAAI,OAAO,aAAa,UAAU,QAAW;GAC3C,MAAM,gBAAgB;GAQtB,CAFE,eAAe,SAAY,WAAW,MAAM,IAAI,QAAQ,QAAQ,EAEpD,CACX,YAAY,CAEb,CAAC,CAAC,CACD,WAAW,eAAe,eAAe,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,CACjE,MAAM,WAAW;IAChB,IAAI,YAAY,QACd,QAAQ,kBAAkB;GAE9B,CAAC;EACL;CACF;AACF;;;;;;;;AAkCA,SAAS,WAAW,UAA8B;CAChD,OAAO,EACL,QAAQ,QAAwC;EAC9C,OAAO,SAAS,QAAQ,MAAM;CAChC,EACF;AACF;;;;;;;AAQA,SAAS,UAAU,WAA+D;CAChF,KAAK,MAAM,YAAY,WAAW;EAChC,MAAM,YAAY;EAElB,IACE,OAAO,UAAU,UAAU,cAC3B,OAAO,UAAU,QAAQ,cACzB,OAAO,UAAU,cAAc,YAE/B,OAAO;CAEX;AAGF;;AAGA,SAAS,eACP,OACA,iBAC0B;CAI1B,OAAO,UAAU,OAFf,OAAO,oBAAoB,WAAW,kBAAkB,CAAC,CAE5B;AACjC"}
@@ -72,6 +72,17 @@ type PanopticConfig = {
72
72
  * set `captureMessages`. Default `false`.
73
73
  */
74
74
  fullHistory?: boolean;
75
+ /**
76
+ * Called whenever the cache-backed store (see {@link cache}) fails a
77
+ * hydrate-on-startup or write-through — a bad URL, an unreachable Redis, an
78
+ * auth failure. The store is best-effort by design (a flaky cache must
79
+ * never throw into the collector's hot path), so without this hook a
80
+ * failure is otherwise invisible: the dashboard just silently shows no
81
+ * traces, forever, with no diagnostic anywhere. When omitted, defaults to
82
+ * `log.error("ai-panoptic", "cacheStore", error)` so it's never silent
83
+ * out of the box; pass your own (even a no-op) to override.
84
+ */
85
+ onError?: (error: unknown) => void;
75
86
  };
76
87
  declare module "@warlock.js/ai" {
77
88
  interface AIConfig {
@@ -1 +1 @@
1
- {"version":3,"file":"panoptic-config.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/panoptic-config.type.ts"],"mappings":";;;;;;;AA0BA;;;;;;;;;;;;;;;;;;AA8Ca;AACX;KA/CU,cAAA;;;;;;;EAOV,SAAA,GAAY,gBAAA;EAiDe;;;;;;;;;;;;EApC3B,KAAA,GAAQ,gBAAA;;;;;;;;EAQR,SAAA,aAAsB,gBAAA;;;;;;EAMtB,UAAA;;;;;;EAMA,cAAA;;;;;;EAMA,WAAA;AAAA;AAAA;EAAA,UAQU,QAAA;;IAER,QAAA,GAAW,cAAc;EAAA;AAAA"}
1
+ {"version":3,"file":"panoptic-config.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/config/panoptic-config.type.ts"],"mappings":";;;;;;;AA0BA;;;;;;;;;;;;;;;;;;;;KAAY,cAAA;EA0DV;;;;;;EAnDA,SAAA,GAAY,gBAAA;EA4De;;AAAA;;;;;;;;;;EA/C3B,KAAA,GAAQ,gBAAA;;;;;;;;EAQR,SAAA,aAAsB,gBAAA;;;;;;EAMtB,UAAA;;;;;;EAMA,cAAA;;;;;;EAMA,WAAA;;;;;;;;;;;EAWA,OAAA,IAAW,KAAA;AAAA;AAAA;EAAA,UAQD,QAAA;;IAER,QAAA,GAAW,cAAc;EAAA;AAAA"}
@@ -40,7 +40,8 @@ function dashboard(store, options = {}) {
40
40
  basePath,
41
41
  title,
42
42
  authToken: options.authToken,
43
- allowedHosts
43
+ allowedHosts,
44
+ evaluate: options.evaluate
44
45
  }));
45
46
  return new Promise((resolve, reject) => {
46
47
  const onError = (error) => {
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts"],"sourcesContent":["import { createServer } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { DashboardHandle, DashboardOptions } from \"./dashboard.type\";\nimport { createRequestHandler } from \"./serve\";\n\nconst DEFAULT_PORT = 4319;\nconst DEFAULT_HOST = \"127.0.0.1\";\nconst DEFAULT_TITLE = \"Panoptic\";\n\n/**\n * Start the local Panoptic dashboard over a {@link TraceStoreContract} —\n * a zero-dependency `node:http` server that serves a read-only JSON API\n * and one self-contained HTML page polling it. The store is the live\n * object the collector fills, so each poll reflects the latest completed\n * traces with no extra wiring.\n *\n * This is a low-level building block. The documented path is\n * `ai.config({ panoptic: { dashboard } })`, which constructs (or reuses)\n * the store and calls this for you. Use it directly only when you manage\n * the store yourself.\n *\n * Binds loopback-only by default (`127.0.0.1`) so prompt content is never\n * exposed to the LAN. Pass `port: 0` for an ephemeral port — the resolved\n * port comes back on the handle. A port already in use rejects with a\n * clear, actionable `Error` rather than the raw `EADDRINUSE`.\n *\n * @example\n * const store = createInMemoryTraceStore();\n * const handle = await dashboard(store, { port: 4319, open: true });\n * console.log(handle.url); // http://127.0.0.1:4319/\n * // ...later:\n * await handle.close();\n */\nexport function dashboard(\n store: TraceStoreContract,\n options: DashboardOptions = {},\n): Promise<DashboardHandle> {\n const port = options.port ?? DEFAULT_PORT;\n const host = options.host ?? DEFAULT_HOST;\n const title = options.title ?? DEFAULT_TITLE;\n const basePath = normalizeBasePath(options.basePath);\n\n // Secure-by-default off loopback (S4): a non-loopback bind without an\n // auth token would expose raw prompt content to the network, so refuse\n // to start rather than silently exposing it.\n if (!isLoopbackHost(host) && !options.authToken) {\n return Promise.reject(\n new Error(\n `Panoptic dashboard: binding to a non-loopback host (\"${host}\") requires an \\`authToken\\` ` +\n \"(the dashboard exposes raw prompt content). Pass `authToken`, or bind to 127.0.0.1.\",\n ),\n );\n }\n\n const allowedHosts = (options.allowedHosts ?? defaultAllowedHosts(host)).map(h =>\n h.toLowerCase(),\n );\n\n const handler = createRequestHandler(store, {\n basePath,\n title,\n authToken: options.authToken,\n allowedHosts,\n });\n const server = createServer(handler);\n\n return new Promise<DashboardHandle>((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException): void => {\n server.off(\"error\", onError);\n\n if (error.code === \"EADDRINUSE\") {\n reject(\n new Error(\n `Panoptic dashboard: port ${port} in use; pass { port: 0 } for an ephemeral port`,\n ),\n );\n\n return;\n }\n\n reject(error);\n };\n\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n server.off(\"error\", onError);\n\n const address = server.address();\n const resolvedPort = typeof address === \"object\" && address !== null ? address.port : port;\n const url = `http://${host}:${resolvedPort}${basePath}`;\n\n if (options.open) {\n openBrowser(url);\n }\n\n resolve({\n url,\n port: resolvedPort,\n close(): Promise<void> {\n return new Promise<void>((closeResolve, closeReject) => {\n server.close((closeError) => {\n if (closeError) {\n closeReject(closeError);\n\n return;\n }\n\n closeResolve();\n });\n });\n },\n });\n });\n });\n}\n\n/** Loopback hosts the dashboard may bind without an auth token. */\nfunction isLoopbackHost(host: string): boolean {\n const h = host.toLowerCase();\n return h === \"127.0.0.1\" || h === \"::1\" || h === \"[::1]\" || h === \"localhost\";\n}\n\n/**\n * Default `Host` allowlist for a given bind host. A loopback bind accepts\n * the loopback names a browser would send; a non-loopback bind defaults to\n * just the bound host (callers can widen via `allowedHosts`).\n */\nfunction defaultAllowedHosts(host: string): string[] {\n if (isLoopbackHost(host)) {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"];\n }\n return [host];\n}\n\n/**\n * Normalize a caller `basePath` to a leading-and-trailing-slash form the\n * router can prefix routes with. `undefined` / `\"\"` → `\"/\"`.\n */\nfunction normalizeBasePath(basePath?: string): string {\n if (basePath === undefined || basePath.length === 0 || basePath === \"/\") {\n return \"/\";\n }\n\n const withLeading = basePath.startsWith(\"/\") ? basePath : `/${basePath}`;\n\n return withLeading.endsWith(\"/\") ? withLeading : `${withLeading}/`;\n}\n\n/**\n * Best-effort open of the default browser at `url`. Fire-and-forget and\n * fully swallowed — failing to open a browser must never reject the\n * dashboard start. Uses the platform's native opener via a lazy\n * `node:child_process` import so the dependency is paid only when\n * `open: true`.\n */\nfunction openBrowser(url: string): void {\n void import(\"node:child_process\")\n .then(({ spawn }) => {\n const command =\n process.platform === \"win32\" ? \"cmd\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n const args = process.platform === \"win32\" ? [\"/c\", \"start\", \"\", url] : [url];\n\n const child = spawn(command, args, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {\n // Swallow — opening a browser is best-effort.\n });\n child.unref();\n })\n .catch(() => {\n // Swallow — opening a browser is best-effort.\n });\n}\n"],"mappings":";;;;AAKA,MAAM,eAAe;AACrB,MAAM,eAAe;AACrB,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BtB,SAAgB,UACd,OACA,UAA4B,CAAC,GACH;CAC1B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,WAAW,kBAAkB,QAAQ,QAAQ;CAKnD,IAAI,CAAC,eAAe,IAAI,KAAK,CAAC,QAAQ,WACpC,OAAO,QAAQ,uBACb,IAAI,MACF,wDAAwD,KAAK,mHAE/D,CACF;CAGF,MAAM,gBAAgB,QAAQ,gBAAgB,oBAAoB,IAAI,EAAC,CAAE,KAAI,MAC3E,EAAE,YAAY,CAChB;CAQA,MAAM,SAAS,aANC,qBAAqB,OAAO;EAC1C;EACA;EACA,WAAW,QAAQ;EACnB;CACF,CACkC,CAAC;CAEnC,OAAO,IAAI,SAA0B,SAAS,WAAW;EACvD,MAAM,WAAW,UAAuC;GACtD,OAAO,IAAI,SAAS,OAAO;GAE3B,IAAI,MAAM,SAAS,cAAc;IAC/B,uBACE,IAAI,MACF,4BAA4B,KAAK,gDACnC,CACF;IAEA;GACF;GAEA,OAAO,KAAK;EACd;EAEA,OAAO,GAAG,SAAS,OAAO;EAE1B,OAAO,OAAO,MAAM,YAAY;GAC9B,OAAO,IAAI,SAAS,OAAO;GAE3B,MAAM,UAAU,OAAO,QAAQ;GAC/B,MAAM,eAAe,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO;GACtF,MAAM,MAAM,UAAU,KAAK,GAAG,eAAe;GAE7C,IAAI,QAAQ,MACV,YAAY,GAAG;GAGjB,QAAQ;IACN;IACA,MAAM;IACN,QAAuB;KACrB,OAAO,IAAI,SAAe,cAAc,gBAAgB;MACtD,OAAO,OAAO,eAAe;OAC3B,IAAI,YAAY;QACd,YAAY,UAAU;QAEtB;OACF;OAEA,aAAa;MACf,CAAC;KACH,CAAC;IACH;GACF,CAAC;EACH,CAAC;CACH,CAAC;AACH;;AAGA,SAAS,eAAe,MAAuB;CAC7C,MAAM,IAAI,KAAK,YAAY;CAC3B,OAAO,MAAM,eAAe,MAAM,SAAS,MAAM,WAAW,MAAM;AACpE;;;;;;AAOA,SAAS,oBAAoB,MAAwB;CACnD,IAAI,eAAe,IAAI,GACrB,OAAO;EAAC;EAAa;EAAa;EAAS;CAAK;CAElD,OAAO,CAAC,IAAI;AACd;;;;;AAMA,SAAS,kBAAkB,UAA2B;CACpD,IAAI,aAAa,UAAa,SAAS,WAAW,KAAK,aAAa,KAClE,OAAO;CAGT,MAAM,cAAc,SAAS,WAAW,GAAG,IAAI,WAAW,IAAI;CAE9D,OAAO,YAAY,SAAS,GAAG,IAAI,cAAc,GAAG,YAAY;AAClE;;;;;;;;AASA,SAAS,YAAY,KAAmB;CACtC,AAAK,OAAO,qBAAqB,CAC9B,MAAM,EAAE,YAAY;EAKnB,MAAM,QAAQ,MAHZ,QAAQ,aAAa,UAAU,QAAQ,QAAQ,aAAa,WAAW,SAAS,YACrE,QAAQ,aAAa,UAAU;GAAC;GAAM;GAAS;GAAI;EAAG,IAAI,CAAC,GAAG,GAExC;GAAE,OAAO;GAAU,UAAU;EAAK,CAAC;EACtE,MAAM,GAAG,eAAe,CAExB,CAAC;EACD,MAAM,MAAM;CACd,CAAC,CAAC,CACD,YAAY,CAEb,CAAC;AACL"}
1
+ {"version":3,"file":"dashboard.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.ts"],"sourcesContent":["import { createServer } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport type { DashboardHandle, DashboardOptions } from \"./dashboard.type\";\nimport { createRequestHandler } from \"./serve\";\n\nconst DEFAULT_PORT = 4319;\nconst DEFAULT_HOST = \"127.0.0.1\";\nconst DEFAULT_TITLE = \"Panoptic\";\n\n/**\n * Start the local Panoptic dashboard over a {@link TraceStoreContract} —\n * a zero-dependency `node:http` server that serves a read-only JSON API\n * and one self-contained HTML page polling it. The store is the live\n * object the collector fills, so each poll reflects the latest completed\n * traces with no extra wiring.\n *\n * This is a low-level building block. The documented path is\n * `ai.config({ panoptic: { dashboard } })`, which constructs (or reuses)\n * the store and calls this for you. Use it directly only when you manage\n * the store yourself.\n *\n * Binds loopback-only by default (`127.0.0.1`) so prompt content is never\n * exposed to the LAN. Pass `port: 0` for an ephemeral port — the resolved\n * port comes back on the handle. A port already in use rejects with a\n * clear, actionable `Error` rather than the raw `EADDRINUSE`.\n *\n * @example\n * const store = createInMemoryTraceStore();\n * const handle = await dashboard(store, { port: 4319, open: true });\n * console.log(handle.url); // http://127.0.0.1:4319/\n * // ...later:\n * await handle.close();\n */\nexport function dashboard(\n store: TraceStoreContract,\n options: DashboardOptions = {},\n): Promise<DashboardHandle> {\n const port = options.port ?? DEFAULT_PORT;\n const host = options.host ?? DEFAULT_HOST;\n const title = options.title ?? DEFAULT_TITLE;\n const basePath = normalizeBasePath(options.basePath);\n\n // Secure-by-default off loopback (S4): a non-loopback bind without an\n // auth token would expose raw prompt content to the network, so refuse\n // to start rather than silently exposing it.\n if (!isLoopbackHost(host) && !options.authToken) {\n return Promise.reject(\n new Error(\n `Panoptic dashboard: binding to a non-loopback host (\"${host}\") requires an \\`authToken\\` ` +\n \"(the dashboard exposes raw prompt content). Pass `authToken`, or bind to 127.0.0.1.\",\n ),\n );\n }\n\n const allowedHosts = (options.allowedHosts ?? defaultAllowedHosts(host)).map(h =>\n h.toLowerCase(),\n );\n\n const handler = createRequestHandler(store, {\n basePath,\n title,\n authToken: options.authToken,\n allowedHosts,\n evaluate: options.evaluate,\n });\n const server = createServer(handler);\n\n return new Promise<DashboardHandle>((resolve, reject) => {\n const onError = (error: NodeJS.ErrnoException): void => {\n server.off(\"error\", onError);\n\n if (error.code === \"EADDRINUSE\") {\n reject(\n new Error(\n `Panoptic dashboard: port ${port} in use; pass { port: 0 } for an ephemeral port`,\n ),\n );\n\n return;\n }\n\n reject(error);\n };\n\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n server.off(\"error\", onError);\n\n const address = server.address();\n const resolvedPort = typeof address === \"object\" && address !== null ? address.port : port;\n const url = `http://${host}:${resolvedPort}${basePath}`;\n\n if (options.open) {\n openBrowser(url);\n }\n\n resolve({\n url,\n port: resolvedPort,\n close(): Promise<void> {\n return new Promise<void>((closeResolve, closeReject) => {\n server.close((closeError) => {\n if (closeError) {\n closeReject(closeError);\n\n return;\n }\n\n closeResolve();\n });\n });\n },\n });\n });\n });\n}\n\n/** Loopback hosts the dashboard may bind without an auth token. */\nfunction isLoopbackHost(host: string): boolean {\n const h = host.toLowerCase();\n return h === \"127.0.0.1\" || h === \"::1\" || h === \"[::1]\" || h === \"localhost\";\n}\n\n/**\n * Default `Host` allowlist for a given bind host. A loopback bind accepts\n * the loopback names a browser would send; a non-loopback bind defaults to\n * just the bound host (callers can widen via `allowedHosts`).\n */\nfunction defaultAllowedHosts(host: string): string[] {\n if (isLoopbackHost(host)) {\n return [\"localhost\", \"127.0.0.1\", \"[::1]\", \"::1\"];\n }\n return [host];\n}\n\n/**\n * Normalize a caller `basePath` to a leading-and-trailing-slash form the\n * router can prefix routes with. `undefined` / `\"\"` → `\"/\"`.\n */\nfunction normalizeBasePath(basePath?: string): string {\n if (basePath === undefined || basePath.length === 0 || basePath === \"/\") {\n return \"/\";\n }\n\n const withLeading = basePath.startsWith(\"/\") ? basePath : `/${basePath}`;\n\n return withLeading.endsWith(\"/\") ? withLeading : `${withLeading}/`;\n}\n\n/**\n * Best-effort open of the default browser at `url`. Fire-and-forget and\n * fully swallowed — failing to open a browser must never reject the\n * dashboard start. Uses the platform's native opener via a lazy\n * `node:child_process` import so the dependency is paid only when\n * `open: true`.\n */\nfunction openBrowser(url: string): void {\n void import(\"node:child_process\")\n .then(({ spawn }) => {\n const command =\n process.platform === \"win32\" ? \"cmd\" : process.platform === \"darwin\" ? \"open\" : \"xdg-open\";\n const args = process.platform === \"win32\" ? [\"/c\", \"start\", \"\", url] : [url];\n\n const child = spawn(command, args, { stdio: \"ignore\", detached: true });\n child.on(\"error\", () => {\n // Swallow — opening a browser is best-effort.\n });\n child.unref();\n })\n .catch(() => {\n // Swallow — opening a browser is best-effort.\n });\n}\n"],"mappings":";;;;AAKA,MAAM,eAAe;AACrB,MAAM,eAAe;AACrB,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;AA0BtB,SAAgB,UACd,OACA,UAA4B,CAAC,GACH;CAC1B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,WAAW,kBAAkB,QAAQ,QAAQ;CAKnD,IAAI,CAAC,eAAe,IAAI,KAAK,CAAC,QAAQ,WACpC,OAAO,QAAQ,uBACb,IAAI,MACF,wDAAwD,KAAK,mHAE/D,CACF;CAGF,MAAM,gBAAgB,QAAQ,gBAAgB,oBAAoB,IAAI,EAAC,CAAE,KAAI,MAC3E,EAAE,YAAY,CAChB;CASA,MAAM,SAAS,aAPC,qBAAqB,OAAO;EAC1C;EACA;EACA,WAAW,QAAQ;EACnB;EACA,UAAU,QAAQ;CACpB,CACkC,CAAC;CAEnC,OAAO,IAAI,SAA0B,SAAS,WAAW;EACvD,MAAM,WAAW,UAAuC;GACtD,OAAO,IAAI,SAAS,OAAO;GAE3B,IAAI,MAAM,SAAS,cAAc;IAC/B,uBACE,IAAI,MACF,4BAA4B,KAAK,gDACnC,CACF;IAEA;GACF;GAEA,OAAO,KAAK;EACd;EAEA,OAAO,GAAG,SAAS,OAAO;EAE1B,OAAO,OAAO,MAAM,YAAY;GAC9B,OAAO,IAAI,SAAS,OAAO;GAE3B,MAAM,UAAU,OAAO,QAAQ;GAC/B,MAAM,eAAe,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO;GACtF,MAAM,MAAM,UAAU,KAAK,GAAG,eAAe;GAE7C,IAAI,QAAQ,MACV,YAAY,GAAG;GAGjB,QAAQ;IACN;IACA,MAAM;IACN,QAAuB;KACrB,OAAO,IAAI,SAAe,cAAc,gBAAgB;MACtD,OAAO,OAAO,eAAe;OAC3B,IAAI,YAAY;QACd,YAAY,UAAU;QAEtB;OACF;OAEA,aAAa;MACf,CAAC;KACH,CAAC;IACH;GACF,CAAC;EACH,CAAC;CACH,CAAC;AACH;;AAGA,SAAS,eAAe,MAAuB;CAC7C,MAAM,IAAI,KAAK,YAAY;CAC3B,OAAO,MAAM,eAAe,MAAM,SAAS,MAAM,WAAW,MAAM;AACpE;;;;;;AAOA,SAAS,oBAAoB,MAAwB;CACnD,IAAI,eAAe,IAAI,GACrB,OAAO;EAAC;EAAa;EAAa;EAAS;CAAK;CAElD,OAAO,CAAC,IAAI;AACd;;;;;AAMA,SAAS,kBAAkB,UAA2B;CACpD,IAAI,aAAa,UAAa,SAAS,WAAW,KAAK,aAAa,KAClE,OAAO;CAGT,MAAM,cAAc,SAAS,WAAW,GAAG,IAAI,WAAW,IAAI;CAE9D,OAAO,YAAY,SAAS,GAAG,IAAI,cAAc,GAAG,YAAY;AAClE;;;;;;;;AASA,SAAS,YAAY,KAAmB;CACtC,AAAK,OAAO,qBAAqB,CAC9B,MAAM,EAAE,YAAY;EAKnB,MAAM,QAAQ,MAHZ,QAAQ,aAAa,UAAU,QAAQ,QAAQ,aAAa,WAAW,SAAS,YACrE,QAAQ,aAAa,UAAU;GAAC;GAAM;GAAS;GAAI;EAAG,IAAI,CAAC,GAAG,GAExC;GAAE,OAAO;GAAU,UAAU;EAAK,CAAC;EACtE,MAAM,GAAG,eAAe,CAExB,CAAC;EACD,MAAM,MAAM;CACd,CAAC,CAAC,CACD,YAAY,CAEb,CAAC;AACL"}
@@ -1,3 +1,5 @@
1
+ import { EvaluateConfig } from "../evaluate/evaluate.type.mjs";
2
+
1
3
  //#region ../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.d.ts
2
4
  /**
3
5
  * Options for the local Panoptic {@link dashboard} server.
@@ -5,8 +7,10 @@
5
7
  * Every field is optional and has a safe default. The dashboard is a dev
6
8
  * tool over an in-memory trace store: it binds loopback-only by default
7
9
  * (so prompt content is never accidentally exposed to the LAN), serves a
8
- * single self-contained HTML page, and exposes a tiny read-only JSON API
9
- * the page polls. There is no auth, persistence, or write surface.
10
+ * single self-contained HTML page, and exposes a JSON API the page polls —
11
+ * every route is `GET`-only and read-only EXCEPT the one opt-in write route
12
+ * {@link DashboardOptions.evaluate} enables. There is no auth or persistence
13
+ * by default either.
10
14
  */
11
15
  type DashboardOptions = {
12
16
  /**
@@ -47,6 +51,15 @@ type DashboardOptions = {
47
51
  * bind; for a non-loopback bind you must list the expected host(s).
48
52
  */
49
53
  allowedHosts?: string[];
54
+ /**
55
+ * Enables the drawer's "Evaluate system prompt" button and its backing
56
+ * `POST {basePath}api/traces/:traceId/spans/:spanId/evaluate` route — the
57
+ * dashboard's only write-capable route, and only reachable when this is
58
+ * set. Absent ⇒ the button never renders and a POST there 405s like any
59
+ * other route. Gated by the same `authToken` / `allowedHosts` checks as
60
+ * every other route.
61
+ */
62
+ evaluate?: EvaluateConfig;
50
63
  };
51
64
  /**
52
65
  * Handle returned by {@link dashboard} once the server is listening.
@@ -1 +1 @@
1
- {"version":3,"file":"dashboard.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.ts"],"mappings":";;AASA;;;;;;;;KAAY,gBAAA;EAgCV;;;AAOY;AAQd;EAzCE,IAAA;;;;;;EAMA,IAAA;EAyCgB;AAAA;;;;EAnChB,QAAA;;;;;EAKA,IAAA;EAEA,KAAA;;;;;;;EAOA,SAAA;;;;;;;EAOA,YAAA;AAAA;;;;;;KAQU,eAAA;kFAED,GAAA;WAEA,IAAA;EAET,KAAA,IAAS,OAAO;AAAA"}
1
+ {"version":3,"file":"dashboard.type.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/dashboard.type.ts"],"mappings":";;;;;AAaA;;;;;;;;;KAAY,gBAAA;EAuCV;;;;AASyB;EA1CzB,IAAA;EAkDyB;;;;;EA5CzB,IAAA;EAkDS;;AAAO;;;EA5ChB,QAAA;;;;;EAKA,IAAA;EAEA,KAAA;;;;;;;EAOA,SAAA;;;;;;;EAOA,YAAA;;;;;;;;;EASA,QAAA,GAAW,cAAc;AAAA;;;;;;KAQf,eAAA;kFAED,GAAA;WAEA,IAAA;EAET,KAAA,IAAS,OAAO;AAAA"}
@@ -0,0 +1,7 @@
1
+ import { parseQuery } from "./parse-query.mjs";
2
+ import { dashboardHtml } from "./ui.html.mjs";
3
+ import { createRequestHandler } from "./serve.mjs";
4
+ import { dashboard } from "./dashboard.mjs";
5
+ import { NO_PROMPT_KEY, NO_SESSION_KEY, NO_TYPE_KEY, aggregateByType, filterTraces, groupByPrompt, groupBySession, groupByType, heatIntensity, matchesFilter, maxNodeCost, percentile, rollupCost, traceCost, tracePromptKey } from "./trace-filter.mjs";
6
+
7
+ export { };
@@ -1,7 +1,12 @@
1
+ import { evaluateSystemPrompt } from "../evaluate/evaluate-system-prompt.mjs";
2
+ import { extractLastSystemPrompt } from "../evaluate/extract-last-system-prompt.mjs";
3
+ import { findSpanById } from "../evaluate/find-span-by-id.mjs";
1
4
  import { parseQuery } from "./parse-query.mjs";
2
5
  import { dashboardHtml } from "./ui.html.mjs";
3
6
 
4
7
  //#region ../@warlock.js/ai-panoptic/src/dashboard/serve.ts
8
+ /** Hard cap on the evaluate route's request body — well beyond a real instructions string. */
9
+ const MAX_EVALUATE_BODY_BYTES = 64 * 1024;
5
10
  /**
6
11
  * Security response headers added to every dashboard response (S4):
7
12
  * block MIME-sniffing, framing, referrer leakage, and lock the page's
@@ -31,15 +36,22 @@ function isAuthorized(req, url, token) {
31
36
  * trace store. Kept separate from the server lifecycle so it can be unit
32
37
  * tested by feeding it a fake `req`/`res` without binding a port.
33
38
  *
34
- * Routes (all under `config.basePath`, all read-only):
39
+ * Routes (all under `config.basePath`):
35
40
  *
36
41
  * - `GET api/traces` → `store.query(parseQuery(searchParams))`
37
42
  * - `GET api/traces/:id` → `store.get(id)` or `404`
38
43
  * - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`
39
44
  * - `GET {basePath}` → the self-contained HTML page
45
+ * - `POST api/traces/:traceId/spans/:spanId/evaluate` → ONLY when
46
+ * `config.evaluate` is set; grades the span's last captured system
47
+ * prompt and returns a {@link EvaluateVerdict}. The dashboard's one
48
+ * write route — absent config, POST 405s like it would against any
49
+ * other route (the path is never even pattern-matched).
40
50
  *
41
- * Anything else → `404`. Non-`GET` methods → `405`. The store shapes
42
- * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so responses
51
+ * Anything else → `404`. A method the matched route doesn't accept → `405`.
52
+ * Host allowlist + bearer-token auth (S4) are checked for EVERY request,
53
+ * regardless of method, before any routing. The store shapes
54
+ * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so GET responses
43
55
  * are a plain `JSON.stringify` with no serializer.
44
56
  *
45
57
  * @example
@@ -50,10 +62,6 @@ function createRequestHandler(store, config) {
50
62
  const base = config.basePath;
51
63
  const apiPrefix = `${base}api`;
52
64
  return function handle(req, res) {
53
- if (req.method !== "GET") {
54
- sendJson(res, 405, { error: "method_not_allowed" });
55
- return;
56
- }
57
65
  const url = new URL(req.url ?? "/", "http://localhost");
58
66
  const pathname = url.pathname;
59
67
  const host = hostHeaderName(req.headers.host);
@@ -65,6 +73,17 @@ function createRequestHandler(store, config) {
65
73
  sendJson(res, 401, { error: "unauthorized" });
66
74
  return;
67
75
  }
76
+ if (req.method === "POST" && config.evaluate) {
77
+ const match = matchEvaluatePath(pathname, apiPrefix);
78
+ if (match) {
79
+ handleEvaluate(store, config.evaluate, match.traceId, match.spanId, req, res);
80
+ return;
81
+ }
82
+ }
83
+ if (req.method !== "GET") {
84
+ sendJson(res, 405, { error: "method_not_allowed" });
85
+ return;
86
+ }
68
87
  if (pathname === `${apiPrefix}/traces`) {
69
88
  sendJson(res, 200, store.query(parseQuery(url.searchParams)));
70
89
  return;
@@ -91,7 +110,7 @@ function createRequestHandler(store, config) {
91
110
  "content-type": "text/html; charset=utf-8",
92
111
  ...SECURITY_HEADERS
93
112
  });
94
- res.end(dashboardHtml(base, config.title));
113
+ res.end(dashboardHtml(base, config.title, Boolean(config.evaluate), config.evaluate?.instructions ?? ""));
95
114
  return;
96
115
  }
97
116
  sendJson(res, 404, { error: "not_found" });
@@ -105,6 +124,102 @@ function sendJson(res, status, body) {
105
124
  });
106
125
  res.end(JSON.stringify(body));
107
126
  }
127
+ /**
128
+ * Match `{apiPrefix}/traces/:traceId/spans/:spanId/evaluate` — the one
129
+ * write route. Returns `undefined` for anything else, including the plain
130
+ * `{apiPrefix}/traces/:id` read route (no `/spans/.../evaluate` suffix),
131
+ * so the two never collide.
132
+ */
133
+ function matchEvaluatePath(pathname, apiPrefix) {
134
+ const prefix = `${apiPrefix}/traces/`;
135
+ if (!pathname.startsWith(prefix)) return;
136
+ const match = /^([^/]+)\/spans\/([^/]+)\/evaluate$/.exec(pathname.slice(prefix.length));
137
+ if (!match) return;
138
+ return {
139
+ traceId: decodeURIComponent(match[1]),
140
+ spanId: decodeURIComponent(match[2])
141
+ };
142
+ }
143
+ /**
144
+ * Collect and JSON-parse a request body, capped at
145
+ * {@link MAX_EVALUATE_BODY_BYTES} so an oversized body can't hold the
146
+ * connection open indefinitely. An empty body resolves to `undefined` —
147
+ * the evaluate route treats that as "no per-run override".
148
+ */
149
+ function readJsonBody(req) {
150
+ return new Promise((resolve, reject) => {
151
+ const chunks = [];
152
+ let size = 0;
153
+ req.on("data", (chunk) => {
154
+ size += chunk.length;
155
+ if (size > MAX_EVALUATE_BODY_BYTES) {
156
+ req.destroy();
157
+ reject(/* @__PURE__ */ new Error("payload_too_large"));
158
+ return;
159
+ }
160
+ chunks.push(chunk);
161
+ });
162
+ req.on("end", () => {
163
+ if (chunks.length === 0) {
164
+ resolve(void 0);
165
+ return;
166
+ }
167
+ try {
168
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
169
+ } catch {
170
+ reject(/* @__PURE__ */ new Error("invalid_json"));
171
+ }
172
+ });
173
+ req.on("error", reject);
174
+ });
175
+ }
176
+ /**
177
+ * Handle `POST {apiPrefix}/traces/:traceId/spans/:spanId/evaluate`. Looks
178
+ * up the trace + span, extracts its last captured system prompt, and
179
+ * grades it via {@link evaluateSystemPrompt}. `judgePromptBody` itself
180
+ * never throws (a broken judge degrades to an issues-only outcome) — the
181
+ * try/catch here only guards `config.evaluate.model` resolution, the one
182
+ * step that CAN throw (e.g. a factory constructing an SDK client).
183
+ */
184
+ async function handleEvaluate(store, evaluate, traceId, spanId, req, res) {
185
+ const trace = store.get(traceId);
186
+ if (trace === void 0) {
187
+ sendJson(res, 404, {
188
+ error: "trace_not_found",
189
+ traceId
190
+ });
191
+ return;
192
+ }
193
+ const span = findSpanById(trace.root, spanId);
194
+ if (span === void 0) {
195
+ sendJson(res, 404, {
196
+ error: "span_not_found",
197
+ spanId
198
+ });
199
+ return;
200
+ }
201
+ const systemPrompt = extractLastSystemPrompt(span);
202
+ if (systemPrompt === void 0) {
203
+ sendJson(res, 422, { error: "no_system_prompt" });
204
+ return;
205
+ }
206
+ let body;
207
+ try {
208
+ body = await readJsonBody(req);
209
+ } catch (error) {
210
+ const message = error instanceof Error ? error.message : "invalid_json";
211
+ sendJson(res, message === "payload_too_large" ? 413 : 400, { error: message });
212
+ return;
213
+ }
214
+ try {
215
+ sendJson(res, 200, await evaluateSystemPrompt(systemPrompt, evaluate, body?.instructions));
216
+ } catch (error) {
217
+ sendJson(res, 502, {
218
+ error: "evaluate_failed",
219
+ message: error instanceof Error ? error.message : String(error)
220
+ });
221
+ }
222
+ }
108
223
 
109
224
  //#endregion
110
225
  export { createRequestHandler };
@@ -1 +1 @@
1
- {"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport { parseQuery } from \"./parse-query\";\nimport { dashboardHtml } from \"./ui.html\";\n\n/** Fully-resolved routing config the request handler closes over. */\nexport type ServeConfig = {\n /** Normalized mount path, always ending in `/` (e.g. `\"/\"`). */\n basePath: string;\n /** Header title baked into the served page. */\n title: string;\n /** Bearer token required on every request when set (S4). */\n authToken?: string;\n /** `Host` header allowlist — defends against DNS-rebinding (S4). */\n allowedHosts: string[];\n};\n\n/**\n * Security response headers added to every dashboard response (S4):\n * block MIME-sniffing, framing, referrer leakage, and lock the page's\n * content sources down to itself (the UI is fully self-contained — no CDN).\n */\nconst SECURITY_HEADERS: Record<string, string> = {\n \"x-content-type-options\": \"nosniff\",\n \"x-frame-options\": \"DENY\",\n \"referrer-policy\": \"no-referrer\",\n \"content-security-policy\":\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n};\n\n/** Extract the host (no port) from a `Host` header value. */\nfunction hostHeaderName(hostHeader: string | undefined): string | undefined {\n if (!hostHeader) return undefined;\n // IPv6 literal `[::1]:4319` → `[::1]`; otherwise strip `:port`.\n if (hostHeader.startsWith(\"[\")) {\n return hostHeader.slice(0, hostHeader.indexOf(\"]\") + 1);\n }\n const colon = hostHeader.indexOf(\":\");\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/** Constant-ish bearer-token check from `Authorization` header or `?token=`. */\nfunction isAuthorized(req: IncomingMessage, url: URL, token: string): boolean {\n const header = req.headers.authorization;\n if (header && header === `Bearer ${token}`) return true;\n return url.searchParams.get(\"token\") === token;\n}\n\n/**\n * Build the `node:http` request handler for the dashboard over a given\n * trace store. Kept separate from the server lifecycle so it can be unit\n * tested by feeding it a fake `req`/`res` without binding a port.\n *\n * Routes (all under `config.basePath`, all read-only):\n *\n * - `GET api/traces` → `store.query(parseQuery(searchParams))`\n * - `GET api/traces/:id` → `store.get(id)` or `404`\n * - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`\n * - `GET {basePath}` → the self-contained HTML page\n *\n * Anything else → `404`. Non-`GET` methods → `405`. The store shapes\n * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so responses\n * are a plain `JSON.stringify` with no serializer.\n *\n * @example\n * const handler = createRequestHandler(store, { basePath: \"/\", title: \"Panoptic\" });\n * http.createServer(handler).listen(4319, \"127.0.0.1\");\n */\nexport function createRequestHandler(\n store: TraceStoreContract,\n config: ServeConfig,\n): (req: IncomingMessage, res: ServerResponse) => void {\n const base = config.basePath;\n const apiPrefix = `${base}api`;\n\n return function handle(req: IncomingMessage, res: ServerResponse): void {\n if (req.method !== \"GET\") {\n sendJson(res, 405, { error: \"method_not_allowed\" });\n\n return;\n }\n\n // `req.url` is path + query only; a dummy origin lets URL parse it.\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n const pathname = url.pathname;\n\n // Host-header allowlist — blocks DNS-rebinding attacks that point a\n // hostile domain at this loopback port (S4).\n const host = hostHeaderName(req.headers.host);\n if (!host || !config.allowedHosts.includes(host.toLowerCase())) {\n sendJson(res, 403, { error: \"host_not_allowed\" });\n\n return;\n }\n\n // Bearer-token auth when configured (always required off loopback, S4).\n if (config.authToken && !isAuthorized(req, url, config.authToken)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n\n return;\n }\n\n if (pathname === `${apiPrefix}/traces`) {\n sendJson(res, 200, store.query(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname.startsWith(`${apiPrefix}/traces/`)) {\n const traceId = decodeURIComponent(pathname.slice(`${apiPrefix}/traces/`.length));\n const trace = traceId.length > 0 ? store.get(traceId) : undefined;\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n sendJson(res, 200, trace);\n\n return;\n }\n\n if (pathname === `${apiPrefix}/aggregate`) {\n sendJson(res, 200, store.aggregate(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname === base || pathname === base.replace(/\\/$/, \"\")) {\n res.writeHead(200, {\n \"content-type\": \"text/html; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(dashboardHtml(base, config.title));\n\n return;\n }\n\n sendJson(res, 404, { error: \"not_found\" });\n };\n}\n\n/** Write a JSON response with the given status code and security headers. */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"content-type\": \"application/json; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(JSON.stringify(body));\n}\n"],"mappings":";;;;;;;;;AAsBA,MAAM,mBAA2C;CAC/C,0BAA0B;CAC1B,mBAAmB;CACnB,mBAAmB;CACnB,2BACE;AACJ;;AAGA,SAAS,eAAe,YAAoD;CAC1E,IAAI,CAAC,YAAY,OAAO;CAExB,IAAI,WAAW,WAAW,GAAG,GAC3B,OAAO,WAAW,MAAM,GAAG,WAAW,QAAQ,GAAG,IAAI,CAAC;CAExD,MAAM,QAAQ,WAAW,QAAQ,GAAG;CACpC,OAAO,UAAU,KAAK,aAAa,WAAW,MAAM,GAAG,KAAK;AAC9D;;AAGA,SAAS,aAAa,KAAsB,KAAU,OAAwB;CAC5E,MAAM,SAAS,IAAI,QAAQ;CAC3B,IAAI,UAAU,WAAW,UAAU,SAAS,OAAO;CACnD,OAAO,IAAI,aAAa,IAAI,OAAO,MAAM;AAC3C;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,qBACd,OACA,QACqD;CACrD,MAAM,OAAO,OAAO;CACpB,MAAM,YAAY,GAAG,KAAK;CAE1B,OAAO,SAAS,OAAO,KAAsB,KAA2B;EACtE,IAAI,IAAI,WAAW,OAAO;GACxB,SAAS,KAAK,KAAK,EAAE,OAAO,qBAAqB,CAAC;GAElD;EACF;EAGA,MAAM,MAAM,IAAI,IAAI,IAAI,OAAO,KAAK,kBAAkB;EACtD,MAAM,WAAW,IAAI;EAIrB,MAAM,OAAO,eAAe,IAAI,QAAQ,IAAI;EAC5C,IAAI,CAAC,QAAQ,CAAC,OAAO,aAAa,SAAS,KAAK,YAAY,CAAC,GAAG;GAC9D,SAAS,KAAK,KAAK,EAAE,OAAO,mBAAmB,CAAC;GAEhD;EACF;EAGA,IAAI,OAAO,aAAa,CAAC,aAAa,KAAK,KAAK,OAAO,SAAS,GAAG;GACjE,SAAS,KAAK,KAAK,EAAE,OAAO,eAAe,CAAC;GAE5C;EACF;EAEA,IAAI,aAAa,GAAG,UAAU,UAAU;GACtC,SAAS,KAAK,KAAK,MAAM,MAAM,WAAW,IAAI,YAAY,CAAC,CAAC;GAE5D;EACF;EAEA,IAAI,SAAS,WAAW,GAAG,UAAU,SAAS,GAAG;GAC/C,MAAM,UAAU,mBAAmB,SAAS,MAAM,GAAG,UAAU,UAAU,MAAM,CAAC;GAChF,MAAM,QAAQ,QAAQ,SAAS,IAAI,MAAM,IAAI,OAAO,IAAI;GAExD,IAAI,UAAU,QAAW;IACvB,SAAS,KAAK,KAAK;KAAE,OAAO;KAAmB;IAAQ,CAAC;IAExD;GACF;GAEA,SAAS,KAAK,KAAK,KAAK;GAExB;EACF;EAEA,IAAI,aAAa,GAAG,UAAU,aAAa;GACzC,SAAS,KAAK,KAAK,MAAM,UAAU,WAAW,IAAI,YAAY,CAAC,CAAC;GAEhE;EACF;EAEA,IAAI,aAAa,QAAQ,aAAa,KAAK,QAAQ,OAAO,EAAE,GAAG;GAC7D,IAAI,UAAU,KAAK;IACjB,gBAAgB;IAChB,GAAG;GACL,CAAC;GACD,IAAI,IAAI,cAAc,MAAM,OAAO,KAAK,CAAC;GAEzC;EACF;EAEA,SAAS,KAAK,KAAK,EAAE,OAAO,YAAY,CAAC;CAC3C;AACF;;AAGA,SAAS,SAAS,KAAqB,QAAgB,MAAqB;CAC1E,IAAI,UAAU,QAAQ;EACpB,gBAAgB;EAChB,GAAG;CACL,CAAC;CACD,IAAI,IAAI,KAAK,UAAU,IAAI,CAAC;AAC9B"}
1
+ {"version":3,"file":"serve.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-panoptic/src/dashboard/serve.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { EvaluateConfig } from \"../evaluate/evaluate.type\";\nimport { evaluateSystemPrompt } from \"../evaluate/evaluate-system-prompt\";\nimport { extractLastSystemPrompt } from \"../evaluate/extract-last-system-prompt\";\nimport { findSpanById } from \"../evaluate/find-span-by-id\";\nimport type { TraceStoreContract } from \"../store/trace-store.contract\";\nimport { parseQuery } from \"./parse-query\";\nimport { dashboardHtml } from \"./ui.html\";\n\n/** Fully-resolved routing config the request handler closes over. */\nexport type ServeConfig = {\n /** Normalized mount path, always ending in `/` (e.g. `\"/\"`). */\n basePath: string;\n /** Header title baked into the served page. */\n title: string;\n /** Bearer token required on every request when set (S4). */\n authToken?: string;\n /** `Host` header allowlist — defends against DNS-rebinding (S4). */\n allowedHosts: string[];\n /**\n * Enables the one write route: `POST\n * {basePath}api/traces/:traceId/spans/:spanId/evaluate`. Absent ⇒ the\n * route 405s (POST isn't accepted by any route) and the served page never\n * renders the Evaluate button.\n */\n evaluate?: EvaluateConfig;\n};\n\n/** Request body accepted by the evaluate route — everything optional. */\ntype EvaluateRequestBody = {\n /** Per-run instructions override; falls back to `config.evaluate.instructions`. */\n instructions?: string;\n};\n\n/** Hard cap on the evaluate route's request body — well beyond a real instructions string. */\nconst MAX_EVALUATE_BODY_BYTES = 64 * 1024;\n\n/**\n * Security response headers added to every dashboard response (S4):\n * block MIME-sniffing, framing, referrer leakage, and lock the page's\n * content sources down to itself (the UI is fully self-contained — no CDN).\n */\nconst SECURITY_HEADERS: Record<string, string> = {\n \"x-content-type-options\": \"nosniff\",\n \"x-frame-options\": \"DENY\",\n \"referrer-policy\": \"no-referrer\",\n \"content-security-policy\":\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n};\n\n/** Extract the host (no port) from a `Host` header value. */\nfunction hostHeaderName(hostHeader: string | undefined): string | undefined {\n if (!hostHeader) return undefined;\n // IPv6 literal `[::1]:4319` → `[::1]`; otherwise strip `:port`.\n if (hostHeader.startsWith(\"[\")) {\n return hostHeader.slice(0, hostHeader.indexOf(\"]\") + 1);\n }\n const colon = hostHeader.indexOf(\":\");\n return colon === -1 ? hostHeader : hostHeader.slice(0, colon);\n}\n\n/** Constant-ish bearer-token check from `Authorization` header or `?token=`. */\nfunction isAuthorized(req: IncomingMessage, url: URL, token: string): boolean {\n const header = req.headers.authorization;\n if (header && header === `Bearer ${token}`) return true;\n return url.searchParams.get(\"token\") === token;\n}\n\n/**\n * Build the `node:http` request handler for the dashboard over a given\n * trace store. Kept separate from the server lifecycle so it can be unit\n * tested by feeding it a fake `req`/`res` without binding a port.\n *\n * Routes (all under `config.basePath`):\n *\n * - `GET api/traces` → `store.query(parseQuery(searchParams))`\n * - `GET api/traces/:id` → `store.get(id)` or `404`\n * - `GET api/aggregate` → `store.aggregate(parseQuery(searchParams))`\n * - `GET {basePath}` → the self-contained HTML page\n * - `POST api/traces/:traceId/spans/:spanId/evaluate` → ONLY when\n * `config.evaluate` is set; grades the span's last captured system\n * prompt and returns a {@link EvaluateVerdict}. The dashboard's one\n * write route — absent config, POST 405s like it would against any\n * other route (the path is never even pattern-matched).\n *\n * Anything else → `404`. A method the matched route doesn't accept → `405`.\n * Host allowlist + bearer-token auth (S4) are checked for EVERY request,\n * regardless of method, before any routing. The store shapes\n * ({@link Trace}/{@link TraceSpan}) are already JSON-safe, so GET responses\n * are a plain `JSON.stringify` with no serializer.\n *\n * @example\n * const handler = createRequestHandler(store, { basePath: \"/\", title: \"Panoptic\" });\n * http.createServer(handler).listen(4319, \"127.0.0.1\");\n */\nexport function createRequestHandler(\n store: TraceStoreContract,\n config: ServeConfig,\n): (req: IncomingMessage, res: ServerResponse) => void {\n const base = config.basePath;\n const apiPrefix = `${base}api`;\n\n return function handle(req: IncomingMessage, res: ServerResponse): void {\n // `req.url` is path + query only; a dummy origin lets URL parse it.\n const url = new URL(req.url ?? \"/\", \"http://localhost\");\n const pathname = url.pathname;\n\n // Host-header allowlist — blocks DNS-rebinding attacks that point a\n // hostile domain at this loopback port (S4). Checked for every method.\n const host = hostHeaderName(req.headers.host);\n if (!host || !config.allowedHosts.includes(host.toLowerCase())) {\n sendJson(res, 403, { error: \"host_not_allowed\" });\n\n return;\n }\n\n // Bearer-token auth when configured (always required off loopback, S4).\n if (config.authToken && !isAuthorized(req, url, config.authToken)) {\n sendJson(res, 401, { error: \"unauthorized\" });\n\n return;\n }\n\n // The one write route — opt-in via `config.evaluate`, so it must be\n // checked before the blanket GET-only gate below.\n if (req.method === \"POST\" && config.evaluate) {\n const match = matchEvaluatePath(pathname, apiPrefix);\n\n if (match) {\n void handleEvaluate(store, config.evaluate, match.traceId, match.spanId, req, res);\n\n return;\n }\n }\n\n if (req.method !== \"GET\") {\n sendJson(res, 405, { error: \"method_not_allowed\" });\n\n return;\n }\n\n if (pathname === `${apiPrefix}/traces`) {\n sendJson(res, 200, store.query(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname.startsWith(`${apiPrefix}/traces/`)) {\n const traceId = decodeURIComponent(pathname.slice(`${apiPrefix}/traces/`.length));\n const trace = traceId.length > 0 ? store.get(traceId) : undefined;\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n sendJson(res, 200, trace);\n\n return;\n }\n\n if (pathname === `${apiPrefix}/aggregate`) {\n sendJson(res, 200, store.aggregate(parseQuery(url.searchParams)));\n\n return;\n }\n\n if (pathname === base || pathname === base.replace(/\\/$/, \"\")) {\n res.writeHead(200, {\n \"content-type\": \"text/html; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(dashboardHtml(base, config.title, Boolean(config.evaluate), config.evaluate?.instructions ?? \"\"));\n\n return;\n }\n\n sendJson(res, 404, { error: \"not_found\" });\n };\n}\n\n/** Write a JSON response with the given status code and security headers. */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"content-type\": \"application/json; charset=utf-8\",\n ...SECURITY_HEADERS,\n });\n res.end(JSON.stringify(body));\n}\n\n/**\n * Match `{apiPrefix}/traces/:traceId/spans/:spanId/evaluate` — the one\n * write route. Returns `undefined` for anything else, including the plain\n * `{apiPrefix}/traces/:id` read route (no `/spans/.../evaluate` suffix),\n * so the two never collide.\n */\nfunction matchEvaluatePath(\n pathname: string,\n apiPrefix: string,\n): { traceId: string; spanId: string } | undefined {\n const prefix = `${apiPrefix}/traces/`;\n\n if (!pathname.startsWith(prefix)) {\n return undefined;\n }\n\n const match = /^([^/]+)\\/spans\\/([^/]+)\\/evaluate$/.exec(pathname.slice(prefix.length));\n\n if (!match) {\n return undefined;\n }\n\n return {\n traceId: decodeURIComponent(match[1]),\n spanId: decodeURIComponent(match[2]),\n };\n}\n\n/**\n * Collect and JSON-parse a request body, capped at\n * {@link MAX_EVALUATE_BODY_BYTES} so an oversized body can't hold the\n * connection open indefinitely. An empty body resolves to `undefined` —\n * the evaluate route treats that as \"no per-run override\".\n */\nfunction readJsonBody<T>(req: IncomingMessage): Promise<T | undefined> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let size = 0;\n\n req.on(\"data\", (chunk: Buffer) => {\n size += chunk.length;\n\n if (size > MAX_EVALUATE_BODY_BYTES) {\n req.destroy();\n reject(new Error(\"payload_too_large\"));\n\n return;\n }\n\n chunks.push(chunk);\n });\n\n req.on(\"end\", () => {\n if (chunks.length === 0) {\n resolve(undefined);\n\n return;\n }\n\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString(\"utf-8\")) as T);\n } catch {\n reject(new Error(\"invalid_json\"));\n }\n });\n\n req.on(\"error\", reject);\n });\n}\n\n/**\n * Handle `POST {apiPrefix}/traces/:traceId/spans/:spanId/evaluate`. Looks\n * up the trace + span, extracts its last captured system prompt, and\n * grades it via {@link evaluateSystemPrompt}. `judgePromptBody` itself\n * never throws (a broken judge degrades to an issues-only outcome) — the\n * try/catch here only guards `config.evaluate.model` resolution, the one\n * step that CAN throw (e.g. a factory constructing an SDK client).\n */\nasync function handleEvaluate(\n store: TraceStoreContract,\n evaluate: EvaluateConfig,\n traceId: string,\n spanId: string,\n req: IncomingMessage,\n res: ServerResponse,\n): Promise<void> {\n const trace = store.get(traceId);\n\n if (trace === undefined) {\n sendJson(res, 404, { error: \"trace_not_found\", traceId });\n\n return;\n }\n\n const span = findSpanById(trace.root, spanId);\n\n if (span === undefined) {\n sendJson(res, 404, { error: \"span_not_found\", spanId });\n\n return;\n }\n\n const systemPrompt = extractLastSystemPrompt(span);\n\n if (systemPrompt === undefined) {\n sendJson(res, 422, { error: \"no_system_prompt\" });\n\n return;\n }\n\n let body: EvaluateRequestBody | undefined;\n\n try {\n body = await readJsonBody<EvaluateRequestBody>(req);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"invalid_json\";\n sendJson(res, message === \"payload_too_large\" ? 413 : 400, { error: message });\n\n return;\n }\n\n try {\n const verdict = await evaluateSystemPrompt(systemPrompt, evaluate, body?.instructions);\n sendJson(res, 200, verdict);\n } catch (error) {\n sendJson(res, 502, {\n error: \"evaluate_failed\",\n message: error instanceof Error ? error.message : String(error),\n });\n }\n}\n"],"mappings":"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"}
@@ -40,7 +40,7 @@ import { WARLOCK_LOGO_DATA_URI } from "./warlock-logo.mjs";
40
40
  * @param basePath Normalized mount path ending in `/` (e.g. `"/"`).
41
41
  * @param title Header title shown in the page.
42
42
  */
43
- function dashboardHtml(basePath, title) {
43
+ function dashboardHtml(basePath, title, evaluateEnabled = false, evaluateDefaultInstructions = "") {
44
44
  const apiBase = `${basePath}api`;
45
45
  const safeTitle = escapeHtml(title);
46
46
  return `<!doctype html>
@@ -221,6 +221,18 @@ function dashboardHtml(basePath, title) {
221
221
  .legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }
222
222
  .legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }
223
223
  .gantt { padding: 8px 10px; }
224
+
225
+ /* Evaluate — the drawer's one write action (config-gated). */
226
+ .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; }
227
+ .eval-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border2); }
228
+ .eval-btn:disabled { opacity: .6; cursor: default; }
229
+ .eval-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); }
230
+ .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; }
231
+ .eval-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
232
+ .eval-error { color: var(--fail); font-size: 12px; }
233
+ .eval-result { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; }
234
+ .eval-score { font-weight: 600; }
235
+ .eval-issues { margin: 6px 0 0; padding-left: 18px; }
224
236
  .grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
225
237
  .grow:hover { background: var(--surface); }
226
238
  .grow.selected { background: var(--sel-bg); }
@@ -285,7 +297,21 @@ function dashboardHtml(basePath, title) {
285
297
  <script>
286
298
  (function () {
287
299
  var API = ${JSON.stringify(apiBase)};
300
+ var EVALUATE_ENABLED = ${JSON.stringify(evaluateEnabled)};
288
301
  var BT = String.fromCharCode(96);
302
+ var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};
303
+
304
+ // Carry the ?token= the page itself was loaded with onto every
305
+ // subsequent poll — otherwise the API calls below inherit no auth and
306
+ // 401 forever once authToken is configured (the initial page load is
307
+ // the only request the URL's query string naturally reaches).
308
+ var TOKEN = new URLSearchParams(window.location.search).get("token");
309
+ function fetchAuthed(url, options) {
310
+ var opts = options || {};
311
+ var headers = opts.headers || {};
312
+ if (TOKEN) headers = Object.assign({}, headers, { Authorization: "Bearer " + TOKEN });
313
+ return fetch(url, Object.assign({}, opts, { headers: headers }));
314
+ }
289
315
 
290
316
  var state = {
291
317
  traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,
@@ -297,7 +323,8 @@ function dashboardHtml(basePath, title) {
297
323
  showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)
298
324
  collapsedGroups: {}, // collapsed group headers (session, prompt, or type)
299
325
  view: "tree", // drawer left pane: "tree" | "timeline"
300
- hashApplied: false // guards one-time deep-link open on load
326
+ hashApplied: false, // guards one-time deep-link open on load
327
+ evaluate: {} // per-span evaluate UI state, keyed by spanId
301
328
  };
302
329
 
303
330
  var STATUS_FILTERS = ["completed", "failed", "cancelled"];
@@ -1006,6 +1033,80 @@ function dashboardHtml(basePath, title) {
1006
1033
  return '<div class="meta-sec"><div class="meta-title">metadata</div><div class="kv">' + kv + "</div></div>";
1007
1034
  }
1008
1035
 
1036
+ // --- Evaluate: grade a span's last captured system prompt -------------
1037
+ // Config-gated (EVALUATE_ENABLED) — the drawer's only write action, POSTing
1038
+ // to a route that itself only exists when the server was configured with
1039
+ // evaluate. UI state lives in state.evaluate, keyed by spanId, so it
1040
+ // survives a re-render (e.g. switching to a sibling span and back).
1041
+ function extractLastSystemPrompt(span) {
1042
+ if (!Array.isArray(span.input)) return null;
1043
+ for (var i = span.input.length - 1; i >= 0; i--) {
1044
+ var m = span.input[i];
1045
+ if (m && typeof m === "object" && m.role === "system" && typeof m.content === "string") return m.content;
1046
+ }
1047
+ return null;
1048
+ }
1049
+ function evalState(spanId) {
1050
+ return state.evaluate[spanId] || (state.evaluate[spanId] = {
1051
+ open: false, instructions: EVALUATE_DEFAULT_INSTRUCTIONS, status: "idle", result: null, error: null
1052
+ });
1053
+ }
1054
+ function evalResultHtml(result) {
1055
+ var score = typeof result.score === "number" ? Math.round(result.score * 100) + "%" : "n/a";
1056
+ var issues = (result.issues || []).map(function (i) { return "<li>" + esc(i) + "</li>"; }).join("");
1057
+ return '<div class="eval-result"><span class="eval-score">Score: ' + score + "</span>"
1058
+ + (issues ? '<ul class="eval-issues">' + issues + "</ul>" : "") + "</div>";
1059
+ }
1060
+ function evalSectionHtml(span) {
1061
+ if (!EVALUATE_ENABLED) return "";
1062
+ var sysPrompt = extractLastSystemPrompt(span);
1063
+ if (!sysPrompt) return "";
1064
+ var st = evalState(span.spanId);
1065
+ var btn = '<button type="button" class="eval-btn" data-evaluate-toggle="' + esc(span.spanId) + '">'
1066
+ + (st.open ? "Hide evaluate" : "Evaluate system prompt") + "</button>";
1067
+ if (!st.open) return btn;
1068
+ var running = st.status === "running";
1069
+ return btn + '<div class="eval-panel">'
1070
+ + '<textarea id="eval-instructions" placeholder="Grading instructions (optional — falls back to the configured default)"' + (running ? " disabled" : "") + ">" + esc(st.instructions || "") + "</textarea>"
1071
+ + '<div class="eval-actions">'
1072
+ + '<button type="button" class="eval-btn" data-evaluate-run="' + esc(span.spanId) + '"' + (running ? " disabled" : "") + ">" + (running ? "Evaluating…" : "Run") + "</button>"
1073
+ + (st.error ? '<span class="eval-error">' + esc(st.error) + "</span>" : "")
1074
+ + "</div>"
1075
+ + (st.result ? evalResultHtml(st.result) : "")
1076
+ + "</div>";
1077
+ }
1078
+ function rerenderDetail() {
1079
+ var t = findTrace(state.selectedId);
1080
+ if (!t) return;
1081
+ var sel = findSpan(t.root, state.selectedSpanId) || t.root;
1082
+ document.getElementById("drawer-detail").innerHTML = renderDetail(sel, t);
1083
+ }
1084
+ function toggleEvalPanel(spanId) {
1085
+ evalState(spanId).open = !evalState(spanId).open;
1086
+ rerenderDetail();
1087
+ }
1088
+ function runEvaluate(traceId, spanId) {
1089
+ var st = evalState(spanId);
1090
+ var textarea = document.getElementById("eval-instructions");
1091
+ if (textarea) st.instructions = textarea.value;
1092
+ st.status = "running"; st.error = null;
1093
+ rerenderDetail();
1094
+ fetchAuthed(API + "/traces/" + encodeURIComponent(traceId) + "/spans/" + encodeURIComponent(spanId) + "/evaluate", {
1095
+ method: "POST",
1096
+ headers: { "content-type": "application/json" },
1097
+ body: JSON.stringify({ instructions: st.instructions })
1098
+ }).then(function (r) {
1099
+ return r.json().then(function (data) { return { ok: r.ok, data: data }; });
1100
+ }).then(function (res) {
1101
+ if (res.ok) { st.status = "done"; st.result = res.data; st.error = null; }
1102
+ else { st.status = "error"; st.error = (res.data && (res.data.message || res.data.error)) || "evaluate failed"; st.result = null; }
1103
+ rerenderDetail();
1104
+ }).catch(function () {
1105
+ st.status = "error"; st.error = "network error"; st.result = null;
1106
+ rerenderDetail();
1107
+ });
1108
+ }
1109
+
1009
1110
  function renderDetail(span, trace) {
1010
1111
  var path = findPath(trace.root, span.spanId) || [span];
1011
1112
  var crumb = path.map(function (p, i) { return (i ? '<span class="sep"> › </span>' : "") + "<span>" + esc(p.name) + "</span>"; }).join("");
@@ -1028,6 +1129,7 @@ function dashboardHtml(basePath, title) {
1028
1129
  + '<span class="dim">· ' + dur(span.duration) + "</span></div>"
1029
1130
  + metaLine
1030
1131
  + io
1132
+ + evalSectionHtml(span, trace)
1031
1133
  + renderMeta(span, trace);
1032
1134
  }
1033
1135
 
@@ -1190,9 +1292,20 @@ function dashboardHtml(basePath, title) {
1190
1292
  if (vb) { setView(vb.getAttribute("data-view")); return; }
1191
1293
  var tog = e.target.closest ? e.target.closest("[data-toggle]") : null;
1192
1294
  if (tog) { toggleSpan(tog.getAttribute("data-toggle")); return; }
1295
+ var evalToggle = e.target.closest ? e.target.closest("[data-evaluate-toggle]") : null;
1296
+ if (evalToggle) { toggleEvalPanel(evalToggle.getAttribute("data-evaluate-toggle")); return; }
1297
+ var evalRun = e.target.closest ? e.target.closest("[data-evaluate-run]") : null;
1298
+ if (evalRun && state.selectedId) { runEvaluate(state.selectedId, evalRun.getAttribute("data-evaluate-run")); return; }
1193
1299
  var node = e.target.closest ? e.target.closest(".tnode[data-span], .grow[data-span]") : null;
1194
1300
  if (node) selectSpan(node.getAttribute("data-span"));
1195
1301
  });
1302
+ // Track the instructions textarea live (no re-render on keystroke, so
1303
+ // typing never loses focus/cursor position).
1304
+ document.getElementById("drawer-body").addEventListener("input", function (e) {
1305
+ if (e.target && e.target.id === "eval-instructions" && state.selectedSpanId) {
1306
+ evalState(state.selectedSpanId).instructions = e.target.value;
1307
+ }
1308
+ });
1196
1309
  document.getElementById("backdrop").addEventListener("click", closeDrawer);
1197
1310
  document.addEventListener("keydown", function (e) { if (e.key === "Escape" || e.keyCode === 27) closeDrawer(); });
1198
1311
 
@@ -1288,8 +1401,8 @@ function dashboardHtml(basePath, title) {
1288
1401
 
1289
1402
  function poll() {
1290
1403
  Promise.all([
1291
- fetch(API + "/aggregate").then(function (r) { return r.json(); }),
1292
- fetch(API + "/traces").then(function (r) { return r.json(); })
1404
+ fetchAuthed(API + "/aggregate").then(function (r) { return r.json(); }),
1405
+ fetchAuthed(API + "/traces").then(function (r) { return r.json(); })
1293
1406
  ]).then(function (res) {
1294
1407
  renderStats(res[0]);
1295
1408
  state.traces = res[1] || [];
@@ -1324,6 +1437,22 @@ function dashboardHtml(basePath, title) {
1324
1437
  function escapeHtml(value) {
1325
1438
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1326
1439
  }
1440
+ /**
1441
+ * Encode free-form text (e.g. `evaluate.instructions`, which an operator
1442
+ * could type anything into) as a JS EXPRESSION that reconstructs it at
1443
+ * runtime, WITHOUT ever emitting a literal backtick into the served page —
1444
+ * the client script is itself built from a TS template literal, so a raw
1445
+ * backtick in the output would be a real syntax hazard (see the `BT =
1446
+ * String.fromCharCode(96)` construction already in the client script for
1447
+ * the same reason). `JSON.stringify` alone doesn't escape backticks (they
1448
+ * aren't JSON-significant), so a value containing one is split around it
1449
+ * and rejoined with the client's own `BT` constant. No backticks in the
1450
+ * input ⇒ a single plain `JSON.stringify(value)` — no unnecessary
1451
+ * concatenation in the common case.
1452
+ */
1453
+ function encodeForInlineScript(value) {
1454
+ return value.split("`").map((part) => JSON.stringify(part)).join(" + BT + ");
1455
+ }
1327
1456
 
1328
1457
  //#endregion
1329
1458
  export { dashboardHtml };