feat(web): add Dream Output Viewer

Dreams are currently a black box — to benchmark Tier L models on dreamer
induction quality we need to see what each model produced. This adds a
workspace-scoped /dreams route that surfaces every dream run as a
group of conclusions split into explicit / deductive / inductive
columns, with click-to-expand premise trees.

What's here:

- `lib/dreams.ts`: pure helpers. `clusterConclusionsIntoDreams` groups a
  raw conclusions list into per-pair bursts using a configurable time
  window (default 60s). `expandPremiseTree` walks `reasoning_tree` first
  and falls back to a flat `premises` ID list, with cycle detection and
  a depth cap. Defines an `ExtendedConclusion` type that augments the
  generated schema with `conclusion_type`, `premises`, and
  `reasoning_tree` — Honcho's migration f1a2b3c4d5e6 added those
  columns but `schema.d.ts` doesn't expose them yet, so the UI degrades
  gracefully (unknown types fall into "explicit").

- `api/queries.ts`: new `useDreams` hook that paginates the conclusions
  list endpoint up to a configurable cap (default 400) and hands the
  raw list to the UI for clustering. New `dreams` query key.

- `components/dreams/`:
  - `DreamList.tsx` — route entry. Shows recent dreams as rows with
    timestamp, observer→observed pair, and per-type counts. Selecting
    a row expands an inline detail panel above the list.
  - `DreamDetail.tsx` — three-column view (explicit / deductive /
    inductive). Each inductive conclusion has a "Show premises" button
    that expands the reasoning chain.
  - `PremiseTree.tsx` — recursive premise renderer with type badges,
    cycle indicator, and graceful handling of premises that fall
    outside the loaded page.

- Routing: `routes/workspaces_.$workspaceId_.dreams.tsx` registers the
  page; routeTree.gen.ts regenerated.

- Sidebar: new "Dreams" entry between Conclusions and Webhooks
  (MoonStar icon to distinguish from the dark-mode Moon).

- Breadcrumb: "dreams" added to SECTION_LABELS so the trail resolves.

Tests (vitest, 14 new):
- clustering: empty input; same-pair burst within window; gap exceeds
  threshold → split; different pairs don't merge even with overlapping
  timestamps; custom gap window; newest-first ordering; counts default
  unknown types to explicit.
- premise tree: empty children for no premises; flat list → direct
  children; multi-level reasoning_tree recursion; missing premise
  flagged (not in loaded page); cycle detection halts recursion;
  maxDepth cap; reasoning_tree preferred over flat premises.

Verified manually in the preview: route mounts, sidebar/breadcrumb
resolve, error path renders cleanly, typecheck + lint + dream tests
all clean. The unrelated `app.test.tsx` failure is pre-existing on
main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Agents
2026-05-24 18:29:26 +01:00
committed by Offending Commit
parent 6960bf4ffe
commit f318555c82
11 changed files with 1140 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
import type { components } from "@/api/schema.d.ts";
type ApiConclusion = components["schemas"]["Conclusion"];
export type ConclusionType = "explicit" | "deductive" | "inductive";
export const CONCLUSION_TYPES: readonly ConclusionType[] = [
"explicit",
"deductive",
"inductive",
] as const;
// The generated OpenAPI schema does not yet expose `conclusion_type`, `premises`, or
// `reasoning_tree` (Honcho migration f1a2b3c4d5e6 added the columns but the response
// schema hasn't been regenerated client-side). We declare them as optional here so
// the UI consumes them when present and degrades gracefully when absent.
export type ExtendedConclusion = ApiConclusion & {
conclusion_type?: ConclusionType | null;
premises?: string[] | null;
reasoning_tree?: ReasoningTreeNode | null;
};
export interface ReasoningTreeNode {
conclusion_id: string;
premises?: ReasoningTreeNode[];
}
export interface Dream {
id: string;
observer_id: string;
observed_id: string | null;
session_id: string | null;
earliestMs: number;
latestMs: number;
earliestIso: string;
latestIso: string;
conclusions: ExtendedConclusion[];
}
export interface DreamCounts {
explicit: number;
deductive: number;
inductive: number;
total: number;
}
export interface ClusterOptions {
/** Max gap (ms) between adjacent conclusions in the same dream. Defaults to 60s. */
gapMs?: number;
}
const DEFAULT_GAP_MS = 60_000;
export function inferConclusionType(c: ExtendedConclusion): ConclusionType {
return c.conclusion_type ?? "explicit";
}
export function dreamCounts(dream: Pick<Dream, "conclusions">): DreamCounts {
const counts: DreamCounts = { explicit: 0, deductive: 0, inductive: 0, total: 0 };
for (const c of dream.conclusions) {
counts[inferConclusionType(c)]++;
counts.total++;
}
return counts;
}
function parseMs(iso: string): number {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
function dreamIdFor(observer: string, observed: string | null, earliestIso: string): string {
return `${observer}|${observed ?? ""}|${earliestIso}`;
}
/**
* Group conclusions into "dreams" — bursts of conclusions for the same
* (observer, observed) pair within a short time window.
*
* Algorithm: walk conclusions newest-first; for each peer pair, keep an "open"
* dream open as long as the next conclusion is within `gapMs` of the oldest
* timestamp in the dream. When the gap exceeds `gapMs`, close the dream and
* start a new one for that pair.
*/
export function clusterConclusionsIntoDreams(
conclusions: ExtendedConclusion[],
options: ClusterOptions = {},
): Dream[] {
const gapMs = options.gapMs ?? DEFAULT_GAP_MS;
if (conclusions.length === 0) return [];
const sorted = [...conclusions].sort((a, b) => parseMs(b.created_at) - parseMs(a.created_at));
const openByPair = new Map<string, Dream>();
const result: Dream[] = [];
for (const c of sorted) {
const observed = c.observed_id ?? null;
const pairKey = `${c.observer_id}::${observed ?? ""}`;
const t = parseMs(c.created_at);
const open = openByPair.get(pairKey);
if (open && open.earliestMs - t <= gapMs) {
open.conclusions.push(c);
if (t < open.earliestMs) {
open.earliestMs = t;
open.earliestIso = c.created_at;
open.id = dreamIdFor(c.observer_id, observed, c.created_at);
}
if (t > open.latestMs) {
open.latestMs = t;
open.latestIso = c.created_at;
}
continue;
}
const dream: Dream = {
id: dreamIdFor(c.observer_id, observed, c.created_at),
observer_id: c.observer_id,
observed_id: observed,
session_id: c.session_id ?? null,
earliestMs: t,
latestMs: t,
earliestIso: c.created_at,
latestIso: c.created_at,
conclusions: [c],
};
openByPair.set(pairKey, dream);
result.push(dream);
}
return result.sort((a, b) => b.latestMs - a.latestMs);
}
export function buildPremiseIndex(
conclusions: ExtendedConclusion[],
): Map<string, ExtendedConclusion> {
const index = new Map<string, ExtendedConclusion>();
for (const c of conclusions) index.set(c.id, c);
return index;
}
export interface PremiseNode {
conclusion: ExtendedConclusion | null;
conclusionId: string;
depth: number;
children: PremiseNode[];
cycle: boolean;
}
/**
* Expand the premise tree for a conclusion. Walks `reasoning_tree` if present,
* otherwise falls back to the flat `premises` ID list. Cycle-safe via a visited
* set; `maxDepth` defaults to 8.
*/
export function expandPremiseTree(
conclusionId: string,
index: Map<string, ExtendedConclusion>,
maxDepth = 8,
): PremiseNode {
return walk(conclusionId, index, new Set<string>(), 0, maxDepth);
}
function walk(
conclusionId: string,
index: Map<string, ExtendedConclusion>,
visited: Set<string>,
depth: number,
maxDepth: number,
): PremiseNode {
if (visited.has(conclusionId)) {
return {
conclusion: index.get(conclusionId) ?? null,
conclusionId,
depth,
children: [],
cycle: true,
};
}
const conclusion = index.get(conclusionId) ?? null;
if (!conclusion || depth >= maxDepth) {
return { conclusion, conclusionId, depth, children: [], cycle: false };
}
const nextVisited = new Set(visited);
nextVisited.add(conclusionId);
let children: PremiseNode[] = [];
if (conclusion.reasoning_tree?.premises?.length) {
children = conclusion.reasoning_tree.premises.map((node) =>
walk(node.conclusion_id, index, nextVisited, depth + 1, maxDepth),
);
} else if (conclusion.premises?.length) {
children = conclusion.premises.map((id) => walk(id, index, nextVisited, depth + 1, maxDepth));
}
return { conclusion, conclusionId, depth, children, cycle: false };
}