dsh-generative-ui 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What the model is told about a card that will not render.
3
+ *
4
+ * Split in two, deliberately, because the two halves want opposite lifetimes:
5
+ *
6
+ * - **The detail is state.** It goes out as a runtime-context snapshot, re-evaluated on every
7
+ * assembly and superseded by the next one. A card that gets fixed simply stops being mentioned.
8
+ * As a chat message it was the opposite — permanent, and still there three turns after the card
9
+ * started working.
10
+ * - **The nudge is an event.** One short line through `followup`, whose only job is to open a turn
11
+ * so the model looks at the detail now rather than whenever the user next types.
12
+ *
13
+ * Neither is a user-role message any more, and that removes the paragraph the old body had to
14
+ * carry: *"This was sent by the renderer, not by the user — nobody typed it, so do not apologise
15
+ * or address it as a request."* That existed because the report arrived wearing the user's face.
16
+ * A `kind: "plugin"` source does not, so the model is told what happened and nothing else — the
17
+ * disclaimer cost more tokens than the error it was wrapped around.
18
+ */
19
+ /** A card failure as the browser half reported it. */
20
+ export type CardFailure = {
21
+ readonly message: string;
22
+ readonly phase: string;
23
+ };
24
+ /** The runtime-context section name. Stable: it is how a later snapshot supersedes an earlier one. */
25
+ export declare const CARD_FAILURE_CONTEXT = "ui4a:card-failure";
26
+ /**
27
+ * Ordered after the plugin's own guidance so the model reads how cards work before it reads that
28
+ * one is broken.
29
+ */
30
+ export declare const CARD_FAILURE_CONTEXT_ORDER = 250;
31
+ /**
32
+ * The detail, as the model sees it.
33
+ *
34
+ * It says it is current state rather than an event on purpose: the same text is re-delivered on
35
+ * every step while the card stays broken, and a model that reads it as a fresh report tries to
36
+ * fix the card again on each one.
37
+ */
38
+ export declare const failureText: (failure: CardFailure) => string;
39
+ /**
40
+ * The one line that opens a turn. The detail is already in context; this only asks for attention.
41
+ *
42
+ * The second sentence is not padding — it closes a race the split design makes possible. The nudge
43
+ * is an event and cannot be recalled once queued; the detail is state and is re-evaluated when the
44
+ * turn assembles. A card that recovers in between (a retry succeeding is the common way) clears
45
+ * the state, so the turn opens with "go read the runtime context" pointing at a context that says
46
+ * nothing about a card.
47
+ *
48
+ * Seen in a real session: two notices, and the SECOND one had no card-failure section anywhere in
49
+ * the turn that followed it. Without a defined outcome for that, the model has been told something
50
+ * is broken, cannot find it, and goes looking — in that session it rewrote a card that was fine.
51
+ */
52
+ export declare const WAKE_TEXT = "A ui4a card you wrote is not rendering \u2014 the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
53
+ /** Shown as the context row's label in the transcript, so a reader can see what fired without opening it. */
54
+ export declare const WAKE_SUMMARY = "ui4a card failed to render";
55
+ /**
56
+ * The current failing card per session.
57
+ *
58
+ * Per session rather than per card: the nudge exists to get one turn spent on the problem, and a
59
+ * reply that breaks three cards does not want three turns. The newest failure wins because it is
60
+ * the one the model just wrote.
61
+ */
62
+ export declare class CardFailures {
63
+ private readonly bySession;
64
+ /**
65
+ * Record a failure.
66
+ *
67
+ * @returns whether the session went from HEALTHY to failing — the caller wakes the model only
68
+ * then. Dedup lives here rather than in the browser half because that half is reloaded by every
69
+ * navigation, and a dedup set that resets on reload wakes the model again for a card it already
70
+ * knows about.
71
+ *
72
+ * **Not "is this a different message".** That was the first version, and it is wrong for a
73
+ * nudge whose entire payload is *go read the context*: two different failures produce two
74
+ * BYTE-IDENTICAL notices, so a reader watching a long turn sees the same sentence queued twice
75
+ * and the model gets two turns pointed at one current-state snapshot. Measured on a real
76
+ * session — three genuinely different failures (`@react-three/fiber` unresolvable, `maps is not
77
+ * defined`, `@react-three/cannon` unresolvable) over eighty minutes, each "news" by the old
78
+ * test, and the third's notice was still sitting unsent in the composer beside the second.
79
+ *
80
+ * While a failure is already recorded the model is either about to look or has looked, and the
81
+ * detail it will read is re-evaluated every step anyway — so a changed message needs no second
82
+ * nudge. `clear()` on recovery is what re-arms this.
83
+ */
84
+ set(session: string, failure: CardFailure): boolean;
85
+ clear(session: string): void;
86
+ /** The context text for one session; empty contributes nothing to the assembly. */
87
+ text(session: string | undefined): string;
88
+ }
@@ -16,7 +16,7 @@ export type CanvasPanelProps = {
16
16
  /** A canvas that failed to compile; see `runtime/report-error.ts`. */
17
17
  onCardError?: (message: string, phase: string) => void;
18
18
  /** A canvas that painted; see `runtime/report-error.ts`. */
19
- onCardRendered?: () => void;
19
+ onCardRendered?: (restored: boolean) => void;
20
20
  };
21
21
  /**
22
22
  * Rewrites a canvas's relative sub-page imports into blob URLs before it is compiled.
@@ -13,7 +13,7 @@ export type CanvasHostOptions = {
13
13
  */
14
14
  onCardError?: (message: string, phase: string) => void;
15
15
  /** A canvas that painted; cancels a deferred error report the next frame made untrue. */
16
- onCardRendered?: () => void;
16
+ onCardRendered?: (restored: boolean) => void;
17
17
  };
18
18
  /**
19
19
  * A call whose arguments carry executable code rather than a file operation.
@@ -4,29 +4,23 @@ export type GenUISurfaceProps = {
4
4
  /** True while `code` is still a prefix, so partial frames get normalized before compiling. */
5
5
  streaming?: boolean;
6
6
  /**
7
- * Keep React state across recompiles. Right for a growing stream, where each frame is
8
- * the previous one plus more text. Wrong for a whole-file replacement: the renderer
9
- * decides reuse from the hook signature, so a rewrite that keeps the same hooks — an
10
- * edited canvas usually does — is silently dropped rather than rendered.
7
+ * Keep React state across recompiles, by rendering through partial-react's stable slot
8
+ * wrapper so hooks stay on the same fiber. Off, every recompile renders a freshly
9
+ * compiled function type, which React remounts a running timer restarts at zero.
10
+ *
11
+ * It does NOT risk dropping an edit: the renderer's two reuse branches only fire in
12
+ * `push` mode, so a whole-file replacement always renders.
11
13
  */
12
14
  preserveState?: boolean;
13
- /** Real compile diagnostics. Transient streaming frames are filtered out — see TRANSIENT below. */
15
+ /** Real compile diagnostics. A streaming frame never reaches this — see `errorAction`. */
14
16
  onError?: (error: Error, phase: "transform" | "compile" | "render") => void;
15
17
  /** Fires whenever a frame actually painted. Use it to clear a previously shown error. */
16
- onRendered?: () => void;
18
+ onRendered?: (restored: boolean) => void;
17
19
  className?: string;
18
20
  };
19
21
  /** Paired with `disposeCompiler`: the shared instance must go with the wasm behind it. */
20
22
  export declare const dropSharedCompiler: () => void;
21
23
  export declare const compiler: () => import("./compiler.ts").TsxCompiler;
22
- /**
23
- * Mid-stream frames legitimately fail: a prefix that has not reached `export default`
24
- * yet, or a half-written expression. partial-react treats these as transient and keeps
25
- * the last good frame, so surfacing them would just make the UI flash errors while the
26
- * model types. Only a failure that survives settling is the caller's business.
27
- */
28
- /** Exported for `test/transient.test.ts`: this decides whether the reader sees an error. */
29
- export declare const TRANSIENT: RegExp;
30
24
  /**
31
25
  * A dependency that failed to arrive, not code that is wrong. esm.sh cold-starts and the
32
26
  * network drops, and the symptom is identical to a broken component — a blank surface — so
@@ -41,15 +35,6 @@ export declare const TRANSIENT_LOAD: RegExp;
41
35
  * each of which sends the reader somewhere different when it is wrong. Exported for
42
36
  * `test/retry.test.ts`.
43
37
  */
44
- /**
45
- * Whether a mid-stream error is the stream not being finished yet.
46
- *
47
- * Both patterns come from the parse stages — `No default export found` is thrown inside
48
- * `importCompiledComponent` (compile), and an unexpected EOF is the transform rejecting a
49
- * prefix. A card whose own render throws a message that happens to match is a real error, so
50
- * the phase is part of the question rather than the message alone.
51
- */
52
- export declare const isUnfinishedFrame: (message: string, phase: string, streaming: boolean) => boolean;
53
38
  export declare const shouldRetry: (message: string, phase: string, streaming: boolean, attempts: number) => boolean;
54
39
  /**
55
40
  * What to do with a frame, given what the surface already holds.
@@ -95,6 +80,20 @@ export declare const deliver: (renderer: RendererCalls, delivery: Delivery) => b
95
80
  * code to get wrong than the re-probe costs.
96
81
  */
97
82
  export declare const importSignature: (code: string) => string;
83
+ /**
84
+ * Whether a failure suppressed during streaming still has to be told to someone.
85
+ *
86
+ * The hole this closes: `errorAction` ignores every streaming frame (a truncated one is not a
87
+ * broken card), and `deliveryFor` answers `nothing` when the settled frame is byte-identical to
88
+ * the last streamed one. Both are right on their own, and together they mean a card that really
89
+ * is broken recompiles never and reports never.
90
+ *
91
+ * Pure, and exported, because it is three conditions and each one is a distinct bug when wrong:
92
+ * without `!streaming` it fires mid-stream and undoes the fix it belongs to; without `stranded`
93
+ * it reports nothing; without the `reportedFor` guard a settled card re-rendered by every later
94
+ * frame of the transcript reports on each one.
95
+ */
96
+ export declare const reportStranded: (streaming: boolean, stranded: unknown, code: string, reportedFor: string) => boolean;
98
97
  /** Only what `deliver` touches — the real renderer has far more. */
99
98
  export type RendererCalls = {
100
99
  render: (code: string) => void;
@@ -110,12 +109,15 @@ export type RendererCalls = {
110
109
  * - `retry` a dependency failed to arrive, and busting the import URLs is the fix
111
110
  * - `report` tell the reader
112
111
  *
113
- * Only a SETTLED surface retries: while streaming, the next frame re-delivers on its own, and a
114
- * retry there would replace the growing buffer with a stale prefix. Compile phase only — a failed
115
- * dependency import is reported there (`importCompiledComponent` runs inside the compile `catch`,
116
- * `partial-react/src/runtime.ts:338`), whereas the same message from the RENDER phase is the
117
- * card's own `fetch` throwing inside its body, where re-importing changes nothing and costs three
118
- * retries and 2.4 seconds of blank surface before the reader is told anything.
112
+ * **A streaming frame is never reported, whatever it says.** This used to test the message
113
+ * against `TRANSIENT` (`No default export found`, unexpected EOF) and report anything else, on
114
+ * the theory that a truncated frame fails to parse. It does not have to: a cut that lands
115
+ * mid-identifier leaves valid syntax and throws at module evaluation instead. Measured on one
116
+ * real session five consecutive reports, five regenerations, and every final card was fine:
117
+ * `Mouse is not defined` from a card whose only such name is `MousePointer2`, `type is not
118
+ * defined` from `type ToolGroup =`, and `icon is not defined` from a card containing no `icon` at
119
+ * all. The frame, not the card, was broken. There is no message that distinguishes the two, so
120
+ * the phase does it: only a settled surface has anything worth saying about.
119
121
  */
120
122
  export declare const errorAction: (message: string, phase: string, streaming: boolean, attempts: number) => "ignore" | "retry" | "report";
121
123
  /**
@@ -133,15 +135,26 @@ export declare const errorAction: (message: string, phase: string, streaming: bo
133
135
  *
134
136
  * - **stale** — a later frame's probe won the race. Applying this one reverts the map to an
135
137
  * older import set, and the newer frame's packages go missing.
136
- * - **redeliver** — a settled surface has no next frame. `setImportMap` only stores; it
137
- * schedules nothing, so without a re-render the card stays blank for good.
138
- * - **store** while streaming, the very next frame applies the map. Re-delivering here instead
139
- * would replace the buffer with whatever prefix was current when the probe fired, truncating
140
- * the stream mid-flight.
138
+ * - **redeliver** — nothing else is going to apply the map. Always true of a settled surface,
139
+ * and true while streaming too when no frame has been delivered since the probe fired:
140
+ * `setImportMap` only stores, so that buffer stays compiled against a map without these
141
+ * entries and an unresolvable bare specifier kills the whole module graph, so the card is
142
+ * blank rather than wrong. Measured 2026-08-27 on a card importing `@radix-ui/react-tabs`:
143
+ * one delivery with `streaming: true` renders 0 characters **for good**, while the identical
144
+ * code with `streaming: false` renders. The error is swallowed on top of it — `errorAction`
145
+ * answers `ignore` while streaming, so the reader gets an empty card and the model gets no
146
+ * report. A cold probe is 1.0s and the import behind it another 2.4s, so the window this
147
+ * covers is seconds wide, not a frame.
148
+ * - **store** — a newer frame has already been delivered, and it will apply the map itself.
149
+ * Re-delivering here instead would replace the buffer with the prefix that was current when
150
+ * the probe fired, truncating the stream mid-flight.
151
+ *
152
+ * `redeliver` renders `deliveredRef.current`, which is read at SETTLE time — the newest buffer,
153
+ * not the captured one — so the append the next frame computes still lines up.
141
154
  *
142
155
  * The `delivered !== ""` part is not defensive: re-rendering an empty buffer clears the surface.
143
156
  */
144
- export declare const probeOutcome: (signature: string, current: string, streaming: boolean, delivered: string) => "stale" | "redeliver" | "store";
157
+ export declare const probeOutcome: (signature: string, current: string, streaming: boolean, delivered: string, probed: string) => "stale" | "redeliver" | "store";
145
158
  export declare const dispatchError: (action: "ignore" | "retry" | "report", effects: {
146
159
  attempts: () => number;
147
160
  setAttempts: (n: number) => void;
@@ -155,5 +168,22 @@ export declare const dispatchError: (action: "ignore" | "retry" | "report", effe
155
168
  * fresh, and appending a query to a `blob:` URL makes it unresolvable — which would break every
156
169
  * card rather than fixing one.
157
170
  */
171
+ /**
172
+ * The same import map with `bundle`/`external` dropped from every esm.sh entry.
173
+ *
174
+ * esm.sh serves two different builds and only one of them can fail: `?bundle` runs esbuild over
175
+ * the package's whole tree, and a version skew anywhere in it is a hard 500. Measured on
176
+ * `mermaid`, three attempts, deterministic — `?bundle&target=es2022&external=react,react-dom,scheduler`
177
+ * answers **500 `esbuild: No matching export in "node_modules/d3/src/index.js" for import
178
+ * "curveBumpX"`**, while the plain `https://esm.sh/mermaid?target=es2022` answers 200 and the
179
+ * module imports fine. The card meanwhile renders **completely blank with nothing in the
180
+ * console**, because an unresolvable import kills the whole module graph.
181
+ *
182
+ * So the retry that only busts the query re-requests the same broken build. Unbundling is a
183
+ * genuinely different artefact on esm.sh's side, which is what makes it a second chance rather
184
+ * than a second identical failure. It is not the first choice — the bundled build is one request
185
+ * instead of a waterfall — which is why this is on the retry path and not on the happy one.
186
+ */
187
+ export declare const unbundleFetchedImports: (imports: Record<string, string>) => Record<string, string>;
158
188
  export declare const bustFetchedImports: (imports: Record<string, string>, attempt: number) => Record<string, string>;
159
189
  export declare function GenUISurface({ code, streaming, preserveState, onError, onRendered, className }: GenUISurfaceProps): import("react").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import type { ReactElement } from "react";
2
- import type { Ui4aSegment } from "./segments.ts";
2
+ import { type Ui4aSegment } from "./segments.ts";
3
3
  /** Split out from `hasPainted` so the rule can be tested without a DOM. */
4
4
  export declare const isPaintedText: (text: string) => boolean;
5
5
  export declare const hasPainted: (mount: HTMLElement) => boolean;
@@ -14,10 +14,37 @@ export declare const matchSegment: (segments: readonly Ui4aSegment[], rendered:
14
14
  export type InlineFenceOptions = {
15
15
  /** Every ui4a segment currently in the transcript, in document order. */
16
16
  segments: () => readonly Ui4aSegment[];
17
+ /** `last` answers whether this card is still the transcript's newest — asked at report time, not render time. */
17
18
  render: (props: {
18
19
  code: string;
19
20
  streaming: boolean;
21
+ last: () => boolean;
20
22
  }) => ReactElement;
21
23
  scope?: HTMLElement;
22
24
  };
25
+ /**
26
+ * Whether this card's code is the last ui4a segment in the transcript.
27
+ *
28
+ * **Only the last block's failure is worth telling the model about.** An earlier card that cannot
29
+ * render stays in the transcript and re-renders on every later frame, so it fails again, and
30
+ * again, for the rest of the session — measured on a real one where the model wrote a card
31
+ * importing `Github` from `lucide-react` (removed upstream), was told, fixed it to `GitBranch` in
32
+ * the very next reply, and the failure notice kept coming back from the reply it had already
33
+ * superseded. The model cannot act on it: editing a message it already sent is not a thing it
34
+ * can do.
35
+ *
36
+ * Evaluated when the report is about to be SENT, not when the error is raised. A card is the last
37
+ * one at the moment it breaks and stops being so as soon as the next card appears, which is
38
+ * exactly the second in between.
39
+ *
40
+ * **Segments, not mounts.** Comparing DOM nodes was wrong three separate ways, all of them
41
+ * silent: `release` calls `claim.mount.remove()` on every markdown re-render, so a broken card
42
+ * followed by any trailing prose compared a detached node and dropped its own report; `defer`
43
+ * parks a far-offscreen block without ever creating a mount, so a reader scrolled up while the fix
44
+ * streams left an older card holding the last mount and reporting as though it were newest; and an
45
+ * empty node list has to mean false, since a session switch inside the settle window empties it
46
+ * while `sendToModel` reads `currentSession()` at send time — session A's error into session B's
47
+ * store. The segment list is the transcript's own order and survives all three.
48
+ */
49
+ export declare const isLastSegment: (segments: readonly Ui4aSegment[], code: string) => boolean;
23
50
  export declare function claimInlineFences({ segments, render, scope }: InlineFenceOptions): () => void;
@@ -1,17 +1,64 @@
1
+ /**
2
+ * Sends a card's compile error back to the model as a chat message.
3
+ *
4
+ * Until this existed, `onError` had no consumer at all: a card that failed to compile painted a
5
+ * red panel for the reader and the model never learned anything. Measured on a real session —
6
+ * a card imported `MonacoEditor` from `modern-monaco`, the surface reported *"has no export named
7
+ * 'MonacoEditor'; module exports: Workspace, errors, hydrate, init, lazy"*, and the transcript
8
+ * ends there: `MonacoEditor` appears 6 times in the records after that point and `no export named`
9
+ * zero. The reader saw the answer on screen; the one party who could act on it did not.
10
+ *
11
+ * Four constraints, each of which this got wrong in an obvious first version:
12
+ *
13
+ * - **Only errors that survived.** `GenUISurface` already separates a mid-stream prefix failure
14
+ * and a retryable network blip from a real one — the `report` branch of its error action. That
15
+ * branch is the only caller, so a half-written expression never reaches the model.
16
+ *
17
+ * It is not sufficient on its own, though. `isUnfinishedFrame` excludes the RENDER phase by
18
+ * design — a card whose own render throws is usually a real error — but a half-written
19
+ * component rendering mid-stream throws too, and the next frame is fine. Measured in a browser:
20
+ * a card that ultimately rendered correctly reported `Cannot read properties of undefined
21
+ * (reading 'getCurrentStack')`, a React internal, to the model. Painting a red panel for a
22
+ * frame costs nothing; sending the model a message about a card that then worked costs it a
23
+ * turn spent fixing what is not broken. So the send is DEFERRED, and a paint cancels it.
24
+ * - **Once per card, not once per frame.** A settled card that fails re-renders on every later
25
+ * frame of the transcript, and a turn per render is a loop the user has to kill. Deduplication
26
+ * lives HOST-side now (`CardFailures.set`), because this half is thrown away by every
27
+ * navigation and a dedup set that resets on reload wakes the model about a card it already
28
+ * knows. What stays here is the settling, which is about frames, not about turns.
29
+ * - **Recovery is reported too.** The report is state, not an event: a card that starts working
30
+ * sends `null`, and the host drops it out of the model's context. As a chat message that was
31
+ * impossible, which is why the old body had to carry a paragraph explaining that nobody had
32
+ * typed it.
33
+ * - **Only the newest card counts.** An earlier card that cannot render stays in the transcript
34
+ * and re-renders on every later frame, so it goes on failing for the rest of the session.
35
+ * Measured on a real one: the model wrote a card importing `Github` from `lucide-react`
36
+ * (removed upstream), was told, fixed it to `GitBranch` in its very next reply — and the
37
+ * failure notice kept coming back from the message it had already superseded. There is nothing
38
+ * the model can do with that; it cannot edit a reply it has sent. `isLastSegment` is the gate, on both the report and the retraction.
39
+ */
1
40
  /** Exported for the test: a fresh card in a fresh session should be able to report again. */
2
41
  export declare const forgetReportedErrors: () => void;
3
- export type ErrorReporter = (text: string) => void;
42
+ /** `null` means the card recovered. */
43
+ export type ErrorReporter = (report: {
44
+ message: string;
45
+ phase: string;
46
+ } | null) => void;
47
+ /** Drop a report the page is going away before it can deliver. NOT a recovery — nothing is fixed. */
48
+ export declare function cancelPendingReport(): void;
4
49
  /**
5
- * The message body. Kept short and factual: it is spent from the user's context window, and the
6
- * one thing the model needs is what failed and that nobody typed it.
50
+ * Called when a surface paints. Cancels a report the very next frame made untrue.
7
51
  *
8
- * English, like the prompt and the skill it sits beside. This message is the only text this
9
- * plugin puts into the conversation, and writing it in Chinese did two things: it read as a
10
- * different voice from everything else the plugin says, and because a card must be written in
11
- * the language the USER wrote init pushed the model toward answering a Spanish or French
12
- * speaker in the wrong language for the rest of the turn.
52
+ * **`restored` is not a detail.** partial-react answers a render throw by re-mounting the LAST
53
+ * GOOD component (`runtime.ts:416-419`, whenever `preserve` is on which is every inline card),
54
+ * and that re-mount paints, and painting used to cancel the report that the very same throw had
55
+ * just armed. So the one case the reporting exists fora card that worked, then broke on an
56
+ * edit showed the reader stale content and told the model nothing. A paint only means the card
57
+ * is fine when it is the NEW code that painted.
58
+ */
59
+ /**
60
+ * @param current whether the painting card is the one the gate would let report. Defaults to
61
+ * permissive for the canvas path, which shows one card at a time.
13
62
  */
14
- export declare const reportBody: (message: string, phase: string) => string;
15
- /** Called when a surface paints. Cancels a report the very next frame made untrue. */
16
- export declare function cardRendered(): void;
17
- export declare function reportCardError(send: ErrorReporter | undefined, message: string, phase: string): void;
63
+ export declare function cardRendered(restored?: boolean, current?: () => boolean): void;
64
+ export declare function reportCardError(send: ErrorReporter | undefined, message: string, phase: string, current?: () => boolean): void;
@@ -39,3 +39,13 @@ export declare const EXEC_PATH = "/dsh-generative-ui/exec";
39
39
  * re-opening from here what the host closed for its own tools is not ours to do.
40
40
  */
41
41
  export declare const WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
42
+ /**
43
+ * A card's surviving failure, reported by the browser half: `?session=<id>`, POST
44
+ * `{message, phase}` to set it and `{}` to clear it.
45
+ *
46
+ * The detail does NOT come back as a chat message. It becomes a runtime-context snapshot
47
+ * (`ui4a:card-failure`), which is re-evaluated per assembly and superseded by the next one, so a
48
+ * card that gets fixed stops being mentioned instead of leaving a stale complaint in history. The
49
+ * route only carries the state; `wakeAgent` is what asks the model to look at it.
50
+ */
51
+ export declare const CARD_ERROR_PATH = "/dsh-generative-ui/card-error";
@@ -11,6 +11,24 @@
11
11
  import type { IncomingMessage, ServerResponse } from "node:http";
12
12
  import type { Context } from "@deepseek-ai/cordis";
13
13
  import z from "@deepseek-ai/schemastery";
14
+ import { CardFailures } from "./card-failure.ts";
15
+ /**
16
+ * The one field of the assembling agent this plugin reads: its id, which IS the session id.
17
+ *
18
+ * Declared here rather than by importing `@deepseek-ai/dsh-agent`, which owns the real
19
+ * augmentation. That package also augments cordis `Context` with the HOST's `sessions` service,
20
+ * and the augmentation is global — pulling it in retyped `ctx.sessions` inside `src/client/`,
21
+ * where the session store is the browser runtime's and has `getSnapshot`/`binding` instead.
22
+ * Six type errors in files this change does not touch. One optional field is the whole
23
+ * dependency, so it is cheaper to state it than to import the package that carries it.
24
+ */
25
+ declare module "@deepseek-ai/dsh-system-prompt" {
26
+ interface AssembleContext {
27
+ agent?: {
28
+ readonly id: string;
29
+ };
30
+ }
31
+ }
14
32
  export declare const name = "dsh-generative-ui";
15
33
  export declare const inject: string[];
16
34
  /** The settings section this plugin owns; the key under `dsh-generative-ui:` in settings.yaml. */
@@ -20,13 +38,24 @@ export declare const SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").Set
20
38
  * `settings.yaml` section against it and builds the settings UI from it, so a plain interface
21
39
  * would be a switch nobody can find and nobody can check.
22
40
  *
23
- * `allowExec` is off by default and that default is the point. `$dsh/fs` is bounded it takes a
24
- * workspace-relative path and runs under the session's sandbox policy, so the worst it reaches is
25
- * a file the user could have opened anyway. `$dsh/exec` takes an arbitrary command string, and a
26
- * card is code a MODEL wrote, running in the user's browser, firing on their keystrokes. The
27
- * sandbox policy still applies, but "whatever the agent's own bash tool may do" is a much larger
28
- * surface than a path — and the user never approves a card's commands the way they approve the
29
- * agent's.
41
+ * `allowExec` is ON by default, and the trade it makes is worth stating rather than assuming.
42
+ *
43
+ * What it is NOT: an escape from the fence. The route resolves `ctx.sandboxPolicy` for the session
44
+ * and hands it to `ctx.shell`, so a card's command opens nothing the agent's own bash has not
45
+ * already opened, in a workdir pinned to a live session's workspace, killed after 15s and on the
46
+ * reader closing the page.
47
+ *
48
+ * What it IS: the approval layer does not reach here. `approval.request()` needs an open turn and
49
+ * an agent, and a card fires on a reader's keystroke long after its turn ended — so the per-command
50
+ * fence is the sandbox policy alone. Under `workspace-write` a card can therefore delete inside the
51
+ * workspace with nobody agreeing to it command by command. The prompt answers that where it can
52
+ * ("observe, never change"; anything destructive belongs in a `sendMessage` the user agrees to),
53
+ * which is guidance, not a fence.
54
+ *
55
+ * Turned on because the capability it gates is the ordinary case, not the exotic one: search
56
+ * (`fd`, `rg`) that no `$dsh/fs` call expresses, `lint` / `check` / test runs whose output IS the
57
+ * card, `git log`, and the twenty-`readdir` walks a single `ls -R` replaces. Off, a model reasoning
58
+ * from a five-capability set writes those as file-by-file loops or does not write the card at all.
30
59
  */
31
60
  export declare const Config: z<Schemastery.ObjectS<{
32
61
  allowExec: z<boolean, boolean>;
@@ -253,3 +282,17 @@ type LlmCtx = {
253
282
  /** Exported for `test/ai-route.test.ts`. */
254
283
  export declare function serveAi(ctx: LlmCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
255
284
  export declare function apply(ctx: Context, config?: Config): void;
285
+ /**
286
+ * Record or clear one session's failing card, and wake the model when the failure is news.
287
+ *
288
+ * `POST ?session=<id>` with `{message, phase}` to set it and `{}` to clear it. The detail never
289
+ * comes back as a chat message — it becomes the `ui4a:card-failure` runtime context, which the
290
+ * assembly re-reads each step. See `card-failure.ts` for why the two halves are split.
291
+ *
292
+ * The wake is looked up per request rather than captured: the agent registry is scoped, so the
293
+ * function it hands out can go away while this route stays up, and a stale capture would call
294
+ * into a disposed fiber.
295
+ *
296
+ * Exported for `test/card-error-route.test.ts`.
297
+ */
298
+ export declare function serveCardError(failures: CardFailures, wake: () => ((session: string) => void) | null, req: IncomingMessage, res: ServerResponse): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-generative-ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Generative UI for DeepSeek Harness — the agent writes TSX, dsh web renders it live, inline in chat and in a canvas panel",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -94,6 +94,7 @@
94
94
  "react": "^18.2.0"
95
95
  },
96
96
  "devDependencies": {
97
+ "@babel/parser": "^8.0.4",
97
98
  "@deepseek-ai/cordis": "^4.0.1",
98
99
  "@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
99
100
  "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
@@ -109,10 +110,11 @@
109
110
  "@types/node": "^26.3.0",
110
111
  "@types/react": "~18.3.31",
111
112
  "@types/react-dom": "~18.3.7",
113
+ "@types/scheduler": "^0.26.0",
112
114
  "micromatch": "^4.0.8",
113
115
  "minimatch": "^10.2.6",
114
116
  "motion": "^13.1.1",
115
- "oxfmt": "^0.64.0",
117
+ "oxfmt": "^0.66.0",
116
118
  "oxlint": "^1.80.0",
117
119
  "picomatch": "^4.0.7",
118
120
  "pkg-pr-new": "^0.0.88",
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What the model is told about a card that will not render.
3
+ *
4
+ * Split in two, deliberately, because the two halves want opposite lifetimes:
5
+ *
6
+ * - **The detail is state.** It goes out as a runtime-context snapshot, re-evaluated on every
7
+ * assembly and superseded by the next one. A card that gets fixed simply stops being mentioned.
8
+ * As a chat message it was the opposite — permanent, and still there three turns after the card
9
+ * started working.
10
+ * - **The nudge is an event.** One short line through `followup`, whose only job is to open a turn
11
+ * so the model looks at the detail now rather than whenever the user next types.
12
+ *
13
+ * Neither is a user-role message any more, and that removes the paragraph the old body had to
14
+ * carry: *"This was sent by the renderer, not by the user — nobody typed it, so do not apologise
15
+ * or address it as a request."* That existed because the report arrived wearing the user's face.
16
+ * A `kind: "plugin"` source does not, so the model is told what happened and nothing else — the
17
+ * disclaimer cost more tokens than the error it was wrapped around.
18
+ */
19
+
20
+ /** A card failure as the browser half reported it. */
21
+ export type CardFailure = { readonly message: string; readonly phase: string };
22
+
23
+ /** The runtime-context section name. Stable: it is how a later snapshot supersedes an earlier one. */
24
+ export const CARD_FAILURE_CONTEXT = "ui4a:card-failure";
25
+
26
+ /**
27
+ * Ordered after the plugin's own guidance so the model reads how cards work before it reads that
28
+ * one is broken.
29
+ */
30
+ export const CARD_FAILURE_CONTEXT_ORDER = 250;
31
+
32
+ /**
33
+ * The detail, as the model sees it.
34
+ *
35
+ * It says it is current state rather than an event on purpose: the same text is re-delivered on
36
+ * every step while the card stays broken, and a model that reads it as a fresh report tries to
37
+ * fix the card again on each one.
38
+ */
39
+ export const failureText = (failure: CardFailure) =>
40
+ `A ui4a card in this session is not rendering. It failed at the ${failure.phase} step:\n\n${failure.message}\n\nThis is current state, not a new event — it is re-read every step and disappears once the card renders. If the error names the correct usage (the available exports, for instance), fix the card and send it again. If it does not, look it up before you change anything.`;
41
+
42
+ /**
43
+ * The one line that opens a turn. The detail is already in context; this only asks for attention.
44
+ *
45
+ * The second sentence is not padding — it closes a race the split design makes possible. The nudge
46
+ * is an event and cannot be recalled once queued; the detail is state and is re-evaluated when the
47
+ * turn assembles. A card that recovers in between (a retry succeeding is the common way) clears
48
+ * the state, so the turn opens with "go read the runtime context" pointing at a context that says
49
+ * nothing about a card.
50
+ *
51
+ * Seen in a real session: two notices, and the SECOND one had no card-failure section anywhere in
52
+ * the turn that followed it. Without a defined outcome for that, the model has been told something
53
+ * is broken, cannot find it, and goes looking — in that session it rewrote a card that was fine.
54
+ */
55
+ export const WAKE_TEXT =
56
+ "A ui4a card you wrote is not rendering — the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
57
+
58
+ /** Shown as the context row's label in the transcript, so a reader can see what fired without opening it. */
59
+ export const WAKE_SUMMARY = "ui4a card failed to render";
60
+
61
+ /**
62
+ * The current failing card per session.
63
+ *
64
+ * Per session rather than per card: the nudge exists to get one turn spent on the problem, and a
65
+ * reply that breaks three cards does not want three turns. The newest failure wins because it is
66
+ * the one the model just wrote.
67
+ */
68
+ export class CardFailures {
69
+ private readonly bySession = new Map<string, CardFailure>();
70
+
71
+ /**
72
+ * Record a failure.
73
+ *
74
+ * @returns whether the session went from HEALTHY to failing — the caller wakes the model only
75
+ * then. Dedup lives here rather than in the browser half because that half is reloaded by every
76
+ * navigation, and a dedup set that resets on reload wakes the model again for a card it already
77
+ * knows about.
78
+ *
79
+ * **Not "is this a different message".** That was the first version, and it is wrong for a
80
+ * nudge whose entire payload is *go read the context*: two different failures produce two
81
+ * BYTE-IDENTICAL notices, so a reader watching a long turn sees the same sentence queued twice
82
+ * and the model gets two turns pointed at one current-state snapshot. Measured on a real
83
+ * session — three genuinely different failures (`@react-three/fiber` unresolvable, `maps is not
84
+ * defined`, `@react-three/cannon` unresolvable) over eighty minutes, each "news" by the old
85
+ * test, and the third's notice was still sitting unsent in the composer beside the second.
86
+ *
87
+ * While a failure is already recorded the model is either about to look or has looked, and the
88
+ * detail it will read is re-evaluated every step anyway — so a changed message needs no second
89
+ * nudge. `clear()` on recovery is what re-arms this.
90
+ */
91
+ set(session: string, failure: CardFailure): boolean {
92
+ const wasHealthy = !this.bySession.has(session);
93
+ this.bySession.set(session, failure);
94
+ return wasHealthy;
95
+ }
96
+
97
+ clear(session: string): void {
98
+ this.bySession.delete(session);
99
+ }
100
+
101
+ /** The context text for one session; empty contributes nothing to the assembly. */
102
+ text(session: string | undefined): string {
103
+ const failure = session === undefined ? undefined : this.bySession.get(session);
104
+ return failure === undefined ? "" : failureText(failure);
105
+ }
106
+ }