import research docs from prior conversation and scattered sources
This commit is contained in:
52
docs/research/README.md
Normal file
52
docs/research/README.md
Normal 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`.
|
||||
675
docs/research/agent-hud-architecture.md
Normal file
675
docs/research/agent-hud-architecture.md
Normal 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).
|
||||
1077
docs/research/hono-jsx-ssr-llm-hud.md
Normal file
1077
docs/research/hono-jsx-ssr-llm-hud.md
Normal file
File diff suppressed because it is too large
Load Diff
119
docs/research/prior-poc-source-reference.md
Normal file
119
docs/research/prior-poc-source-reference.md
Normal 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.
|
||||
372
docs/research/signals-ujsx-reactive-pipeline.md
Normal file
372
docs/research/signals-ujsx-reactive-pipeline.md
Normal 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.
|
||||
113
docs/research/typebox-module-type-registry.md
Normal file
113
docs/research/typebox-module-type-registry.md
Normal 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", ... }`.
|
||||
101
docs/research/typebox-module-valuepointer.md
Normal file
101
docs/research/typebox-module-valuepointer.md
Normal 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
|
||||
554
docs/research/ujsx-v2-typebox-rewrite.md
Normal file
554
docs/research/ujsx-v2-typebox-rewrite.md
Normal 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
|
||||
1211
docs/research/unist-ecosystem-jsx-to-markdown.md
Normal file
1211
docs/research/unist-ecosystem-jsx-to-markdown.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user