Files
hermes-agent/apps/desktop/src/lib/transcript-directives.ts
T
Brooklyn Nicholson 59b1c40cdf feat(desktop): plugins can render inline components in assistant messages via ::name{...} directives
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.

Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
2026-08-17 15:12:53 -05:00

83 lines
3.0 KiB
TypeScript

import type { ReactNode } from 'react'
/**
* TRANSCRIPT DIRECTIVES — the transcript as a contribution area.
*
* A plugin registers a named directive; the model addresses it by emitting a
* paragraph of the form `::name{key="value"}` and that leaf renders as the
* plugin's component, inline in the assistant message. This is the deliberate
* counterpart to artifact promotion: artifacts are heuristic (substantial
* fences get promoted whether or not the model asked), directives are
* addressed (nothing renders unless a plugin claimed the name).
*
* The parse is deliberately narrow — a directive must be the entire
* paragraph, so it can never hijack mid-prose text, and an unclaimed or
* malformed directive falls back to the plain paragraph it always was.
* Attributes are untrusted model output: plugins validate their own fields.
*/
export const TRANSCRIPT_DIRECTIVE_AREA = 'transcript.directives'
/** Props handed to a directive contribution's `render`. */
export interface TranscriptDirectiveProps {
/** Parsed, untrusted attributes (e.g. `{ file: 'demo.html' }`). */
attrs: Readonly<Record<string, string>>
/** Original directive source text (diagnostics / fallback rendering). */
source: string
/** True while the surrounding message is still streaming. */
streaming: boolean
}
/** Payload of a `transcript.directives` contribution's `data`. */
export interface TranscriptDirectiveContribution {
/** The name the model addresses: `::<name>{...}`. Lowercase, `[a-z0-9-]`,
* unique across plugins — first registration wins on collision. */
name: string
/** Renders the directive leaf. Mounted inside the contribution error
* boundary, so a throw degrades to an inline error, not a dead message. */
render: (props: TranscriptDirectiveProps) => ReactNode
}
export interface ParsedTranscriptDirective {
name: string
attrs: Record<string, string>
source: string
}
// The whole paragraph, nothing else on the line: `::name` or `::name{...}`.
// Length caps bound the attr scan on adversarial input.
const DIRECTIVE_RE = /^::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?$/
// `key="value"` pairs; single quotes accepted for model sloppiness.
const ATTR_RE = /([a-z][\w-]{0,63})=(?:"([^"]*)"|'([^']*)')/gi
/**
* Parse a paragraph as a transcript directive. Returns null unless the ENTIRE
* trimmed text is one directive — prose containing `::` stays prose.
* Pure and synchronous — safe to call during render.
*/
export function parseTranscriptDirective(text: string): ParsedTranscriptDirective | null {
const trimmed = text.trim()
// Cheap reject before the regex: directives are short single lines.
if (!trimmed.startsWith('::') || trimmed.length > 1200 || trimmed.includes('\n')) {
return null
}
const match = DIRECTIVE_RE.exec(trimmed)
if (!match) {
return null
}
const attrs: Record<string, string> = {}
if (match[2]) {
for (const pair of match[2].matchAll(ATTR_RE)) {
attrs[pair[1].toLowerCase()] = pair[2] ?? pair[3] ?? ''
}
}
return { name: match[1], attrs, source: trimmed }
}