import research docs from prior conversation and scattered sources

This commit is contained in:
2026-04-29 15:11:46 +00:00
parent 9915be2ca6
commit b256fc7eb5
9 changed files with 4274 additions and 0 deletions

52
docs/research/README.md Normal file
View File

@@ -0,0 +1,52 @@
# UJSX Research Index
All research documents for the `@alkdev/ujsx` rewrite. This project is a universal JSX IR that treats JSX as an intermediate representation for multi-target rendering (markdown primary, graph/HTML/etc. future).
## Architecture & Design
| Doc | Description |
|-----|-------------|
| [agent-hud-architecture.md](./agent-hud-architecture.md) | Full HUD architecture: event log, adaptive density, cache-aware system prompt, plugin integration |
| [ujsx-v2-typebox-rewrite.md](./ujsx-v2-typebox-rewrite.md) | The rewrite plan: TypeBox-schema-driven UJSX, bi-directional transforms, JSX syntax, HTML-agnostic core |
| [signals-ujsx-reactive-pipeline.md](./signals-ujsx-reactive-pipeline.md) | Preact Signals integration: `computed()` components, reactive transform pipeline, signals+TypeBox interaction |
## Ecosystem & Feasibility
| Doc | Description |
|-----|-------------|
| [unist-ecosystem-jsx-to-markdown.md](./unist-ecosystem-jsx-to-markdown.md) | hast/mdast/syntax-tree ecosystem, pipeline feasibility, element mappings, alternative approaches |
| [hono-jsx-ssr-llm-hud.md](./hono-jsx-ssr-llm-hud.md) | Hono JSXNode internals, SSR pipeline, custom walker feasibility for markdown rendering |
## TypeBox Patterns
| Doc | Description |
|-----|-------------|
| [typebox-module-type-registry.md](./typebox-module-type-registry.md) | `Type.Module` as type registry, `Import` mechanics, runtime schema access, implications for UJSX |
| [typebox-module-valuepointer.md](./typebox-module-valuepointer.md) | `TModule` + `ValuePointer` API, `ComputeModuleProperties`, Function types, practical patterns |
## Prior Art & Source Reference
| Doc | Description |
|-----|-------------|
| [prior-poc-source-reference.md](./prior-poc-source-reference.md) | Code from `/workspace/aui/ujsx` POC: what to preserve, what to remove/rewrite, TypeBox research examples |
## External Resources
| Location | Description |
|----------|-------------|
| `/workspace/aui/ujsx/` | Prior POC: core types, h() factory, HostConfig, Graphology host, TransformRegistry, StreamingTransformer |
| `/workspace/aui/SUMMARY.md` | POC summary with architecture, strengths, gaps, recommendations |
| `/workspace/research/typebox_research/ujsx/` | TypeBox Module examples: `unist.ts` (unist schema), `ujsx.ts` (UJSX schema draft) |
| `/workspace/@alkdev/typebox/` | TypeBox fork source: Module, ValuePointer, Value system |
| `/workspace/signals/packages/core/` | Preact Signals core: signal, computed, effect, batch, createModel |
| `/workspace/conversations/research/` | Original research docs from conversation session |
## Key Decisions from Prior Discussion
1. **TypeBox Module IS the type registry** — no separate registry needed. `ValuePointer.Get/Set` for runtime access, `Module.Import` for resolved schemas.
2. **Signals for reactivity**`computed()` wrapping component functions so unchanged inputs skip re-evaluation. NOT in the registry, but in the transformation process.
3. **Bi-directional transforms** — same registry, `direction` parameter. UJSX↔mdast, UJSX↔hast. Rules match on TypeBox schemas, not string tags.
4. **HTML-agnostic core** — no onClick, className, aria-* in UniversalProps. Hosts handle platform specifics.
5. **Actual JSX syntax**`jsxImportSource: "@alkdev/ujsx"` with jsx-runtime exports.
6. **HostConfig preserved** — same reconciler pattern from POC, but mount-only → full reconciliation.
7. **Platform agnostic** — no Deno/Node-specific APIs in core. Published as `@alkdev/ujsx`.

View File

@@ -0,0 +1,675 @@
# Agent HUD: A Context Management Architecture for LLM Agents
## Research → Design Synthesis
This document synthesizes findings from six research areas into a concrete architecture for an agent HUD system that replaces ad-hoc compaction with structured, cache-aware context management.
**See also**: [`ujsx-v2-typebox-rewrite.md`](./ujsx-v2-typebox-rewrite.md) — The UJSX v2 rewrite plan, replacing the existing POC at `/workspace/aui` with a TypeBox-schema-driven universal JSX IR that supports actual JSX syntax, bi-directional transforms, and component schemas as tool schemas.
---
## The Problem
Current LLM agent interfaces have a fundamental flaw: **context is managed as a monolithic conversation log that grows until it must be violently compacted**. This creates several pathologies:
1. **Information cliff**: Compaction replaces rich conversation with a lossy summary. Everything before the compaction boundary is gone — the agent can't even know what it lost.
2. **Reactive, not proactive**: OpenCode's compaction fires at ~92% context usage. There's no budgeting — system prompt, tool definitions, and conversation history compete for the same fixed space with no allocation strategy.
3. **Cognitive waste**: The agent sees the full conversation log every turn, including messages that are no longer relevant. Early messages about setup, resolved errors, and abandoned approaches consume tokens without providing value.
4. **Cognitive strain**: The agent has no ambient awareness of its own state. It must explicitly call tools to check context usage, search history, or understand where it is in a task. Each tool call costs a turn and consumes context.
5. **No provider-cache alignment**: OpenCode's 2-part system prompt split (`llm.ts:115-126`) is the only cache-aware structure. The rest of the context — messages, tool results, the full conversation — has no cache segmentation.
The key insight: **the "agent frame" — what the LLM sees in a single turn — should be treated as a composition problem, not an append-only log problem.**
---
## The Leverage Point
OpenCode's `llm.ts:115-126` reveals the critical pattern:
```typescript
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
```
This splits the system prompt into:
- **Part 0** (cached, stable): Agent prompt, provider prompt — changes rarely, benefits from prompt caching
- **Part 1** (dynamic, re-cached each call): Environment, skills, instructions — changes often
The plugin system pushes to `output.system[]` which gets merged into Part 1. The open-memory plugin injects context status (~50 tokens) into this dynamic part.
**This 2-part pattern generalizes.** We can extend it to a multi-part context composition with different cache characteristics:
| Part | Content | Changes | Cache Value |
|------|---------|---------|-------------|
| 0 | Agent identity, core instructions | Rarely | Highest (static across turns) |
| 1 | HUD static layer (task, key decisions) | On agent action | High (stable within a task phase) |
| 2 | HUD dynamic layer (notes, recent events) | Every turn | Medium (re-cached each call) |
| 3 | Conversation messages | Every turn | Low (grows monotonically) |
---
## Architecture: The Agent HUD
### Core Concept
The HUD is a **structured markdown document** that the agent sees at the top of every turn, injected via the `experimental.chat.system.transform` plugin hook. Unlike compaction (which discards information), the HUD is:
- **Composed**: Built from components, not a flat log
- **Bidirectional**: The agent can update it via tools
- **Cache-aware**: Structured so static parts benefit from provider caching
- **Adaptive**: Automatically adjusts density based on context pressure
### The "Agent Frame" Model
Instead of treating the conversation as an append-only log, think of each agent turn as a **frame** composed of:
```
┌─────────────────────────────────────┐
│ System Prompt Part 0 (cached) │ ← Agent identity, core instructions
│ [never changes within a session] │
├─────────────────────────────────────┤
│ System Prompt Part 1 (re-cached) │ ← HUD rendered to markdown
│ ┌─────────────────────────────────┐ │
│ │ # Task │ │ ← Static HUD (changes on action)
│ │ Implement auth module │ │
│ │ │ │
│ │ ## Context │ │ ← Adaptive density
│ │ 67% used (134k/200k tokens) │ │ ← Context status
│ │ Trend: growing rapidly │ │
│ │ │ │
│ │ ## Key Decisions │ │ ← Agent-maintained state
│ │ • Using JWT over sessions │ │
│ │ • bcrypt for password hashing │ │
│ │ │ │
│ │ ## Active Files │ │ ← Derived from tool calls
│ │ • src/auth/mod.ts (editing) │ │
│ │ • src/auth/jwt.ts (referenced) │ │
│ │ │ │
│ │ ## Next Steps │ │ ← Agent's own plan
│ │ 1. Add refresh token rotation │ │
│ │ 2. Write auth middleware │ │
│ │ 3. Add tests │ │
│ │ │ │
│ │ ## Notes │ │ ← Agent's scratchpad
│ │ • DB schema: users, sessions │ │
│ │ • Rate limit: 100/min default │ │
│ └─────────────────────────────────┘ │
├─────────────────────────────────────┤
│ Conversation Messages │ ← Standard message history
│ [filtered by compaction boundary] │
└─────────────────────────────────────┘
```
### The Append-Only Event Log
Underlying the HUD is an **append-only event log** — similar to Yjs/CRDT event streams but simpler because we're single-author (one agent per session). This is the "source of truth" that the HUD renders from.
```typescript
interface HudEvent {
id: string // UUID
type: HudEventType // discriminated union
timestamp: number // Unix ms
sessionId: string // opencode session ID
turn: number // which agent turn
payload: unknown // type-specific data
}
type HudEventType =
| "task.set" // Agent sets the current task description
| "task.update" // Agent refines the task
| "decision.record" // Agent records a key decision
| "note.add" // Agent adds a note
| "note.update" // Agent updates a note
| "note.remove" // Agent removes a note
| "file.open" // Agent reads a file (derived from tool calls)
| "file.edit" // Agent edits a file (derived from tool calls)
| "file.close" // File falls out of recent context
| "step.complete" // Agent marks a step complete
| "step.add" // Agent adds a next step
| "step.reorder" // Agent reorders steps
| "error.encountered" // Agent encounters an error
| "error.resolved" // Agent resolves an error
| "blocker.add" // Agent identifies a blocker
| "blocker.remove" // Agent removes a blocker
| "context.snapshot" // Context window usage snapshot (auto-generated)
| "compact.before" // Pre-compaction state snapshot
| "compact.after" // Post-compaction state + summary
```
The event log is persisted and append-only. It replaces the "conversation log as only state" model with "conversation log as one input to the HUD, alongside structured state."
### Rendering Pipeline: UJSX → MDAST → Markdown
The HUD uses the **UJSX v2** universal JSX IR (see [`ujsx-v2-typebox-rewrite.md`](./ujsx-v2-typebox-rewrite.md)) built on TypeBox schemas. The existing POC at `/workspace/aui` already implements UJSX → mdast → markdown with a `TransformRegistry` and `mdast-util-to-markdown`. The v2 rewrite adds:
- Actual JSX syntax (via `jsxImportSource: "@ade/ujsx"`)
- TypeBox schema-driven node types (schemas ARE types, schemas ARE tool parameter schemas)
- Bi-directional transforms (UJSX ↔ mdast, UJSX ↔ hast, etc.)
- HTML-agnostic core (no `onClick`, `className` in universal props)
```
HUD Components (.tsx with JSX syntax)
h() factory / JSX transform → UElement tree (TypeBox-schema-validated)
▼ TransformRegistry (direction: 'ujsx->mdast')
│ Rules match on TypeBox schemas, not string tags
mdast tree
▼ mdast-util-to-markdown + mdast-util-gfm
Markdown String
▼ Injected via experimental.chat.system.transform
System Prompt Part 1
```
**Why UJSX (not Hono JSXNode, not hast→mdast)?**
1. **Schema-driven**: TypeBox schemas serve triple duty — TypeScript types, runtime validation, and tool parameter schemas. Component props = tool input schemas. Zero duplication.
2. **Bi-directional**: Rules convert both UJSX→mdast AND mdast→UJSX. Parse existing markdown (notes, AGENTS.md) back into the IR.
3. **HTML-agnostic**: No `onClick`, `className`, `aria-*` in the core. The IR isn't pretending to be HTML.
4. **HostConfig preserved**: The same component tree renders to graphology, markdown, or future targets.
5. **Actual JSX syntax**: With `jsxImportSource`, write `<TaskSection task={state.task} density="compact" />` not `h('TaskSection', { task: state.task, density: 'compact' })`.
### HUD Component Architecture
```tsx
// The root HUD component
function HUD({ state, contextInfo }: { state: HudState, contextInfo: ContextInfo }) {
const density = getDensity(contextInfo.percentage)
return (
<Container>
<TaskSection task={state.task} density={density} />
<ContextBar info={contextInfo} density={density} />
{density !== 'minimal' && <DecisionsList decisions={state.decisions} density={density} />}
{density !== 'minimal' && <ActiveFiles files={state.activeFiles} density={density} />}
<NextSteps steps={state.nextSteps} density={density} />
{density === 'full' && <NotesSection notes={state.notes} />}
{contextInfo.percentage > 85 && <WarningBanner info={contextInfo} />}
</Container>
)
}
// Adaptive density: renders differently based on context pressure
type Density = 'full' | 'compact' | 'minimal'
function getDensity(percentage: number): Density {
if (percentage < 70) return 'full'
if (percentage < 85) return 'compact'
return 'minimal'
}
// Example adaptive component
function DecisionsList({ decisions, density }: { decisions: Decision[], density: Density }) {
if (density === 'compact') {
return <text>{`## Decisions (${decisions.length})\n` + decisions.map(d => `- ${d.summary}`).join('\n')}</text>
}
return (
<section title="Decisions">
{decisions.map(d => <DecisionItem decision={d} />)}
</section>
)
}
```
### Cache-Aware System Prompt Structure
The key extension of OpenCode's 2-part pattern:
```
System Message 0 (cached, stable):
- Agent identity prompt
- Provider-specific prompt
— Never changes within a session
System Message 1 (re-cached each call):
- HUD static layer (task, decisions, next steps)
- HUD dynamic layer (context bar, notes, active files)
- Environment info, skills, instructions
— Changes based on agent actions and context pressure
```
The **static layer** of the HUD should change only when the agent explicitly updates it (task change, decision recorded, step completed). The **dynamic layer** changes every turn (context percentage, recent files, notes).
For Anthropic's `cache_control`, we mark:
- System message 0 → `cacheControl: { type: "ephemeral" }` (already done by opencode)
- System message 1 → `cacheControl: { type: "ephemeral" }` (already done by opencode)
The savings come from keeping message 0 stable (0.1x cost on cache hits) and making message 1 as small as possible while still providing full situational awareness.
### HUD State and Event Log Storage
The event log uses the same SQLite database opencode already has, with a new table:
```sql
CREATE TABLE hud_event (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES session(id),
type TEXT NOT NULL, -- HudEventType
turn INTEGER NOT NULL, -- agent turn number
timestamp INTEGER NOT NULL, -- Unix ms
payload TEXT NOT NULL, -- JSON
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX idx_hud_event_session ON hud_event(session_id, timestamp);
CREATE INDEX idx_hud_event_type ON hud_event(session_id, type);
```
The **HUD state** is derived from the event log by projectors (similar to opencode's existing event sourcing pattern):
```typescript
interface HudState {
task: { description: string; updatedAt: number } | null
decisions: Array<{ id: string; summary: string; details: string; recordedAt: number }>
notes: Array<{ id: string; content: string; updatedAt: number }>
activeFiles: Array<{ path: string; lastAccessed: number; status: 'reading' | 'editing' | 'referenced' }>
nextSteps: Array<{ id: string; description: string; completed: boolean; order: number }>
blockers: Array<{ id: string; description: string; resolved: boolean }>
errors: Array<{ id: string; message: string; resolved: boolean; resolvedAt?: number }>
}
```
Projectors are pure functions: `(state, event) => newState`. They're deterministic and idempotent — the state can always be rebuilt from the event log.
Some events are **derived** rather than agent-authored:
- `file.open`, `file.edit`, `file.close`: Intercepted from opencode's `tool.execute.before/after` hook by watching for Read, Write, Edit tools
- `context.snapshot`: Auto-generated from `SessionProcessor` events tracking token usage
### HUD Tools (Agent-Facing)
The agent interacts with the HUD through two tools (following the router pattern from open-memory):
**`hud`** (router tool — reduces context bloat from tool definitions):
```typescript
hud(input: {
tool: string, // operation name
args?: object // operation arguments
})
```
Operations:
- `task.get` / `task.set` / `task.update` — Manage current task
- `decisions.list` / `decisions.record` / `decisions.remove` — Key decisions log
- `notes.list` / `notes.add` / `notes.update` / `notes.remove` — Scratchpad
- `steps.list` / `steps.add` / `steps.complete` / `steps.reorder` — Next steps / plan
- `blockers.list` / `blockers.add` / `blockers.remove` — Blockers
- `snapshot` — Full HUD state
- `history` — Recent event log (for understanding what changed)
**`hud_compact`** (mutation tool — separate to prevent accidental use):
```typescript
hud_compact() // Triggers both HUD compaction AND opencode compaction
```
This is distinct from `memory_compact` because:
1. It snapshots HUD state to the event log before compaction
2. After compaction, the stable HUD layer carries forward — the agent doesn't lose its task, decisions, or notes
3. It can trigger compaction at a natural breakpoint (when the agent says "next steps updated, good time to compact")
### Plugin Integration (opencode)
The HUD is implemented as an opencode plugin, using these hooks:
```typescript
const HUDPlugin: Plugin = async (ctx) => {
const stateManager = new HudStateManager(ctx) // Manages event log + projectors
const renderer = new HudRenderer() // JSX component → markdown
return {
tool: { hud: createHudTool(ctx, stateManager), hud_compact: createHudCompactTool(ctx, stateManager) },
"experimental.chat.system.transform": async (input, output) => {
// Render HUD and inject as system prompt
const state = stateManager.getState(input.sessionID)
const contextInfo = getContextInfo(input.sessionID)
const markdown = renderer.render(HUD, { state, contextInfo })
output.system.push(markdown)
},
"experimental.session.compacting": async (input, output) => {
// Before compaction: snapshot HUD state to event log
stateManager.recordEvent(input.sessionID, { type: "compact.before", payload: stateManager.getState(input.sessionID) })
// Replace compaction prompt with self-continuity + HUD-aware prompt
output.prompt = getCompactionPrompt(stateManager.getState(input.sessionID))
},
"event": async ({ event }) => {
// Derive file events from tool calls
if (event.type === "tool.execute.after") {
stateManager.maybeRecordFileEvent(event)
}
// Track context snapshots
if (event.type === "message.updated") {
stateManager.maybeRecordContextSnapshot(event)
}
},
"tool.execute.before": async (input, output) => {
// Track file access from tool calls
stateManager.trackToolCall(input)
},
}
}
```
### Rendering Pipeline Implementation
```typescript
// core/renderer.ts
import type { FC } from "hono/jsx"
interface RenderContext {
density: Density
state: HudState
contextInfo: ContextInfo
}
class HudRenderer {
// Custom walker over Hono JSXNode tree
render(component: FC<any>, props: Record<string, any>): string {
const node = component(props)
return this.walkNode(node)
}
private walkNode(node: any): string {
if (typeof node === "string" || typeof node === "number") {
return String(node)
}
if (node === null || node === undefined || node === false) {
return ""
}
if (node instanceof Promise) {
throw new Error("Async components not supported in HUD rendering")
}
// JSXFragmentNode — just concatenate children
if (node.tag === null || node.tag === Symbol.for("hono.fragment")) {
return (node.children as any[]).map(c => this.walkNode(c)).join("\n")
}
// Function component — call it and walk the result
if (typeof node.tag === "function") {
const result = node.tag({ ...node.props, children: node.children })
return this.walkNode(result)
}
// Intrinsic element — map HTML tag to markdown
return this.renderElement(node.tag, node.props, node.children)
}
private renderElement(tag: string, props: any, children: any[]): string {
const childContent = (children || []).map(c => this.walkNode(c)).join("\n")
switch (tag) {
case "h1": return `# ${childContent}`
case "h2": return `## ${childContent}`
case "h3": return `### ${childContent}`
case "strong": case "b": return `**${childContent}**`
case "em": case "i": return `*${childContent}*`
case "code": return props.lang
? `\`\`\`${props.lang}\n${childContent}\n\`\`\``
: `\`${childContent}\``
case "ul": return childContent
case "li": return `- ${childContent}`
case "ol": return childContent // handled by parent
case "p": return childContent
case "div": case "section": case "article": case "main": case "header":
case "footer": case "nav": case "aside": case "span":
return childContent // container — just pass through content
case "a": return `[${childContent}](${props.href})`
case "blockquote": return childContent.split("\n").map(l => `> ${l}`).join("\n")
case "hr": return "---"
case "br": return "\n"
case "pre": return childContent // content already formatted by <code>
case "table": return this.renderTable(props, children)
default:
// Unknown/custom tags: use data-md attribute or just render content
if (props?.["data-md"]) {
// Custom markdown rendering hint
return this.renderCustomMd(props["data-md"], props, childContent)
}
return childContent
}
}
}
```
### Adaptive Density in Practice
The key insight from open-memory's context status thresholding: **information density should be proportional to context pressure.**
```
Context < 70% (GREEN / "full"):
┌────────────────────────────────────┐
│ # Task │
│ Implement user authentication │
│ │
│ ## Context: 45% (90k/200k) stable │
│ │
│ ## Decisions (3) │
│ • Using JWT over sessions │
│ • bcrypt for password hashing │
│ • Rate limiting: 100/min default │
│ │
│ ## Active Files │
│ • src/auth/mod.ts (editing) │
│ • src/auth/jwt.ts (referenced) │
│ • src/db/schema.ts (referenced) │
│ │
│ ## Next Steps │
│ 1. ~~Add refresh token rotation~~ │
│ 2. Write auth middleware │
│ 3. Add tests │
│ │
│ ## Notes │
│ • DB schema: users, sessions │
│ • Env vars: JWT_SECRET, DB_URL │
└────────────────────────────────────┘
~300-500 tokens
Context 70-85% (YELLOW / "compact"):
┌────────────────────────────────────┐
│ Task: Implement auth │ 72% (144k) ↑ │
│ Decisions: JWT, bcrypt, 100/min │
│ Steps: ~~rotation~~, middleware, │
│ tests │
│ Files: auth/mod.ts, auth/jwt.ts │
│ Notes: DB schema users/sessions │
└────────────────────────────────────┘
~100-150 tokens
Context 85-92% (RED / "minimal"):
┌────────────────────────────────────┐
│ Auth impl │ 89% │ ⚠ compact soon │
│ JWT+bcrypt, step: middleware │
└────────────────────────────────────┘
~30-50 tokens
```
This adaptive compression means the agent always has situational awareness, but the cost scales inversely with available context.
### Compaction That Preserves State
The critical difference from current compaction: **the HUD state survives compaction.**
Current flow:
```
[conversation] → COMPACT → [summary replaces everything] → agent loses context
```
HUD-aware flow:
```
[event log] → project to HUD state → render HUD → [inject into system prompt]
[conversation] → COMPACT → [summary replaces conversation, but HUD persisted state carries forward]
```
Before compaction:
1. Agent records `compact.before` event with full HUD state
2. HUD state is persisted to the event log (already append-only)
3. Compaction prompt includes: "Your HUD state: {rendered HUD}. This will survive compaction."
After compaction:
1. `filterCompacted` drops pre-compaction messages
2. But the system prompt still contains the fully rendered HUD
3. The agent sees its task, decisions, notes, and next steps — not just a narrative summary
The compaction summary becomes a **complement** to the HUD, not a **replacement** for lost context. The summary handles conversational continuity ("we were discussing..."), while the HUD provides structured persistent state.
### Comparison: Current vs HUD Architecture
| Aspect | Current (Compaction) | HUD Architecture |
|--------|---------------------|------------------|
| State management | Monolithic conversation log | Structured event log + HUD projection |
| Context loss | All-or-nothing compaction cliff | Adaptive density that preserves key state |
| Agent awareness | Must call memory tool | Ambient via system prompt injection |
| Cache optimization | 2-part system prompt only | Multi-part with stable HUD layer |
| Recovery from compaction | LLM-generated summary (lossy) | Structured HUD state (deterministic) |
| Cognitive load | Full conversation always visible | Relevant state always visible, details on demand |
| Token cost at 50% | Full conversation (~100k tokens of history) | Conversation + HUD (~100k + ~300 tokens) |
| Token cost at 90% | Conversation (truncated by compaction, then grows again) | Conversation + compact HUD (truncated + ~50 tokens) |
---
## Implementation Plan
### Phase 1: Minimal Viable HUD (Plugin)
1. **Event log table**: Add `hud_event` table to opencode's SQLite database (via plugin)
2. **Basic event types**: `task.set`, `task.update`, `decision.record`, `note.add`, `step.add`, `step.complete`
3. **State projector**: Pure functions that fold events into `HudState`
4. **Simple renderer**: Template-based markdown rendering (no JSX yet)
5. **Plugin hooks**: `system.transform` for injection, `event` for derivation, `session.compacting` for pre-compaction snapshot
6. **Two tools**: `hud` (router) and `hud_compact`
### Phase 2: JSX Rendering Pipeline
1. **Hono JSXNode walking**: Custom ` HudRenderer` that walks JSXNode trees directly to markdown
2. **HUD components**: `<HUD>`, `<TaskSection>`, `<ContextBar>`, `<DecisionsList>`, `<NotesSection>`, `<NextSteps>`
3. **Adaptive density**: Components that render differently based on `density` prop
4. **Test rendering**: Snapshot tests comparing component output to expected markdown
### Phase 3: Cache Optimization
1. **HUD layer splitting**: Separate HUD into static layer (task, decisions, next steps) and dynamic layer (context bar, notes, active files)
2. **Static layer diffing**: Only push to `output.system[]` when static content changes, reducing cache misses
3. **Dynamic layer minimal updates**: Track what changed since last render, include only deltas
4. **Measure cache hit rates**: Instrument provider token usage to verify caching improvements
### Phase 4: Advanced Features
1. **Derived events**: File tracking from tool calls, context snapshots from message events
2. **Search integration**: `hud.search` operation using FTS5 instead of LIKE
3. **Cross-session HUD**: Persist HUD state across sessions, enable resuming tasks
4. **QuickJS scripting**: Use toolEnv's envProxy pattern to let agents customize their HUD components
5. **Multi-agent HUD**: When `coord.spawn` creates sub-agents, share HUD state via parent session events
---
## Key Design Decisions
### Why event log over direct state mutation?
1. **Auditability**: Every state change has a trace. You can reconstruct the HUD at any point in time.
2. **Compaction resilience**: Events survive compaction because they're in a separate table, not the conversation message stream.
3. **No conflict resolution needed**: Unlike CRDTs, we're single-author (one agent per session). The event log is append-only with no merge conflicts.
4. **Projection flexibility**: Different projectors can derive different views from the same events. The HUD is one projection; a task progress tracker could be another.
### Why JSX over Handlebars/templates?
1. **Component composition**: JSX supports arbitrary nesting and composition. `<CompactView>` can wrap `<DecisionsList>` with different rendering logic.
2. **Conditional rendering**: `{density === 'full' && <NotesSection />}` is cleaner than `{{#if density.full}}...{{/if}}`.
3. **Type safety**: Components are typed functions. Props, state, density — all compile-time checked.
4. **Developer familiarity**: React-like patterns are widely understood.
5. **Direct JSXNode walking avoids HTML roundtrip**: No `renderToStaticMarkup → parse → hast → mdast → markdown` needed.
### Why system prompt injection instead of a dedicated message role?
1. **Cache alignment**: OpenCode already manages the system prompt as a cache-aware structure. Injecting into `output.system[]` gives us immediate cache optimization.
2. **No protocol change**: We don't need to change opencode's core messaging protocol. The plugin hook is sufficient.
3. **Survives compaction**: System prompt is always included (it's never compacted). The HUD is always visible.
4. **Provider compatibility**: System prompts work with every LLM provider. A custom message role might not.
### Why router pattern (1 tool) over separate tools?
From open-memory's research: each tool definition adds ~1-2k tokens to the system prompt. With 10+ HUD operations, that's 10-20k tokens of overhead. A single `hud` router tool costs ~2k tokens regardless of how many operations it supports. The `help` operation provides inline documentation.
---
## File Structure (Proposed)
```
packages/hud-plugin/
src/
index.ts # Plugin entry point
state/
types.ts # HudState, HudEvent types
projector.ts # Event → State projectors
store.ts # SQLite read/write for hud_event
renderer/
components/
HUD.tsx # Root HUD component
TaskSection.tsx # Task display
ContextBar.tsx # Context percentage bar
DecisionsList.tsx # Key decisions
NotesSection.tsx # Scratchpad
NextSteps.tsx # Plan/next steps
ActiveFiles.tsx # Currently active files
WarningBanner.tsx # Context pressure warning
renderer.ts # JSXNode → markdown walker
density.ts # Adaptive density logic
tools/
hud.ts # hud router tool definition
hud_compact.ts # hud_compact tool definition
operations/ # Individual operations
task.ts
decisions.ts
notes.ts
steps.ts
files.ts
snapshot.ts
history.ts
hooks/
system-transform.ts # experimental.chat.system.transform
compacting.ts # experimental.session.compacting
event.ts # event derivation (file tracking, context snapshots)
context/
tracker.ts # Context window tracking (from open-memory)
thresholds.ts # Density thresholds
```
---
## Risks and Open Questions
1. **Token overhead at low context usage**: Even "minimal" HUD adds ~30-50 tokens. Is 0% context worth the overhead? Probably yes — the ROI is in the 70%+ range where the HUD prevents catastrophic compaction. At 0%, the full HUD costs ~300-500 tokens, which is ~0.25% of a 200k context. The payoff is avoiding a compaction event that loses the entire conversation.
2. **Agent compliance**: Will agents consistently use `hud` tools to update their state? The current approach relies on the agent choosing to call `hud` tools. Alternatives:
- **Auto-derivation**: More events derived automatically from tool calls (file tracking is automatic, but decisions/notes require agent action)
- **AGENTS.md prompt**: Include HUD tool usage in instructions
- **Conventional prompting**: "Always update your HUD after completing a step or making a decision" in the compaction/system prompt
3. **Stale state**: If the agent doesn't update the HUD before compaction, the HUD might be stale. Mitigation: auto-snapshot HUD state before compaction in the `session.compacting` hook.
4. **JSX dependency weight**: Hono's JSX runtime adds a dependency. Alternative: use a lightweight custom JSX transform that doesn't need Hono. The renderer only needs `JSXNode` types and the walker — not the full Hono framework.
5. **Multi-agent sessions**: When sub-agents are spawned, should they share HUD state? The event log is per-session, but parent-child relationships in opencode could enable HUD state inheritance.
6. **Search vs. structured state**: Should agents search conversation history (like `memory({tool: "search"})`) or maintain structured state (like `hud({tool: "decisions.record"})`)? Both. The HUD is for state the agent actively maintains. Search is for recovering information the agent didn't think to record. They complement each other.
7. **Event log growth**: The `hud_event` table will grow. Needs a cleanup strategy — perhaps tied to compaction events (archive events older than the last compaction point).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
# Prior UJSX POC: Source Code Reference
Source: `/workspace/aui/ujsx/` (Deno/JSR package `@ade/ujsx`)
Summary: `/workspace/aui/SUMMARY.md`
## What to Preserve
### Transform Registry (`transform/registry.ts`)
Priority-based transformation with continuation-passing style:
```typescript
interface TransformRule<T, U, A> {
name: string;
match: (node: T) => boolean;
transform: (node: T, ctx: TransformContext<A>, next: TransformFn<T, U, A>) => U;
priority?: number;
}
```
Key pattern: `next` continuation allows recursive child transforms. Rules sorted by priority (higher = first). V2 extends this with `direction` and `schema` fields.
### HostConfig + Reconciler (`host/config.ts`)
React-reconciler-inspired host adapter:
```typescript
interface HostConfig<TTag, Instance, RootCtx> {
name: string;
createRootContext(container, options?): RootCtx;
createInstance(tag, props, ctx, parent?): Instance;
createTextInstance(text, ctx, parent?): Instance;
appendChild(parent, child, ctx): void;
insertBefore?(parent, child, before, ctx): void;
removeChild?(parent, child, ctx): void;
prepareUpdate?(instance, tag, prevProps, nextProps, ctx): unknown | null;
commitUpdate?(instance, payload, tag, prevProps, nextProps, ctx): void;
}
```
The `createRoot()` reconciler is mount-only (MVP). V2 needs full reconciliation with key-based diffing.
### Graphology Host (`host/graphology.ts`)
Dirty bitmask pattern on graph nodes:
```typescript
const DIRTY = { Props: 1<<0, Content: 1<<1, Structure: 1<<2 } as const;
```
Edges use `parent->child#order` keys. Version tracking on nodes. Issue: `createInstance` has commented-out append logic, `prepareUpdate` uses `JSON.stringify` diffing.
### Streaming Transformer (`streaming/transformer.ts`)
Chunk-based async iterable processor. V2 preserves this but flush should pass real ancestor context instead of empty arrays.
## What to Remove/Rewrite
### `UniversalProps` (`core/types.ts`)
HTML-specific props that don't belong in a universal IR:
- `onClick`, `onSubmit`, `onInput`, `onChange` (event handlers)
- `className`, `class` (HTML-specific)
- `data-*`, `aria-*` template keys
- `__html` (dangerouslySetInnerHTML)
V2 replaces with plain `Record<string, unknown>`.
### `genId()` (`core/jsx.ts`)
```typescript
function genId(): string {
return `e_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
```
Non-deterministic. V2 uses counter-based or injectable IDs.
### Metadata Injection (`core/jsx.ts`)
Every element gets `{ timestamp: new Date(), id: genId() }`. This is overhead without clear use in a universal IR. Move to host-specific concerns if needed.
## TypeBox Research Examples
Source: `/workspace/research/typebox_research/ujsx/`
### `unist.ts` - Unist schema as TypeBox Module
```typescript
export const Unist = Type.Module({
Data: Type.Object({},{additionalProperties: Type.Unknown()}),
Point: Type.Object({ line: Type.Number(), column: Type.Number(), ... }),
Position: Type.Object({ start: Type.Ref('Point'), end: Type.Ref("Point") }),
Node: Type.Object({ type: Type.String(), data: Type.Optional(...), position: Type.Optional(...) }),
Literal: Type.Composite([Type.Ref("Node"), Type.Object({ value: Type.Unknown() })]),
Parent: Type.Composite([Type.Ref("Node"), Type.Object({ children: Type.Array(Type.Ref("Node")) })]),
})
```
### `ujsx.ts` - UJSX schema as TypeBox Module
```typescript
export const UJSX = Type.Module({
ElementMetadata: Type.Object({ id: Type.Optional(Type.String()), timestamp: Type.Optional(Type.Date()) }, { additionalProperties: Type.Unknown() }),
Children: Type.Union([Type.Ref("UniversalNode"), Type.Array(Type.Ref("UniversalNode"))]),
PropValue: Type.Union([Type.String(), Type.Number(), ..., Type.Function([...Type.Rest(Type.Array(Type.Unknown()))], Type.Void())]),
UniversalProps: Type.Object({ id: Type.Optional(Type.String()), children: Type.Optional(Type.Ref("Children")) }, { additionalProperties: Type.Union([Type.Ref("PropValue"), Type.Undefined()]) }),
RootElement: Type.Object({ type: Type.Literal("root"), props: Type.Intersect([...]), children: Type.Array(Type.Ref("UniversalNode")), ... }),
UniversalElement: Type.Object({ type: Type.Union([Type.String(), Type.Function([Type.Ref("UniversalProps")], Type.Ref("UniversalNode"))]), props: Type.Ref("UniversalProps"), children: Type.Array(Type.Ref("UniversalNode")), ... }),
})
```
Key insight: `Type.Module` creates a `TModule` whose `$defs` is a live `string → TSchema` map. Access via `ValuePointer.Get(UJSX, "$defs/Children")`. Add at runtime via `ValuePointer.Set()`. Import resolved schemas via `UJSX.Import("Element")`.
Note: `ElementMetadata` is defined twice in the research file (duplicate). V2 should clean this up and likely drop metadata from the core schema entirely.
### ts2typebox
CLI tool `ts2typebox` can convert TypeScript types to TypeBox defs but has issues with complex types (JSDoc comments, linked references). Useful for basic types, unreliable for complex ones like unist/mdast type definitions.

View File

@@ -0,0 +1,372 @@
# Signals + UJSX: Reactive Transform Pipeline
## The Core Idea
Preact Signals' `computed` provides exactly what UJSX needs for efficient rendering: a component whose output is automatically cached and only recomputed when its input signals change.
In the context of an agent HUD:
- HUD **state** lives in `signal()` values (task description, decisions, notes, context %)
- HUD **components** are `computed()` signals that read their input signals and produce UElements
- The **root render** is a `computed()` that traverses the component tree and produces markdown
- Changing one signal (e.g., context percentage from 70% to 85%) automatically marks only the downstream components as outdated — not the entire tree
This is fundamentally different from the current approach where the entire HUD is re-rendered from scratch every turn.
## How Signals Work (from source)
Key behaviors from `/workspace/signals/packages/core/src/index.ts`:
### `signal(value)` — The Reactive Primitive
```typescript
const count = signal(0)
count.value // read (tracks deps if in computed/effect)
count.value = 1 // write (notifies dependents, batches updates)
count.peek() // read WITHOUT tracking (untracked)
```
### `computed(fn)` — Derived, cached computation
```typescript
const doubled = computed(() => count.value * 2)
```
- Lazily evaluated: fn only runs when `.value` is read AND deps have changed
- Cached: if deps haven't changed, returns cached value without running fn
- Version tracking: uses `_version` numbers on signals instead of storing values
- Dependency graph: automatically tracks which signals were read during evaluation
### `effect(fn)` — Side effects
```typescript
effect(() => {
console.log(doubled.value) // re-runs when count changes
})
```
- Returns a dispose function
- fn can return a cleanup function
- Batches: multiple signal writes within `batch()` only trigger one effect run
### `batch(fn)` — Batching
```typescript
batch(() => {
a.value = 1 // doesn't trigger effects yet
b.value = 2 // still batched
}) // effects run once after batch completes
```
### `createModel(factory)` — Model pattern
```typescript
const Counter = createModel((initial: number) => ({
count: signal(initial),
increment: action(() => { Counter.count.value++ }),
doubled: computed(() => Counter.count.value * 2),
}))
const c = new Counter(0)
c.count.value // 0
c.increment() // batched, untracked write
c.doubled.value // 2
c[Symbol.dispose]() // cleans up all effects
```
### Key Implementation Details
1. **Version numbers, not value storage**: Computed signals store `_version` per dependency node, not values. When checking if recomputation is needed, it compares version numbers. This is memory-efficient.
2. **Global version counter**: `globalVersion` increments when ANY signal changes. Computed signals track `_globalVersion` — if global hasn't changed since last evaluation, skip entirely (fast path).
3. **Lazy evaluation**: Computed values are NOT computed until someone reads `.value`. No eager re-computation.
4. **Batch snapshots**: When writing multiple signals in a batch, `_batchSnapshotVersion` tracks which signals were written. On batch end, `reconcileBatchSnapshots()` restores version numbers for signals that were written but ended up with the same value (avoiding spurious notifications).
5. **Auto-dispose**: Effects created within `createModel` are captured and disposed when the model's `[Symbol.dispose]()` is called.
6. **Untracked reads**: `signal.peek()` reads without tracking. `untracked(fn)` runs fn without tracking. `action(fn)` wraps fn in batch+untracked.
## Application to UJSX Transform Pipeline
### The Problem
In the HUD, every agent turn re-renders the entire HUD from the root. If the task description hasn't changed, the task section still re-renders. If context went from 67% to 68%, the entire tree is walked even though only `ContextBar` changed.
With signals:
- Each component whose input hasn't changed **skips re-evaluation entirely**
- Only the `computed` signals that depend on changed `signal` values re-compute
- The render pipeline walks the tree, but `computed.value` returns cached results for unchanged components
### Proposed Architecture
```typescript
import { signal, computed, batch, effect } from "@preact/signals-core"
// --- State Layer (signals) ---
const hudState = createModel(() => ({
task: signal<{ description: string; updatedAt: number } | null>(null),
contextPercent: signal(0),
contextStatus: computed(() => {
const p = hudState.contextPercent.value
if (p < 70) return 'green' as const
if (p < 85) return 'yellow' as const
return 'red' as const
}),
decisions: signal<Array<{ id: string; summary: string }>>([]),
notes: signal<Array<{ id: string; content: string }>>([]),
nextSteps: signal<Array<{ id: string; description: string; completed: boolean }>>([]),
activeFiles: signal<Array<{ path: string; status: string }>>([]),
density: computed(() => {
const p = hudState.contextPercent.value
if (p < 70) return 'full' as const
if (p < 85) return 'compact' as const
return 'minimal' as const
}),
}))
// --- Component Layer (computed signals producing UElements) ---
function TaskSection() {
return computed(() => {
const task = hudState.task.value
const density = hudState.density.value
if (!task) return null
if (density === 'minimal') {
return h('p', null, task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, task.description),
)
})
}
function ContextBar() {
return computed(() => {
const percent = hudState.contextPercent.value
const status = hudState.contextStatus.value
const density = hudState.density.value
if (density === 'minimal') {
return h('p', null, `${Math.round(percent)}% ${status === 'red' ? '⚠' : ''}`)
}
return h('div', null,
h('h2', null, 'Context'),
h('p', null, `${Math.round(percent)}% used (${status})`),
)
})
}
function DecisionsList() {
return computed(() => {
const density = hudState.density.value
const decisions = hudState.decisions.value
if (density === 'minimal') return null
if (density === 'compact') {
return h('p', null, `Decisions (${decisions.length})`)
}
return h('div', null,
h('h2', null, 'Decisions'),
h('ul', null, ...decisions.map(d => h('li', null, d.summary))),
)
})
}
// --- Root Render (computed signal) ---
const hudRender = computed(() => {
const task = TaskSection()
const ctx = ContextBar()
const decisions = DecisionsList()
const children = [task, ctx, decisions].filter(Boolean)
return createRoot('hud', ...children)
})
// --- Markdown Output (effect, or manual) ---
// Option A: Reactive — auto-renders when any signal changes
const dispose = effect(() => {
const tree = hudRender.value // only recomputes what changed
const markdown = renderMarkdown(tree)
sendToSystemPrompt(markdown)
})
// Option B: Manual — called when we know we need a new render
function renderHUD() {
return hudRender.value // triggers only necessary computed() evaluations
}
// --- Updates are batched ---
function updateHUD(event: HudEvent) {
batch(() => {
switch (event.type) {
case 'task.set':
hudState.task.value = { description: event.description, updatedAt: Date.now() }
break
case 'context.snapshot':
hudState.contextPercent.value = event.percent
break
case 'decision.record':
hudState.decisions.value = [...hudState.decisions.value, event.decision]
break
}
})
// Only one effect run after the batch, even if multiple signals changed
}
```
### What This Buys Us
1. **Skip unchanged components**: If `task` signal hasn't changed, `TaskSection()` returns the cached UElement without calling the component function.
2. **Density changes propagate efficiently**: Changing `contextPercent` from 67% to 68% (green→green) doesn't trigger `ContextBar` re-render because the computed `contextStatus` still returns 'green'. From 69%→71% (green→yellow) DOES trigger `ContextBar` and anything that reads `density`.
3. **Batched updates**: A multi-field update (task + decisions + context) in one `batch()` only triggers one render pass.
4. **No global dirty tracking needed**: The existing POC's `dirty` bitmask on graphology nodes is a manual approximation. Signals do this automatically and correctly.
5. **Separation of state and rendering**: State is in `signal()` values. Rendering is `computed()` derivations. Effects wire rendering to side effects (injecting into system prompt).
6. **`peek()` for untracked reads**: During transformation (walking the tree to produce markdown), we can `peek()` at values without subscribing — useful when the transform doesn't need to be reactive.
7. **Disposal**: `hudState[Symbol.dispose]()` cleans up all effects created within the model. Clean teardown when a session ends.
### What This Does NOT Buy Us (Yet)
1. **Structural changes still need full walk**: Adding/removing a list item still requires re-walking that part of the tree. Signals help with value changes, not structural changes. For structural changes, we'd need a keyed list diff (like React's reconciliation).
2. **The transform (UJSX → mdast → markdown) is always full**: Even if only one component re-computes, the entire tree still gets walked to produce markdown. To skip unchanged subtrees in the transform, we'd need to memoize transform results per node (using `computed` at the transform level too).
3. **No framework dependency yet**: We're using signals-core standalone, not via Preact/React integration. The Preact adapter (`@preact/signals`) adds `useSignal()` hook, React adapter (`@preact/signals-react`) adds `useSignalValue()`. We don't need either — we use `signal`/`computed` directly.
### Computed Transforms (Memoization at Every Level)
The real power: make the transform pipeline itself reactive:
```typescript
// State signal
const taskText = signal("Implement auth module")
// Component produces a UElement (computed — cached)
const taskElement = computed(() =>
h('h2', null, taskText.value)
)
// Transform produces mdast (computed — cached)
const taskMdast = computed(() =>
transformToMdast(taskElement.value, { direction: 'ujsx->mdast' })
)
// Serialize to markdown (computed — cached)
const taskMarkdown = computed(() =>
toMarkdown(taskMdast.value)
)
// Only runs when taskText changes: taskElement → taskMdast → taskMarkdown
// If taskText hasn't changed, reading taskMarkdown.value returns cached string
```
This means the ENTIRE pipeline is memoized. If you change `contextPercent` from 67% to 68% (green→green, no density change), `taskMarkdown.value` returns the cached string instantly. Only `contextBarMarkdown.value` recomputes.
### The Final Composition
```typescript
// Each section is its own computed pipeline
const taskMd = computed(() => renderSection(TaskSection(), 'ujsx->mdast'))
const ctxMd = computed(() => renderSection(ContextBar(), 'ujsx->mdast'))
const decisionsMd = computed(() => renderSection(DecisionsList(), 'ujsx->mdast'))
// Root markdown is computed from sections
const fullMarkdown = computed(() =>
[taskMd.value, ctxMd.value, decisionsMd.value]
.filter(Boolean)
.join('\n\n')
)
// Only the sections whose signals changed recompute their markdown
// fullMarkdown recomputes its string join (cheap) but doesn't re-walk unchanged sections
```
### Integration with opencode Plugin
The `effect()` that injects into the system prompt would be:
```typescript
const dispose = effect(() => {
const markdown = fullMarkdown.value
// This runs whenever any upstream signal changes
// But only the changed computed() evaluations actually ran
systemPromptInjector.push(markdown)
})
```
Or more pragmatically, since opencode calls the plugin hook on every LLM turn:
```typescript
// In the plugin's system.transform hook:
"experimental.chat.system.transform": async (input, output) => {
// Just read .value — it's cached, no work if unchanged
const markdown = fullMarkdown.value
output.system.push(markdown)
}
```
This is the simplest integration: no effect, no subscription. Just read `.value` when asked. If nothing changed, it's O(1). If something changed, only the minimal recomputation happens.
### Signals + TypeBox Module Interaction
The signals hold runtime state. The TypeBox Module defines the schema for that state.
```typescript
import { Type, Static } from "@alkdev/typebox"
const HudStateModule = Type.Module({
Task: Type.Object({
description: Type.String(),
updatedAt: Type.Number(),
}),
Decision: Type.Object({
id: Type.String(),
summary: Type.String(),
details: Type.Optional(Type.String()),
}),
ContextInfo: Type.Object({
percent: Type.Number(),
status: Type.Union([Type.Literal('green'), Type.Literal('yellow'), Type.Literal('red')]),
model: Type.String(),
}),
})
// Import for validation
const Task = HudStateModule.Import("Task")
const Decision = HudStateModule.Import("Decision")
// Validate before setting signal values
function updateHUD(event: HudEvent) {
batch(() => {
if (event.type === 'task.set') {
if (Value.Check(Task, event.task)) {
hudState.task.value = event.task
}
}
// ...
})
}
```
The TypeBox Module validates incoming events. The signals hold the validated state. The `computed` components derive UElements from the state. The transform pipeline serializes to markdown.
### File Structure Addition
```
@alkdev/ujsx/
core/
schema.ts # TypeBox Module for UNode/UElement/URoot
h.ts # h(), createRoot(), Fragment factory
context.ts # Signal-based context (density, target)
signals.ts # createHudModel() — signals for HUD state
host/
config.ts # HostConfig interface (preserved)
markdown.ts # Markdown host (new)
transform/
registry.ts # Enhanced TransformRegistry with direction + schema
rule.ts # Rule type with TypeBox schema
...
```
The signals integration is an **addition** to the TypeBox-driven rewrite, not a replacement. The TypeBox Module defines the shape. The signals hold the runtime values. The computed signals produce the rendered output.

View File

@@ -0,0 +1,113 @@
# TypeBox Module Research: UJSX Schema Implications
## Key Finding: `Type.Module` IS the Type Registry
`Type.Module` creates a `TModule` object whose `$defs` property is a live `string → TSchema` map. This is exactly the "type registry" pattern needed for UJSX.
### Core Operations
```typescript
const UJSX = Type.Module({
Props: Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: Type.Unknown() }),
Element: Type.Object({
type: Type.String(),
props: Type.Ref("Props"),
children: Type.Array(Type.Ref("Node")),
}),
Node: Type.Union([Type.String(), Type.Number(), Type.Null(), Type.Ref("Element")]),
})
```
#### Reading schemas by name
```typescript
ValuePointer.Get(UJSX, "$defs/Props") // → TSchema with $id: "Props"
ValuePointer.Get(UJSX, "$defs/Element") // → TSchema with $id: "Element"
```
#### Adding schemas at runtime
```typescript
ValuePointer.Set(UJSX, "$defs/Heading", Type.Object({
type: Type.Literal("heading"),
depth: Type.Number(),
children: Type.Array(Type.Ref("Node")),
}))
// Now UJSX.$defs.Heading exists and can be referenced by Type.Ref("Heading")
```
#### Importing (resolves $ref, creates self-contained schema for validation)
```typescript
const Element = UJSX.Import("Element")
// Element = { [Kind]: 'Import', $defs: { Props: {...}, Element: {...}, Node: {...} }, $ref: "Element" }
Value.Check(Element, someData) // Works! All $refs resolved via inlined $defs
```
#### Static type inference
```typescript
type UE = Static<typeof Element>
// UE = { type: string; props: { id?: string; [k: string]: unknown }; children: UE[] }
```
### How `Import` Works (from source)
The `Import` method on `TModule` (module.ts:70-72):
```typescript
public Import<Key extends keyof ComputedModuleProperties>(key: Key, options?: SchemaOptions): TImport<ComputedModuleProperties, Key> {
const $defs = { ...this.$defs, [key]: CreateType(this.$defs[key], options) }
return CreateType({ [Kind]: 'Import', $defs, $ref: key }) as never
}
```
It creates a `TImport` schema with:
- `[Kind]: 'Import'` — uniquely identifies this as a module import
- `$defs` — ALL module definitions copied in (so $refs resolve)
- `$ref: key` — which definition to use as the root
### How `ComputeModuleProperties` Works (from compute.ts)
The constructor calls `ComputeModuleProperties` which walks every property and:
1. Resolves `Type.Ref("X")` → dereferences to the actual type at `moduleProperties[X]`
2. Recursively processes: `Type.Array(Type.Ref("Node"))``Type.Array(ResolvedNode)`
3. Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc.
4. Adds `$id` to each definition via `WithIdentifiers`
This means you can use `Type.Ref()` inside `Type.Module()` and they get resolved to direct references. The $ref JSON pointer stays in the output for JSON Schema compatibility, but TypeBox's internal resolution handles them.
### `TInferFromModuleKey` — Type-Level Inference
The `infer.ts` file provides `TInferFromModuleKey<ModuleProperties, Key>` which is the type-level path of resolving refs. This is what makes `Static<typeof ImportedElement>` produce a proper recursive type:
```typescript
type UE = Static<typeof Element>
// Resolves to:
// { type: string; props: { id?: string; [k: string]: unknown }; children: (string | number | null | UE)[] }
```
This is a **recursive type** inferred from the module. No `Type.Recursive` needed because the Module's type-level inference handles cycles through `TRef`.
### Function Types in Modules
`Type.Function([...params], returnType)` creates a JavaScript-extended schema type:
```typescript
ComponentFn: Type.Function([Type.Ref("Props")], Type.Ref("Node"))
```
This produces a JSON Schema with `{ type: "Function", parameters: [...], returns: {...} }` which is NOT standard JSON Schema but IS valid TypeBox. `Value.Check` validates that the value is a function at runtime. This means we can represent component functions in the schema.
### Implications for UJSX v2
1. **No separate type registry needed.** `TModule` IS the registry. It's a map of `string → TSchema` with computed resolution and `$id` assignment built in.
2. **Schemas ARE types AND ARE tool parameter schemas.** One definition creates the TypeScript type, the runtime validator, and the JSON Schema for tool definitions.
3. **Bi-directional transforms can be schema-driven.** Transform rules can match on `Value.Check(ruleSchema, node)` instead of string tag matching.
4. **Node definitions can be added at runtime.** `ValuePointer.Set(module, "$defs/CustomNode", ...)` or simply defining new entries in the Module. This means plugins can extend the IR.
5. **Function components are first-class in the schema.** `Type.Function([Type.Ref("Props")], Type.Ref("Node"))` validates that `type` is a function at runtime. For serialization, we'd resolve function components to string tags before going to mdast/other targets.
6. **`Import` creates self-contained schemas.** Perfect for sending to tool definitions or validation contexts where the full module isn't available.
7. **The `ComponentFn` pattern means we can separate runtime and serializable representations.** At runtime, `type` can be a function. Before serialization (JSON, mdast), we resolve function components to string identifiers and emit `{ type: "Component", componentId: "TaskSection", ... }`.

View File

@@ -0,0 +1,101 @@
# TypeBox Module & ValuePointer: Core Patterns for UJSX
Source: `/workspace/@alkdev/typebox` (npm: `@alkdev/typebox`)
## TModule: The Schema Registry
`Type.Module()` creates a `TModule` whose `$defs` property is a live `string → TSchema` map. This IS the type registry UJSX needs — no separate registry required.
### Core Operations
```typescript
const UJSX = Type.Module({
Props: Type.Object({ id: Type.Optional(Type.String()) }, { additionalProperties: Type.Unknown() }),
Element: Type.Object({
type: Type.String(),
props: Type.Ref("Props"),
children: Type.Array(Type.Ref("Node")),
}),
Node: Type.Union([Type.String(), Type.Number(), Type.Null(), Type.Ref("Element")]),
})
```
### Read schemas by name
```typescript
import { ValuePointer } from "@alkdev/typebox/value";
ValuePointer.Get(UJSX, "$defs/Props") // TSchema with $id: "Props"
ValuePointer.Get(UJSX, "$defs/Element") // TSchema with $id: "Element"
```
### Add schemas at runtime
```typescript
ValuePointer.Set(UJSX, "$defs/Heading", Type.Object({
type: Type.Literal("heading"),
depth: Type.Number(),
children: Type.Array(Type.Ref("Node")),
}))
// Now UJSX.$defs.Heading exists and Type.Ref("Heading") resolves
```
### Import: Resolved schemas for validation
```typescript
const Element = UJSX.Import("Element")
// Creates TImport with: { [Kind]: 'Import', $defs: { all module defs inlined }, $ref: "Element" }
Value.Check(Element, someData) // Works! All $refs resolved via inlined $defs
```
### Static type inference
```typescript
type UE = Static<typeof Element>
// UE = { type: string; props: { id?: string; [k: string]: unknown }; children: UE[] }
```
The Module's type-level inference handles cycles through `TRef` — no `Type.Recursive` needed.
## How Import Works (from source)
Module source: `/workspace/@alkdev/typebox/src/type/module/index.ts`
```typescript
public Import<Key>(key: Key, options?: SchemaOptions): TImport {
const $defs = { ...this.$defs, [key]: CreateType(this.$defs[key], options) }
return CreateType({ [Kind]: 'Import', $defs, $ref: key })
}
```
Creates a `TImport` schema with:
- `[Kind]: 'Import'` — uniquely identifies as module import
- `$defs` — ALL module definitions copied in (so $refs resolve)
- `$ref: key` — which definition to use as root
## ComputeModuleProperties
Called during Module construction. Walks every property and:
1. Resolves `Type.Ref("X")` → dereferences to actual type at `moduleProperties[X]`
2. Recursively processes: `Type.Array(Type.Ref("Node"))``Type.Array(ResolvedNode)`
3. Handles all TypeBox combinators: Object, Union, Intersect, Function, Record, etc.
4. Adds `$id` to each definition via `WithIdentifiers`
## Function Types in Modules
```typescript
Type.Function([Type.Ref("Props")], Type.Ref("Node"))
```
Creates a JavaScript-extended schema type `{ type: "Function", parameters: [...], returns: {...} }`. Not standard JSON Schema but valid TypeBox. `Value.Check` validates that the value is a function at runtime.
This means component functions CAN be represented in the schema. For serialization, resolve function components to string identifiers before going to mdast/other targets.
## Implications for UJSX v2
1. **No separate type registry needed**`TModule` IS the registry
2. **Schemas ARE types AND tool parameter schemas** — one definition, triple duty
3. **Bi-directional transforms can be schema-driven**`Value.Check(ruleSchema, node)` instead of string matching
4. **Node definitions can be added at runtime** — plugins can extend the IR via `ValuePointer.Set()`
5. **`Import` creates self-contained schemas** — perfect for tool definitions or validation contexts
6. **Function components are first-class in schema** — separate runtime vs serializable representations
7. **No `Type.Recursive` needed in Module** — cycles handle through `TRef` + type-level inference

View File

@@ -0,0 +1,554 @@
# UJSX v2: TypeBox-Schema-Driven Universal JSX IR
## What Exists: The Current POC
The `/workspace/aui` codebase has two packages:
- **`@ade/ujsx`** — Core universal JSX IR: `h()` factory, `UniversalElement`/`RootElement` types, `HostConfig` reconciler, `TransformRegistry`, `StreamingTransformer`
- **`@ade/aui`** — Markdown consumer: `jsxToMdast` transform rules, `renderMarkdown()` via mdast-util-to-markdown + GFM
The architecture is already right at a high level:
```
JSX (h calls) → UniversalNode tree → Host/Transform → Output
```
## What's Wrong
1. **No actual JSX syntax** — Uses `h()` calls instead of `<Component />` syntax. Needs a JSX transform config (like Hono's `tsconfig.json` jsxFactory) so you can write real JSX.
2. **HTML baggage**`UniversalProps` has `onClick`, `onSubmit`, `onInput`, `onChange`, `className`, `aria-*`, `data-*` — all HTML-specific. For a universal IR targeting markdown (and eventually other formats), these are noise.
3. **No schema-driven types** — Types are hand-written interfaces. The whole point of using TypeBox is that the IR *is* a schema — nodes can be validated at runtime, composed generically, and converted to JSON Schema for tool definitions.
4. **Non-deterministic IDs**`genId()` uses `Math.random()`. Needs to be injectable/deterministic.
5. **Separate props + children**`UniversalElement` has both `props` and `children`. This is the React/HTML model where `children` is a special prop. For a universal IR, children is just a prop like any other. Simplify.
6. **No Context system** — React's Context/Provider pattern is essential for passing density/target/host config down the tree without prop drilling.
7. **Fragment not integrated** — Exists but reconciler and transforms don't handle it specially.
8. **Missing markdown nodes** — No blockquote, link, image, hr, table support.
## The Rewrite: TypeBox-Schema-Driven UJSX
### Core Principle
**The UniversalElement IS a TypeBox schema at runtime.** Every node in the tree is both a valid TypeScript value AND a valid JSON Schema value. This means:
- Runtime validation: `Value.Check(UElementSchema, node)`
- Tool schema generation: Convert any component's props schema to JSON Schema for agent tool definitions
- Generic composition: `Type.Intersect()`, `Type.Partial()`, `Type.Omit()` work on node schemas
- Bi-directional transforms: Rules match on schema shape, not string tags
### TypeBox AST Schema
```typescript
import { Type, Static, TSchema } from "@alkdev/typebox"
// The recursive node schema — the heart of the system
const UNodeSchema = Type.Recursive((This) =>
Type.Union([
Type.Null(),
Type.String(),
Type.Number(),
Type.Boolean(),
Type.Object({
type: Type.Union([Type.String(), Type.Function([Type.Object({}), This], This)]),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(This),
}),
]),
{ $id: "UNode" }
)
type UNode = Static<typeof UNodeSchema>
// Refined: element is the object branch
const UElementSchema = Type.Object({
type: Type.Union([Type.String(), Type.Function([Type.Object({}), UNodeSchema], UNodeSchema)]),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(UNodeSchema),
})
type UElement = Static<typeof UElementSchema>
```
Wait — `Type.Function` in TypeBox creates a JavaScript extended schema type, not a JSON Schema one. For the IR's purposes, we want function components to be part of the *runtime* TypeScript type but NOT part of the serializable schema. The schema (what gets validated and what tools see) should only have string types.
Better approach: separate the runtime type from the serializable schema:
```typescript
// Serializable schema — what validation and tool definitions see
const USerializableElementSchema = Type.Recursive((This) =>
Type.Object({
type: Type.String(), // Always a string after component resolution
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Union([
Type.String(),
Type.Number(),
Type.Null(),
This,
])),
}),
{ $id: "UElement" }
)
// Runtime TypeScript type — includes function components
type UType = string | ComponentFn
type UNode = UElement | string | number | null
type UElement = {
type: UType
props: Record<string, unknown>
children: UNode[]
}
```
Actually, let me think about this differently. The key insight from the user's message is **bi-directional transforms**. The current `TransformRegistry` goes one direction (UJSX → mdast). The universal IR concept means rules should go both ways:
- UJSX → mdast (for markdown rendering)
- mdast → UJSX (for parsing markdown back into the IR)
- UJSX → hast (for HTML rendering)
- hast → UJSX (for parsing HTML)
- Eventually: UJSX → Graphology, UJSX → terminal ANSI, etc.
This is where TypeBox schemas really shine: **each node type's schema IS the match criterion**. Instead of string-based matching (`n.type === 'h1'`), rules match on schema shape:
```typescript
// Current: string-based matching
jsxToMdast.register({
name: 'heading',
match: (n) => isUniversalElement(n) && typeof n.type === 'string' && /^h[1-6]$/.test(n.type),
transform: ...
})
// TypeBox-driven: schema-based matching
const HeadingSchema = Type.Object({
type: Type.String({ pattern: '^h[1-6]$' }),
props: Type.Object({ level: Type.Number().optional() }),
children: Type.Array(UNodeSchema),
})
rule({
name: 'heading',
schema: HeadingSchema,
direction: 'ujsx->mdast',
transform: (node, ctx, next) => ({
type: 'heading',
depth: Number(node.type.slice(1)),
children: transformChildren(node, ctx, next),
}),
})
```
This is more verbose but gives us:
1. `Value.Check(HeadingSchema, node)` — validate before transforming
2. The schema can be registered as a tool parameter schema
3. Bi-directional: a `mdast->ujsx` rule for heading uses the same schema
4. `Type.Intersect()` to compose schemas for compound elements
### Simplified Element Model
Remove HTML-specific stuff. The `props` becomes a plain `Record<string, unknown>` with no HTML-centric defaults. Event handlers (`onClick`, etc.) and HTML attributes (`className`, `aria-*`) are gone from the core — they belong in an HTML host extension, not the universal IR.
```typescript
// core/schema.ts
import { Type, Static } from "@alkdev/typebox"
// Primitives
const UPrimitive = Type.Union([Type.String(), Type.Number(), Type.Null()])
// Element — the universal IR node
const UElement = Type.Recursive((This) =>
Type.Object({
type: Type.String(),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Union([UPrimitive, This])),
}, { $id: "UElement" }),
)
// Root — session/frame container
const URoot = Type.Object({
type: Type.Literal("root"),
props: Type.Object({
id: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
}),
children: Type.Array(Type.Union([UPrimitive, UElement])),
}, { $id: "URoot" })
// Node = everything a position in the tree can hold
const UNode = Type.Union([UPrimitive, UElement, URoot], { $id: "UNode" })
type UElement = Static<typeof UElement>
type URoot = Static<typeof URoot>
type UNode = Static<typeof UNode>
```
### The `h()` Factory (Cleaned Up)
```typescript
// core/h.ts
import type { UNode, UElement, URoot } from './schema.ts'
let _idCounter = 0
export function h(
type: string,
props?: Record<string, unknown> | null,
...children: UNode[]
): UElement {
return {
type,
props: props ?? {},
children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
}
}
export function createRoot(id?: string, ...children: UNode[]): URoot {
return {
type: 'root',
props: { id },
children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[],
}
}
// Alias for JSX transform compatibility
export const jsx = h
export const jsxs = h
export const jsxDEV = h
// Fragment: children pass-through
export function Fragment(props: { children?: UNode[] }): UNode[] {
return (props.children ?? []).flat(Infinity).filter(c => c != null && c !== false) as UNode[]
}
```
Key changes from current:
- **No metadata injection** — `timestamp` and `id` on every element was overhead without clear use. If needed, it belongs in the host, not the IR.
- **No `className`/event handlers** — Universal props, not HTML props.
- **Deterministic by default** — No `Math.random()`.
### JSX Transform Config
To support actual JSX syntax (`<TaskSection task={state.task} />`), you need a `tsconfig.json` or `deno.json` with:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ade/ujsx"
}
}
```
And an `jsx-runtime` module that exports `jsx`, `jsxs`, `jsxDEV`, `Fragment`. This is what Hono does (`hono/jsx/jsx-runtime`).
### Host System (Preserved, Simplified)
The `HostConfig` interface is good. Keep it but remove the HTML-specific assumptions:
```typescript
// host/config.ts
export interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string
createRootContext(container: unknown, options?: Record<string, unknown>): RootCtx
finalizeRoot?(ctx: RootCtx): void
createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void
}
```
No changes needed. The host is host-agnostic already. The graphology host stays. A markdown host can be added:
```typescript
// host/markdown.ts
export function createMarkdownHost(): HostConfig<string, MdastNode, MdastRoot> {
// Maps universal tags to mdast node creation
// Uses the same rules as the TransformRegistry but via HostConfig
}
```
### TypeBox-Driven Transform Rules
The key evolution of the `TransformRegistry`. Rules are now schema-aware and bi-directional:
```typescript
// transform/rule.ts
import { Type, TSchema, Value } from "@alkdev/typebox"
export type Direction = 'ujsx->mdast' | 'mdast->ujsx' | 'ujsx->hast' | 'hast->ujsx'
export interface TransformRule<TInput, TOutput, TAncestor> {
name: string
direction: Direction
schema: TSchema // The TypeBox schema this rule matches against
match: (node: TInput) => boolean // Runtime match (can use schema or custom logic)
transform: (node: TInput, ctx: TransformContext<TAncestor>, next: TransformFn<TInput, TOutput, TAncestor>) => TOutput
priority?: number
}
export class TransformRegistry<TInput, TOutput, TAncestor> {
private rules: TransformRule<TInput, TOutput, TAncestor>[] = []
register(rule: TransformRule<TInput, TOutput, TAncestor>) {
this.rules.push(rule)
this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
}
transform(node: TInput, ctx: TransformContext<TAncestor>, direction?: Direction): TOutput {
const rule = direction
? this.rules.find(r => r.direction === direction && r.match(node))
: this.rules.find(r => r.match(node))
if (!rule) throw new Error(`No rule for node`)
return rule.transform(node, ctx, (n, c) => this.transform(n, c, direction))
}
}
```
With schema-based matching, we can also do:
```typescript
// Validate a node against a rule's schema before transforming
function matchesSchema(schema: TSchema, node: unknown): boolean {
return Value.Check(schema, node)
}
```
### Bi-Directional Markdown Rules
```typescript
// markdown/rules.ts
import { Type } from "@alkdev/typebox"
const HeadingUSchema = Type.Object({
type: Type.String({ pattern: '^h[1-6]$' }),
props: Type.Record(Type.String(), Type.Any()),
children: Type.Array(Type.Any()),
})
const HeadingMdastSchema = Type.Object({
type: Type.Literal('heading'),
depth: Type.Number(),
children: Type.Array(Type.Any()),
})
// UJSX → mdast
registry.register({
name: 'heading-ujsx-to-mdast',
direction: 'ujsx->mdast',
schema: HeadingUSchema,
match: (n) => isUElement(n) && /^h[1-6]$/.test(String(n.type)),
transform: (n, ctx, next) => ({
type: 'heading',
depth: Number(String(n.type).slice(1)),
children: transformChildren(n, ctx, next),
}),
})
// mdast → UJSX
registry.register({
name: 'heading-mdast-to-ujsx',
direction: 'mdast->ujsx',
schema: HeadingMdastSchema,
match: (n) => n.type === 'heading',
transform: (n, ctx, next) => ({
type: `h${n.depth}`,
props: {},
children: (n.children || []).map((c, i) => next(c, childCtx(n, ctx, i))),
}),
})
```
This is the bi-directional concept. Same registry, same `transform()` call, different `direction` parameter. The schemas provide the contract for each direction.
### Component Schema = Tool Schema
The real payoff of TypeBox-driven schemas: any component's props schema is automatically a tool parameter schema.
```typescript
// A HUD component defined with TypeBox
const TaskSectionProps = Type.Object({
task: Type.Object({
description: Type.String(),
updatedAt: Type.Number(),
}),
density: Type.Union([Type.Literal('full'), Type.Literal('compact'), Type.Literal('minimal')]),
})
// The component
function TaskSection(props: Static<typeof TaskSectionProps>): UElement {
if (props.density === 'minimal') {
return h('p', null, props.task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, props.task.description),
)
}
// TaskSectionProps IS the schema for an agent tool that sets the task:
// hud({ tool: "task.set", args: { description: "...", updatedAt: Date.now() } })
// The tool's inputSchema = TaskSectionProps
```
No duplication. The component's type IS the tool's schema. `Value.Check(TaskSectionProps, input)` validates both.
### Context System
Needed for passing density/target config down the tree without prop drilling:
```typescript
// core/context.ts
export interface ContextValue {
density: 'full' | 'compact' | 'minimal'
target: string // 'markdown' | 'graph' | 'html' | etc.
metadata: Record<string, unknown>
}
// Simple context implementation (no React dependency)
type ContextListener = (value: ContextValue) => void
export class Context {
private value: ContextValue
private listeners: Set<ContextListener> = new Set()
constructor(initial: ContextValue) {
this.value = initial
}
get(): ContextValue { return this.value }
set(partial: Partial<ContextValue>) {
this.value = { ...this.value, ...partial }
for (const fn of this.listeners) fn(this.value)
}
subscribe(fn: ContextListener): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
}
```
This is lightweight and doesn't need React. Components receive context via the `host` or `transform` path.
### File Structure (Proposed Rewrite)
```
ujsx/
mod.ts # Re-exports
deno.json # JSX transform config
core/
schema.ts # TypeBox schemas for UNode, UElement, URoot
h.ts # h() factory, createRoot(), Fragment
context.ts # Context system for density/target
jsx-runtime.ts # JSX transform runtime exports
host/
config.ts # HostConfig interface + createRoot reconciler (preserved)
graphology.ts # Graphology host (preserved, fix commented-out append)
markdown.ts # NEW: Markdown host using HostConfig pattern
transform/
registry.ts # Enhanced TransformRegistry with direction + schema
rule.ts # TransformRule type with TSchema
streaming/
transformer.ts # Chunk-based async transformer (preserved)
aui/
mod.ts
deno.json
markdown/
render.ts # renderMarkdown() (preserved, uses new registry)
rules.ts # Bi-directional rules with TypeBox schemas
types.ts # mdast type re-exports
components/ # NEW: Pre-built markdown-targeted components
container.ts # <Container>
context-bar.ts # <ContextBar> with density awareness
heading.ts # <Heading level={1-6}>
list.ts # <List>, <ListItem>, <TaskList>
code.ts # <Code>, <CodeBlock>
blockquote.ts # NEW: <Blockquote>
link.ts # NEW: <Link>
table.ts # NEW: <Table>
hr.ts # NEW: <Divider>
hud/ # NEW: HUD-specific components
HUD.tsx # Root HUD component
TaskSection.tsx # Task display
DecisionsList.tsx # Key decisions
NotesSection.tsx # Scratchpad
NextSteps.tsx # Plan/next steps
ActiveFiles.tsx # Currently active files
WarningBanner.tsx # Context pressure warning
```
### tsconfig for JSX Syntax
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ade/ujsx"
}
}
```
With this, you write:
```tsx
import { h } from "@ade/ujsx"
function TaskSection({ task, density }: TaskSectionProps) {
if (density === 'minimal') {
return <p>{task.description}</p>
}
return (
<div>
<h2>Task</h2>
<p>{task.description}</p>
</div>
)
}
```
Instead of:
```typescript
function TaskSection({ task, density }: TaskSectionProps) {
if (density === 'minimal') {
return h('p', null, task.description)
}
return h('div', null,
h('h2', null, 'Task'),
h('p', null, task.description),
)
}
```
### Migration Steps
1. **Replace types.ts with schema.ts** — TypeBox schemas for UNode/UElement/URoot. Export both schemas and `Static<>` types.
2. **Clean up h.ts** — Remove metadata injection, HTML-specific props, `Math.random()`. Add `jsx-runtime.ts` export.
3. **Add deno.json jsxImportSource** — Enable JSX syntax.
4. **Strip UniversalProps** — Replace with plain `Record<string, unknown>`. No `onClick`, `className`, etc.
5. **Upgrade TransformRegistry** — Add `direction` and `schema` fields to rules.
6. **Add missing markdown rules** — blockquote, link, image, hr, table.
7. **Add Context** — Simple context system for density/target.
8. **Fix graphology host** — Uncomment append logic, replace `JSON.stringify` diffing.
9. **Add Markdown host**`createMarkdownHost()` using HostConfig.
10. **Add bi-directional rules** — mdast → ujsx direction for each element.
11. **Build HUD components** — The `aui/hud/` directory with density-aware components.
### What This Enables for the Agent HUD
With the cleaned-up UJSX:
- Components ARE schemas — any HUD component's props schema is automatically a tool parameter schema
- Bi-directional transforms mean we can parse markdown INTO the IR (parse existing agent notes, markdown files, AGENTS.md)
- The host system means the same HUD component tree could render to markdown for LLM consumption OR to a graph for visualization OR to HTML for a web dashboard
- TypeBox validation means we can validate HUD events against component schemas before accepting them
- The Context system carries density/target through the component tree — clean and no prop drilling

File diff suppressed because it is too large Load Diff