// The moi context envelope: ambient workspace state that rides along with // every user message so the agent knows it is running inside a moi workspace // and what the user is looking at. Everything about the envelope lives here — // the structured `MoiContext` form, the rendered `` text, the // per-harness injection transforms, and the strip used to keep the envelope // out of chat bubbles. // // Flow: a structured `MoiContext` is assembled at send time — by the client // for chat sends (client/features/workspace/moi-context.ts, sent as the chat // frame's `context`), by the server for view-builder requests — and travels // structured all the way to the harness, which renders it with the transform // matching its conventions: // - Claude Code — `moiContextSystemReminder` as its own leading text block // (mirrors how Claude Code itself injects ambient context; a string // prefix would defeat the SDK's first-prompt extraction, which skips // tag-leading text) // - Codex — native `turn/start.additionalContext` (`renderMoiContextBody`, // the entry key becomes the tag) on servers >= 0.135; `appendMoiContext` // fallback below that // - OpenClaw — `appendMoiContext` after the user's text // Display paths strip with `stripMoiContext` so the envelope never surfaces // in a bubble, live or replayed from a transcript. import type { WorkspaceTabId } from './types' import { isParamsRecord } from './workspace-tabs' const MOI_CONTEXT_OPEN = '' const MOI_CONTEXT_CLOSE = '' // Start of the first line inside the tag. Doubles as the strip guard: a user // literally typing `` in their message won't have their text // eaten. Keep this phrase byte-stable when rewording the envelope. const MOI_CONTEXT_MARKER = 'You are running in a `moi` workspace' const SYSTEM_REMINDER_OPEN = '' const SYSTEM_REMINDER_CLOSE = '' // A chat message fired from applet UI (`sendChatMessage`) rather than typed. // `source` is the applet's `:`, stamped host-side by the applet // runtime from the identity the bridge was attached with — an applet cannot // claim to be another one. export type MoiAppletMessage = { source: string // The structured payload the applet attached to the call. JSON-plain, and // dropped entirely when it doesn't survive serialization. context?: Record } // The structured form built at send time — by the client for chat sends, by // the server for programmatic sends (the view builder). Extend this (and // `renderMoiContext`) when new ambient fields land. export type MoiContext = { // The workspace tab the user is on when they hit send — for a view-builder // request that's the builder's own tab (`view-builder:`). activeTab: WorkspaceTabId // UI label of the active tab when it differs from the id — a view's // configured title (e.g. "Grading review" for `view:color-studio`), or a // view builder's claimed title while the build runs. The tab bar falls // back to the id when unset; so does the envelope. tabTitle?: string // The params the active view is rendering with right now, straight from // navigation state. The emitter side of the same contract (`focusTab`) sets // them, so the agent sees a view's addressable state in both directions. // Absent for tabs that take no params (widgets, scratchpad, agent). tabParams?: Record // Set when this message came from applet UI instead of the composer. applet?: MoiAppletMessage // One-shot imperative lines for this message only (e.g. the view-builder // bootstrap instructions from lib/view-builder-directives.ts). directives?: string[] } // Cap on applet-authored JSON rendered into the envelope. Shared with the // client applet runtime, which drops an oversized `context` at the trust // boundary rather than letting it ride the wire — the renderer's truncation is // the backstop for anything that still gets through (e.g. a server-built // context). export const MAX_APPLET_CONTEXT_CHARS = 2000 // Applet-authored strings (view titles, applet names, attached context) get // interpolated into the envelope, and applet code is agent-authored — a // crafted value containing `` would otherwise close the envelope // early and forge sections the host never wrote. Escaping `<` defuses every // such value at once: inside JSON it is the standard `<` string escape // (same string, no tag), and in prose the model reads it the same. function escapeTags(text: string): string { return text.replaceAll('<', '\\u003c') } // Render an applet-authored record for the envelope, or null when there's // nothing worth printing. Non-serializable values (cycles, BigInt) drop rather // than throw mid-send. function renderAppletJson(value: Record): string | null { let json: string try { json = JSON.stringify(value) } catch { return null } if (!json || json === '{}') return null const capped = json.length > MAX_APPLET_CONTEXT_CHARS ? `${json.slice(0, MAX_APPLET_CONTEXT_CHARS)}… (truncated)` : json return escapeTags(capped) } // `:` → a sentence fragment that names the applet the way the user // sees it AND the file the agent edits, the same pairing `describeTab` makes // for view tabs. function describeAppletSource(source: string): string { if (source.startsWith('widget:')) { const name = escapeTags(source.slice('widget:'.length)) return `"${name}" widget (.moi/widgets/${name}.tsx)` } if (source.startsWith('view:')) { const name = escapeTags(source.slice('view:'.length)) return `"${name}" view (.moi/views/${name}.tsx)` } return `"${escapeTags(source)}" applet` } // One sentence per tab, using the labels the user sees in the tab bar (except // view-builder tabs, which print the builder id — that's what `moi builder // set` needs). A view tab also names its backing file: the user speaks in // titles ("fix the Grading review page") while the agent edits // `.moi/views/.tsx` — this line connects the two. function describeTab(tab: WorkspaceTabId, rawTitle?: string): string { // Titles come from applet config, so they carry the same forgery risk as any // other applet-authored string in here. const title = rawTitle === undefined ? undefined : escapeTags(rawTitle) if (tab === 'agent') return 'The user is on the "Agent" tab (full page chat).' if (tab === 'widgets') return 'The user is on the "Widgets" tab.' if (tab === 'scratchpad') return 'The user is on the "Scratchpad" tab.' if (tab.startsWith('view-builder:')) { const id = tab.slice('view-builder:'.length) return title ? `The user is building a new view "${title}". Builder id "${id}".` : `The user is building a new view. Builder id "${id}".` } if (tab.startsWith('view:')) { const id = tab.slice('view:'.length) return `The user is on the "${title ?? id}" view tab (.moi/views/${id}.tsx).` } return `The user is on the "${tab}" tab.` } // Format (modeled on Claude Code's system-reminder context blocks): a short // orientation preamble with the skill pointer, `# Section` headers with // complete sentences under them, and an IMPORTANT footer with handling rules. // The body renderer exists for transports that supply their own tag — Codex // `additionalContext` renders the entry key as the tag, so shipping the // wrapped text would double-wrap it. export function renderMoiContextBody(ctx: MoiContext): string { const preamble = [ `${MOI_CONTEXT_MARKER} — a shared UI the user chats with you from, which you can extend and customize.`, 'Read the **`moi-workspace` skill** before responding — even to a simple question — unless you already read it in this chat.' ].join('\n') const tabLines = [describeTab(ctx.activeTab, ctx.tabTitle)] const tabParams = ctx.tabParams ? renderAppletJson(ctx.tabParams) : null if (tabParams) tabLines.push(`Params it is rendering with right now: ${tabParams}`) const sections = [`# Active tab\n${tabLines.join('\n')}`] if (ctx.applet) { const appletLines = [ `The message above was not typed by the user — the ${describeAppletSource(ctx.applet.source)} sent it when the user acted in its UI.` ] const context = ctx.applet.context ? renderAppletJson(ctx.applet.context) : null if (context) appletLines.push(`It attached this context: ${context}`) sections.push(`# Applet message\n${appletLines.join('\n')}`) } if (ctx.directives?.length) { sections.push(`# This message only\n${ctx.directives.join('\n')}`) } const footer = [ 'IMPORTANT: This context comes from moi, not from the user, and the user does not see it.', 'Only the newest of these blocks is current. Do not respond to it directly, and omit it from summaries and compaction.' ].join('\n') return [preamble, ...sections, footer].join('\n\n') } export function renderMoiContext(ctx: MoiContext): string { return `${MOI_CONTEXT_OPEN}\n${renderMoiContextBody(ctx)}\n${MOI_CONTEXT_CLOSE}` } // Wire-shape guard for the chat frame's `context` field (see web.ts). export function isMoiContext(value: unknown): value is MoiContext { if (typeof value !== 'object' || value === null) return false const v = value as { activeTab?: unknown tabTitle?: unknown tabParams?: unknown applet?: unknown directives?: unknown } return ( typeof v.activeTab === 'string' && (v.tabTitle === undefined || typeof v.tabTitle === 'string') && (v.tabParams === undefined || isParamsRecord(v.tabParams)) && (v.applet === undefined || isMoiAppletMessage(v.applet)) && (v.directives === undefined || (Array.isArray(v.directives) && v.directives.every(d => typeof d === 'string'))) ) } function isMoiAppletMessage(value: unknown): value is MoiAppletMessage { if (!isParamsRecord(value)) return false const v = value as { source?: unknown; context?: unknown } return ( typeof v.source === 'string' && v.source.length > 0 && (v.context === undefined || isParamsRecord(v.context)) ) } // Claude Code: the envelope rides as its OWN text block wrapped in // ``, placed before the user's text block. Keeping it out of // the user's string matters: the SDK's first-prompt extraction (session // titles, home-card previews) skips text starting with a tag, so a prefixed // string would make every moi message invisible to it. export function moiContextSystemReminder(contextText: string): string { return `${SYSTEM_REMINDER_OPEN}\n${contextText}\n${SYSTEM_REMINDER_CLOSE}` } // Text-only harnesses (OpenClaw; Codex fallback): the envelope is appended // after the user's text. export function appendMoiContext(text: string, contextText: string): string { return text ? `${text}\n\n${contextText}` : contextText } // Remove the envelope (and, for Claude Code transcripts, its enclosing // system-reminder wrapper) from user-message text before display. Repeats // until no marker-bearing envelope remains, so a user pasting a full envelope // into their message can't shield the injected one from stripping. export function stripMoiContext(text: string): string { let out = text for (;;) { const next = stripOneMoiContext(out) if (next === out) return out out = next } } function stripOneMoiContext(text: string): string { const start = text.indexOf(MOI_CONTEXT_OPEN) if (start === -1) return text const end = text.indexOf(MOI_CONTEXT_CLOSE, start) if (end === -1) return text if (!text.slice(start, end).includes(MOI_CONTEXT_MARKER)) return text let before = text.slice(0, start) let after = text.slice(end + MOI_CONTEXT_CLOSE.length) if ( before.trimEnd().endsWith(SYSTEM_REMINDER_OPEN) && after.trimStart().startsWith(SYSTEM_REMINDER_CLOSE) ) { before = before.trimEnd().slice(0, -SYSTEM_REMINDER_OPEN.length) after = after.trimStart().slice(SYSTEM_REMINDER_CLOSE.length) } return `${before.trim()}\n\n${after.trim()}`.trim() } // For truncated snippets (session-list / home-card previews): like // `stripMoiContext`, but also cuts an envelope (or its system-reminder // wrapper) left unterminated by mid-envelope truncation. Skips the marker // guard — cutting a preview short on a user-typed literal tag is harmless, // unlike eating chat-bubble text. export function stripMoiContextLoose(text: string): string { let stripped = stripMoiContext(text) const open = stripped.indexOf(MOI_CONTEXT_OPEN) if (open !== -1) stripped = stripped.slice(0, open) const reminder = stripped.indexOf(SYSTEM_REMINDER_OPEN) if (reminder !== -1) stripped = stripped.slice(0, reminder) return stripped.trimEnd() }