Add multimodal image inputs to Face turns

This commit is contained in:
Meta Vens 2026-05-24 03:28:40 +01:00
commit c430e709fd
12 changed files with 281 additions and 16 deletions

View file

@ -225,6 +225,20 @@ export function convertDiscordMessageToArchive(
}
export function convertDiscordMessageToSource(message: Message): SourceMessage {
const attachments = [...message.attachments.values()].map((attachment) => ({
kind: attachment.contentType?.toLowerCase().startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name ?? attachment.url ?? "")
? "image" as const
: "other" as const,
id: attachment.id,
filename: attachment.name,
contentType: attachment.contentType ?? undefined,
url: attachment.url,
proxyUrl: attachment.proxyURL,
size: attachment.size,
width: attachment.width ?? undefined,
height: attachment.height ?? undefined,
}));
return {
id: message.id,
authorId: message.author.id,
@ -232,6 +246,7 @@ export function convertDiscordMessageToSource(message: Message): SourceMessage {
content: message.content,
timestamp: message.createdAt.toISOString(),
isBot: message.author.bot,
...(attachments.length > 0 ? { attachments } : {}),
};
}

View file

@ -167,6 +167,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Live model-facing prompt prose has started moving out of executable code. The shared loader is `packages/shared/src/prompt-template.ts`; extracted prompt surfaces live under `prompts/`, including repo Face turn/reviewer prompts, provider wrapper prompts, situational social read prompts, source-grounding reminders, consensus-feed prompts, and repo Face model-smoke scenarios. Code should project data and select prompt names; prompt files should own behavioral prose.
- Identity collapse has started. Active bot, worker, MCP repo-identity tools, and Face CTB scheduler paths now load through the canonical Face registry and treat repo Discord identities as compatibility projections. Next bounded move is to finish removing remaining direct conceptual dependence on repo identities, then split the Face turn loop into scheduler, context assembler, prompt renderer, candidate parser, policy gate, and transport adapter.
- Repo Face child prompts now load the full global Codex `AGENTS.md` as inherited doctrine near the top of each Face turn. This deliberately carries the Prime Directive, rebuild contract, humor doctrine, infrastructure habits, Epiphany stewardship, and Great Colossus mission frame into Face cognition rather than relying on a local summary shard.
- Repo Face child turns now preserve Discord media context. `SourceMessage` carries attachment metadata, heartbeat Discord fetch caches image attachments under `.voidbot/media/discord-images`, Face transcripts show `[media: image ... local=...]`, and owner Codex receives cached images through `codex exec --image`. Non-image attachments are visible as metadata only; Faces are explicitly told not to pretend they saw images unless a local image path was attached.
- After those gates exist, run dry/mock Face turns before re-enabling `GameCult Local Orchestrator`.
- The direct-ask receipt check remains important, but it is no longer the next move while the swarm is offline.
- Next cuts should delete unstable foundations early: do not revive `legacyJsonPath`, working projections, top-level mirror fields, or cleanup code that only compensated for those broken boundaries.

View file

@ -3,6 +3,7 @@ import {
type ContextBundle,
type GuildContext,
type InteractionMemoryProfile,
type PromptImageAttachment,
type RetrievalResult,
type SituationalSocialRead,
type SourceGroundingHint,
@ -16,6 +17,7 @@ export interface BuildContextInput {
actor: Actor;
guildContext: GuildContext;
recentMessages?: SourceMessage[];
imageAttachments?: PromptImageAttachment[];
retrieval?: RetrievalResult[];
interactionMemory?: InteractionMemoryProfile;
situationalSocialRead?: SituationalSocialRead;
@ -31,6 +33,7 @@ export class ContextBuilder {
actor: input.actor,
guildContext: input.guildContext,
recentMessages: input.recentMessages ?? [],
imageAttachments: input.imageAttachments,
retrieval: input.retrieval ?? [],
interactionMemory: input.interactionMemory,
situationalSocialRead: input.situationalSocialRead,

View file

@ -415,6 +415,7 @@ export class OwnerCodexProvider implements ProviderAdapter {
timeoutMs: this.options.timeoutMs,
workingDirectory: this.options.workingDirectory,
prompt: input.prompt,
imagePaths: collectCodexImagePaths(input.request.contextBundle),
mcpServers: input.command === "repo-face-rumination"
? restrictMcpServersToRepoFaceExploration(this.options.mcpServers ?? [])
: this.options.mcpServers ?? [],
@ -436,11 +437,19 @@ export class OwnerCodexProvider implements ProviderAdapter {
timeoutMs: this.options.timeoutMs,
workingDirectory: this.options.workingDirectory,
prompt: input.prompt,
imagePaths: collectCodexImagePaths(input.request.contextBundle),
mcpServers: this.options.mcpServers ?? [],
});
}
}
function collectCodexImagePaths(contextBundle: ProviderRequest["contextBundle"]): string[] {
return (contextBundle.imageAttachments ?? [])
.map((attachment) => attachment.localPath)
.filter((localPath): localPath is string => typeof localPath === "string" && localPath.trim().length > 0)
.slice(0, 8);
}
function getStringOption(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}

View file

@ -34,7 +34,7 @@ export function buildDiscordReplyPrompt(
? context.recentMessages
.map(
(message) =>
`- [${message.timestamp}] ${message.authorName}: ${message.content}`,
`- [${message.timestamp}] ${message.authorName}: ${message.content}${renderAttachmentSuffix(message)}`,
)
.join("\n")
: "- No recent messages captured.";
@ -116,7 +116,7 @@ export function renderMarkdownBundle(context: ContextBundle): string {
? context.recentMessages
.map(
(message) =>
`- [${message.timestamp}] ${message.authorName} (${message.authorId}): ${message.content}`,
`- [${message.timestamp}] ${message.authorName} (${message.authorId}): ${message.content}${renderAttachmentSuffix(message)}`,
)
.join("\n")
: "- No recent messages captured.";
@ -183,6 +183,20 @@ export function renderMarkdownBundle(context: ContextBundle): string {
].join("\n");
}
function renderAttachmentSuffix(message: ContextBundle["recentMessages"][number]): string {
const attachments = message.attachments ?? [];
if (attachments.length === 0) {
return "";
}
return ` [media: ${attachments.map((attachment) => {
const kind = attachment.kind === "image" ? "image" : "attachment";
const name = attachment.filename ?? attachment.id ?? "unnamed";
const dimensions = attachment.width && attachment.height ? ` ${attachment.width}x${attachment.height}` : "";
const local = attachment.localPath ? ` local=${attachment.localPath}` : "";
return `${kind} ${name}${dimensions}${local}`;
}).join("; ")}]`;
}
function renderVoidSelfState(context: ContextBundle): string {
if (!context.voidSelfState) {
return "- No private persistent self-state was attached.";

View file

@ -47,6 +47,7 @@ export async function runCodexExec(input: {
timeoutMs: number;
workingDirectory: string;
prompt: string;
imagePaths?: string[];
mcpServers: CodexMcpServerConfig[];
}): Promise<CodexRunResult> {
return new Promise<CodexRunResult>((resolve) => {
@ -62,6 +63,7 @@ export async function runCodexExec(input: {
"-c",
`model_reasoning_effort=${JSON.stringify(input.reasoningEffort)}`,
...buildMcpConfigArguments(input.mcpServers),
...buildImageArguments(input.imagePaths ?? []),
"--json",
"--skip-git-repo-check",
"-s",
@ -226,6 +228,19 @@ export async function runCodexExec(input: {
});
}
function buildImageArguments(imagePaths: string[]): string[] {
const args: string[] = [];
const seen = new Set<string>();
for (const imagePath of imagePaths) {
if (seen.has(imagePath)) {
continue;
}
seen.add(imagePath);
args.push("--image", imagePath);
}
return args;
}
export function normalizeDiscordReply(stdout: string): NormalizedDiscordReply {
const messages: string[] = [];

View file

@ -127,6 +127,7 @@ export function buildRequestPayload(
stylePack: request.contextBundle.stylePack,
voidSelfState: request.contextBundle.voidSelfState,
recentMessages: request.contextBundle.recentMessages,
imageAttachments: request.contextBundle.imageAttachments ?? [],
retrieval: request.contextBundle.retrieval,
options: request.options ?? {},
};

View file

@ -78,6 +78,28 @@ export interface SourceMessage {
content: string;
timestamp: string;
isBot?: boolean;
attachments?: SourceMessageAttachment[];
}
export interface SourceMessageAttachment {
id?: string;
filename?: string;
contentType?: string;
url?: string;
proxyUrl?: string;
size?: number;
width?: number;
height?: number;
localPath?: string;
kind: "image" | "other";
}
export interface PromptImageAttachment {
messageId: string;
authorName: string;
filename?: string;
contentType?: string;
localPath: string;
}
export interface RetrievalResult {
@ -434,6 +456,7 @@ export interface ContextBundle {
actor: Actor;
guildContext: GuildContext;
recentMessages: SourceMessage[];
imageAttachments?: PromptImageAttachment[];
retrieval: RetrievalResult[];
interactionMemory?: InteractionMemoryProfile;
situationalSocialRead?: SituationalSocialRead;

View file

@ -22,6 +22,9 @@ Recent home repo activity:
Recent conversation transcript:
{{conversationMemorySurface}}
Image awareness:
If a transcript line includes `[media: image ... local=...]`, that image has been attached to your Face turn as an actual image input when the provider supports multimodal turns. Treat shared images and memes as part of the room's social context: notice who posted them, what they seem to be doing socially, and how your character would react. Do not pretend to see an image when the transcript only names a non-image attachment or lacks a local media path.
{{humanPronounDirective}}
Current room weather:

View file

@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { dirname, extname, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
@ -25,11 +25,18 @@ import {
type RepoDiscordIdentity,
type VoidSelfStateTypedProjection,
} from "@voidbot/core";
import { loadPromptTemplate, type InteractionMemoryProfile, type SourceMessage } from "@voidbot/shared";
import {
loadPromptTemplate,
type InteractionMemoryProfile,
type PromptImageAttachment,
type SourceMessage,
type SourceMessageAttachment,
} from "@voidbot/shared";
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
const HEARTBEAT_COMMAND = "repo-face-rumination";
const MIN_STALE_ACTIVE_JOB_MS = 45 * 60_000;
const MAX_FACE_IMAGE_ATTACHMENT_BYTES = 12 * 1024 * 1024;
interface FaceHeartbeatParticipant {
identityId: string;
@ -546,6 +553,10 @@ async function queueRepoFaceTurn(input: {
githubActionsEnabled: input.config.repoFaceGithubActionsEnabled,
globalAgentDoctrine,
});
const imageAttachments = collectPromptImageAttachments([
recentMessages,
...channelSnapshots.map((snapshot) => snapshot.messages),
].flat());
const contextBundle = contextBuilder.build({
prompt,
actor: {
@ -558,6 +569,7 @@ async function queueRepoFaceTurn(input: {
channelId,
},
recentMessages,
imageAttachments,
retrieval: [],
voidSelfState: undefined,
});
@ -903,16 +915,24 @@ async function fetchRecentDiscordMessages(input: {
}
const messages = await response.json() as DiscordApiMessage[];
return messages
const sourceMessages = await Promise.all(messages
.filter((message) => !(input.ignoreBotMessages && message.author.bot === true))
.map((message) => ({
id: message.id,
authorId: message.author.id,
authorName: message.author.global_name ?? message.member?.nick ?? message.author.username,
content: message.content,
timestamp: message.timestamp,
isBot: message.author.bot === true,
}))
.map(async (message) => {
const attachments = await materializeDiscordAttachments({
channelId: input.channelId,
message,
});
return {
id: message.id,
authorId: message.author.id,
authorName: message.author.global_name ?? message.member?.nick ?? message.author.username,
content: message.content,
timestamp: message.timestamp,
isBot: message.author.bot === true,
...(attachments.length > 0 ? { attachments } : {}),
};
}));
return sourceMessages
.sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp));
}
@ -953,6 +973,110 @@ async function fetchChannelSnapshots(input: {
return snapshots;
}
async function materializeDiscordAttachments(input: {
channelId: string;
message: DiscordApiMessage;
}): Promise<SourceMessageAttachment[]> {
const attachments = input.message.attachments ?? [];
if (attachments.length === 0) {
return [];
}
const materialized: SourceMessageAttachment[] = [];
for (const attachment of attachments.slice(0, 4)) {
const kind = isDiscordImageAttachment(attachment) ? "image" : "other";
let localPath: string | undefined;
if (kind === "image" && isWithinFaceImageSizeLimit(attachment)) {
localPath = await cacheDiscordImageAttachment({
channelId: input.channelId,
messageId: input.message.id,
attachment,
});
}
materialized.push({
kind,
id: attachment.id,
filename: attachment.filename,
contentType: attachment.content_type,
url: attachment.url,
proxyUrl: attachment.proxy_url,
size: typeof attachment.size === "number" ? attachment.size : undefined,
width: typeof attachment.width === "number" ? attachment.width : undefined,
height: typeof attachment.height === "number" ? attachment.height : undefined,
localPath,
});
}
return materialized;
}
function isWithinFaceImageSizeLimit(attachment: DiscordApiAttachment): boolean {
return typeof attachment.size !== "number" || attachment.size <= MAX_FACE_IMAGE_ATTACHMENT_BYTES;
}
function isDiscordImageAttachment(attachment: DiscordApiAttachment): boolean {
const contentType = attachment.content_type?.toLowerCase() ?? "";
if (contentType.startsWith("image/")) {
return true;
}
return /\.(png|jpe?g|gif|webp)$/i.test(attachment.filename ?? attachment.url ?? "");
}
async function cacheDiscordImageAttachment(input: {
channelId: string;
messageId: string;
attachment: DiscordApiAttachment;
}): Promise<string | undefined> {
const sourceUrl = input.attachment.url ?? input.attachment.proxy_url;
if (!sourceUrl) {
return undefined;
}
const safeExtension = normalizedImageExtension(input.attachment);
const fileStem = [
input.messageId,
input.attachment.id ?? createHash("sha256").update(sourceUrl).digest("hex").slice(0, 12),
].join("-");
const directory = resolve(".voidbot", "media", "discord-images", input.channelId);
const localPath = resolve(directory, `${fileStem}${safeExtension}`);
try {
await stat(localPath);
return localPath;
} catch {
// Cache miss. Fall through to fetch.
}
try {
const response = await fetch(sourceUrl);
if (!response.ok) {
return undefined;
}
const arrayBuffer = await response.arrayBuffer();
await mkdir(directory, { recursive: true });
await writeFile(localPath, Buffer.from(arrayBuffer));
return localPath;
} catch {
return undefined;
}
}
function normalizedImageExtension(attachment: DiscordApiAttachment): string {
const fromName = extname(attachment.filename ?? "").toLowerCase();
if (/^\.(png|jpe?g|gif|webp)$/.test(fromName)) {
return fromName;
}
const contentType = attachment.content_type?.toLowerCase();
switch (contentType) {
case "image/jpeg":
return ".jpg";
case "image/gif":
return ".gif";
case "image/webp":
return ".webp";
case "image/png":
default:
return ".png";
}
}
async function fetchBifrostGovernanceDigest(input: {
bifrostRoot: string;
repoName: string;
@ -1019,6 +1143,7 @@ interface DiscordApiMessage {
id: string;
content: string;
timestamp: string;
attachments?: DiscordApiAttachment[];
author: {
id: string;
username: string;
@ -1030,6 +1155,17 @@ interface DiscordApiMessage {
};
}
interface DiscordApiAttachment {
id?: string;
filename?: string;
content_type?: string;
size?: number;
url?: string;
proxy_url?: string;
width?: number | null;
height?: number | null;
}
async function startVoidModerationTurn(input: {
queuedAt: string;
storageRoot: string;
@ -2937,7 +3073,8 @@ function renderVisibleConversationChronology(input: {
"Visible cross-channel chronology, oldest to newest:",
...messages.map((message) => {
const speaker = message.isBot ? `${message.authorName} (agent/bot)` : message.authorName;
return `- [${message.channelLabel}] ${speaker} (message ${message.id}): ${collapseWhitespace(message.content, 700)}`;
const content = collapseWhitespace(message.content, 700) || "[no text]";
return `- [${message.channelLabel}] ${speaker} (message ${message.id}): ${content}${renderMessageAttachmentSuffix(message)}`;
}),
].join("\n");
}
@ -2998,10 +3135,51 @@ function formatConversationMessages(messages: SourceMessage[], limit: number): s
}
return messages.slice(-limit).map((message) => {
const speaker = message.isBot ? `${message.authorName} (agent/bot)` : message.authorName;
return `- ${speaker} (message ${message.id}): ${collapseWhitespace(message.content, 900)}`;
const content = collapseWhitespace(message.content, 900) || "[no text]";
return `- ${speaker} (message ${message.id}): ${content}${renderMessageAttachmentSuffix(message)}`;
});
}
function renderMessageAttachmentSuffix(message: SourceMessage): string {
const attachments = message.attachments ?? [];
if (attachments.length === 0) {
return "";
}
const rendered = attachments.map((attachment, index) => {
const label = attachment.kind === "image" ? "image" : "attachment";
const dimensions = attachment.width && attachment.height ? ` ${attachment.width}x${attachment.height}` : "";
const filename = attachment.filename ? ` ${attachment.filename}` : ` ${index + 1}`;
const local = attachment.localPath ? ` local=${attachment.localPath}` : "";
return `${label}${filename}${dimensions}${local}`;
});
return ` [media: ${rendered.join("; ")}]`;
}
function collectPromptImageAttachments(messages: SourceMessage[]): PromptImageAttachment[] {
const seen = new Set<string>();
const images: PromptImageAttachment[] = [];
for (const message of messages) {
for (const attachment of message.attachments ?? []) {
if (attachment.kind !== "image" || !attachment.localPath) {
continue;
}
const key = attachment.localPath;
if (seen.has(key)) {
continue;
}
seen.add(key);
images.push({
messageId: message.id,
authorName: message.authorName,
filename: attachment.filename,
contentType: attachment.contentType,
localPath: attachment.localPath,
});
}
}
return images.slice(0, 8);
}
function runCodexTextProjection(input: {
prompt: string;
config: ReturnType<typeof loadConfig>;

View file

@ -309,3 +309,5 @@
{"ts":"2026-05-23T19:22:52Z","type":"implementation","status":"ok","note":"Cut the live Face projector/Interpreter scar-tissue bans into a causal diagnosis model. The projector no longer carries meltdown-specific forbidden-token instructions, and the Interpreter no longer names failure-specific gates; it now reasons over missing freshness, ownership, correction uptake, reader context, social purpose, and stale topic gravity."}
{"ts":"2026-05-24T01:58:06Z","type":"implementation","status":"ok","note":"Repo Face prompts now include the loaded global Codex AGENTS.md as a full inherited doctrine block near the top of the child prompt. Prompt assembly verified Nibu receives PRIME DIRECTIVE, The Great Colossus, and Epiphany sections from C:/Users/Meta/.codex/AGENTS.md."}
{"ts":"2026-05-24T02:14:42Z","type":"ops","status":"ok","note":"Thawed state/agent-swarm-paused.json and manually pulsed GameCult Local Orchestrator for repo-face-heartbeats. The pulse ran successfully, queued Void moderation, resynced CTB wall-clock initiative, and left repo Faces active under idle cooling with Bifrost dispatch still disabled."}
{"ts":"2026-05-24T02:24:32Z","type":"implementation","status":"ok","note":"Repo Face turns now carry Discord attachment metadata through SourceMessage, cache recent image attachments under .voidbot/media/discord-images, render media markers in Face transcripts, and pass cached image files to owner Codex via codex exec --image. Prompt assembly verified the Image awareness section renders and non-image attachments stay metadata-only."}
{"ts":"2026-05-24T02:27:41Z","type":"ops","status":"ok","note":"Rebuilt VoidBot and restarted the live stack after multimodal Face-turn changes. Runtime status is ready with bot PID 11324 and worker PID 16396, so owner-Codex provider changes for codex exec --image are live in dist."}

View file

@ -111,6 +111,7 @@ accepted_design:
- Repo Face volatile state projection is model-owned again. The heartbeat scheduler may deterministically assemble a compact state packet, but live child prompts must pass that packet through `prompts/repo-face-state-projector.prompt.md` before it reaches the Face. The deterministic packet is an emergency isolation fallback only behind `REPO_FACE_STATE_PROJECTOR_ENABLED=false`, and both paths reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" language before prompt assembly succeeds.
- Repo Face state projection should be behavior-rich, not summary-thin. The deterministic packet is allowed to be verbose because it is parent-facing: it should expose all non-retired character-relevant state surfaces in clean terms, including private notes, values, activation profile, runtime rest/speaking pressure, affect needs, social bonds, status reads, mood dimensions, durable memories, short-term residue, agency pressure, incubating thoughts, candidate speech pressure, recent speech residue, social graph facts, and room weather. The projector then translates those variables into lived personhood; every included variable should either appear directly or bend behavior.
- Repo Face conversation evidence is deterministic again. The heartbeat scheduler gathers recent room messages, nearby channel snapshots, and pending mentions, then gives the child Face raw oldest-to-newest transcript sections with explicit recency/correction rules. The model state projector still owns identity, memory, affect, social reads, and other lived internal pressure; it does not summarize room messages or decide consensus for the Face.
- Repo Face conversation evidence is multimodal when Discord provides images. Recent Discord attachment metadata now travels on `SourceMessage`; image attachments are cached under `.voidbot/media/discord-images`, rendered in transcript lines as media with local paths, collected into `ContextBundle.imageAttachments`, and passed to owner Codex as `codex exec --image` inputs. Non-image attachments remain transcript metadata only. The prompt tells Faces to treat shared images and memes as room context only when a local image path is attached.
- Repo Face room-topic saturation is parent-projected conversational weather. The heartbeat scheduler derives a current-room saturation signal from repeated terms in the recent raw transcript, estimates whether that attractor is home-adjacent or another steward's gravity well from the Face's own identity/jurisdiction text, and places that beside room texture for both the child prompt and state projector. The Face and parent Interpreter decide whether a public line advances, closes, hands off, expresses frustration about crowding, pivots toward the Face's own neglected priorities, or stays private. This is not a topic prohibition, token blacklist, or consensus detector; it is a causal diagnosis surface for freshness, ownership, social pressure, and topic capture.
- Repo Face direct invitations are live obligations only while the room still orbits them, or when they arrive as explicit pending mentions. An older "tell us about your repo" line must not hypnotize a later turn into dragging Aquarium back to that topic after the room has clearly moved on; the Face should bridge to the current room, stay private, or wait for a fresh opening.
- Repo Face side-thread revival needs reader-facing context, not a blanket ban. Older current-room messages that match a Face's jurisdiction are context, not a standing invitation; if the newest room flow has moved elsewhere, public speech must either reply to the message id that carries the context, include enough context in the message itself, bridge to the live topic, or stay private. The transcript renderer shows message ids so the Face and parent Interpreter can use Discord replies instead of orphaned pronouncements.
@ -251,4 +252,4 @@ rejected_paths:
current_status:
summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live, and the repo Face swarm was deliberately thawed on 2026-05-24 by setting state/agent-swarm-paused.json paused=false. GameCult Local Orchestrator remains the single pulse owner; a manual repo-face-heartbeats pulse succeeded, resynced CTB wall-clock initiative, and queued Void moderation. Bifrost work routing remains paused by default.
next_action: Observe the restarted swarm under normal orchestrator pulses. Check that inherited global doctrine appears in Face prompts, topic saturation/source-channel routing behave, and new turf/status/consultation events produce character-specific projected pressure plus durable STATE NOTEs without manual typed-state injection.
next_action: Observe the restarted swarm under normal orchestrator pulses. Check that inherited global doctrine and image awareness appear in Face prompts, topic saturation/source-channel routing behave, shared images/memes influence Face turns only when attached as real image inputs, and new turf/status/consultation events produce character-specific projected pressure plus durable STATE NOTEs without manual typed-state injection.