chore(desktop): literal comments across the onboarding flow (#108438)
* chore(desktop): literal comments in the guide script and runbooks Comment-only change to onboarding-script.ts and setup-profile.ts. The module headers now state the purpose and the constraints that shaped each file. The notes beside the runbook strings keep one fact per sentence, or are deleted when the string beside them says the same thing. The runbook text, the persona, the option pills and the SOUL text are unchanged. Both versions transpile to identical output with comments removed. * chore(desktop): literal comments in the guide chat cards and stores Comment-only change to the guided chat's cards, directive dispatcher, option catalog, assembly module and chip. Metaphor and personification are replaced by the name of the atom, effect or CSS property they stood for. Comments that restate the code are deleted. Two stale facts are corrected in place: the mini layout trees point at app/contrib/layout-presets.ts, and the skip button sets the onboarding phase to skipped rather than done. One comment line in cards/frame.tsx from bb/connector-ui-e2e-v2 loses a metaphor and an em dash; its fact is unchanged. * chore(desktop): literal comments in the handoff and first build Comment-only change to the handoff wiring, the kickoff, the receipt store, the first-build check-ins, the handoff tour, the connector rows and the machine profile store. Every kept comment names the caller, the constraint or the defect it prevents. The claim that the tour never throws is removed: the function can reject and its caller does not catch. Five comment blocks in connector-tool.tsx written on bb/connector-ui-e2e-v2 lose personification, dramatic capitals and em dashes. Every fact in them stays, and no block moves. * chore(desktop): literal comments in the intro reveal Comment-only change to the intro reveal's clock, timeline, cube renderer, sound, scenes, store and README. Animation comments now name the actual ramp, easing or offset with its number. Four comments that contradicted the code are corrected: the first texture slot opens at 3700 ms, the tear settles from 1 to 0 over 460 ms, the typing weight delays the character it sits on, and INTRO_EXIT_MS is wall time in index.tsx but score time in the overlay. * chore(desktop): literal comments in the Electron onboarding windows Comment-only change to the window growth geometry and the two onboarding windows. The 768 px floor keeps its one fact: the floor uses Math.ceil where the deltas round, because rounding 906.24 DIP down leaves the media query false. The comment that placed the CSS-pixel to DIP conversion at getBounds now points at growWindowBounds, where it happens. * chore(gateway): literal docstrings in the onboarding RPCs and the tour tool Docstring and comment-only change. The module summaries state what each module does and where authorization comes from, without contrast pairs. The tool descriptions the model reads are unchanged. Two words in the tour tool's module docstring lose personification; the rest of that docstring is as it was. ast.dump of both versions, with docstrings stripped, is identical for all three files. * chore(desktop): literal punctuation in the relaunch and film-end notes Comment-only change to four lines that bb/connector-ui-e2e-v2 added to the boot gate, the gate store and the intro gate. Each em dash becomes a colon, a full stop or a pair of parentheses; one emphasis capital is lowercased. The facts in the notes are unchanged.
This commit is contained in:
@@ -15,7 +15,7 @@ export function registerChatOnboardingWindow({ enabled, mainWindow }: ChatOnboar
|
||||
return
|
||||
}
|
||||
|
||||
// Renderer CSS pixels become native DIP here, including the user's zoom.
|
||||
// The request arrives in renderer CSS pixels; growWindowBounds converts it to DIP with the zoom factor below.
|
||||
const bounds = win.getBounds()
|
||||
|
||||
win.setBounds(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { chatWindowWebPreferences } from './session-windows'
|
||||
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
|
||||
import { createWindowRevealController } from './window-reveal'
|
||||
|
||||
// The native watchdog must outlast the renderer deadman, even if its clock stalls.
|
||||
// Longer than the renderer's INTRO_DEADMAN_MS, so the main process closes the overlay if the renderer clock stalls.
|
||||
export const INTRO_REVEAL_WATCHDOG_MS = 34_000
|
||||
const INTRO_FROST_IN_MS = 500
|
||||
const INTRO_FROST_OUT_MS = 600
|
||||
@@ -186,7 +186,7 @@ export function createIntroRevealWindowController({
|
||||
const main = mainWindow()
|
||||
|
||||
if (payload.hideMain === true && main && !main.isDestroyed()) {
|
||||
// Stamp ownership even before first paint: skip must reveal an unshown app.
|
||||
// Set before the overlay's first paint, so a skip during load still shows the main window again.
|
||||
onboardingFlowHidMain = true
|
||||
main.hide()
|
||||
}
|
||||
@@ -234,7 +234,7 @@ export function createIntroRevealWindowController({
|
||||
mainFadeTimer = null
|
||||
}
|
||||
|
||||
// Teardown must not reveal the app while it is quitting.
|
||||
// Cleared before destroy, so the 'closed' handler does not show the main window while the app quits.
|
||||
onboardingFlowHidMain = false
|
||||
introRevealWindow?.destroy()
|
||||
introRevealWindow = null
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Where the main window lands when the guided chat assembles the app around it.
|
||||
* Geometry for the main window as the guided chat grows it.
|
||||
*
|
||||
* Pure geometry, extracted from the `chat-onboarding:grow` handler so the one
|
||||
* thing that has actually gone wrong here — ending up too small — can be
|
||||
* asserted rather than eyeballed on a first run.
|
||||
* Extracted from the `chat-onboarding:grow` handler so the resulting size can be asserted in a unit test
|
||||
* instead of checked by eye on a first run.
|
||||
*/
|
||||
|
||||
import type { Rectangle } from 'electron'
|
||||
@@ -11,8 +10,7 @@ import type { Rectangle } from 'electron'
|
||||
export interface GrowRequest {
|
||||
bottom?: number
|
||||
left?: number
|
||||
/** Floor for the resulting CSS-pixel viewport width, for a layout with a
|
||||
* responsive breakpoint to clear. Optional: most growth is just deltas. */
|
||||
/** Floor for the resulting viewport width in CSS pixels, used to clear a responsive breakpoint. */
|
||||
minWidth?: number
|
||||
right?: number
|
||||
top?: number
|
||||
@@ -21,21 +19,21 @@ export interface GrowRequest {
|
||||
export interface GrowInputs {
|
||||
/** Current window bounds, frame included. */
|
||||
bounds: { height: number; width: number }
|
||||
/** Non-zero on framed platforms: `bounds.width` minus the content width. The
|
||||
* floor is about the viewport, so the frame has to be added back on top. */
|
||||
/** `bounds.width` minus the content width, non-zero on platforms that draw a window frame. `minWidth` is a
|
||||
* viewport floor, so the frame width is added to it. */
|
||||
frameWidth?: number
|
||||
/** Display work area the result is centred in and clamped to. */
|
||||
workArea: { height: number; width: number; x: number; y: number }
|
||||
/** Renderer zoom. Requests arrive in CSS pixels; windows live in DIP. */
|
||||
/** Renderer zoom factor. Requests arrive in CSS pixels; window bounds are in DIP. */
|
||||
zoom?: number
|
||||
}
|
||||
|
||||
/** Growth is bounded so a malformed request can't ask for a wall-sized window;
|
||||
* the display clamp below is the real limit. */
|
||||
/** Cap on each value converted from the request, so a malformed request cannot ask for an oversized window.
|
||||
* The work area clamp below is usually the stricter limit. */
|
||||
const MAX_DELTA_PX = 4000
|
||||
|
||||
/** Never fill the whole display — a window pinned to every edge reads as broken
|
||||
* rather than as an app that grew. */
|
||||
/** Fraction of the display work area a grown window may fill. Below 1 so the result keeps a margin instead
|
||||
* of looking maximized. */
|
||||
const MAX_WORK_AREA = 0.92
|
||||
|
||||
export function growWindowBounds(
|
||||
@@ -47,16 +45,14 @@ export function growWindowBounds(
|
||||
|
||||
const toDip = (value?: number) => dip(value, Math.round)
|
||||
|
||||
// The floor CEILS where the deltas round. Rounding a breakpoint down lands
|
||||
// fractionally under it — at 118% zoom a 768px floor becomes 906 DIP, a
|
||||
// 767.8px viewport, and the media query the floor exists to satisfy is still
|
||||
// false. Half a pixel, whole floating sidebar.
|
||||
// The floor uses Math.ceil where the deltas round to nearest. At 118% zoom a 768px floor is 906.24 DIP:
|
||||
// rounding to nearest would give 906 DIP, a 767.8px viewport, and the media query the floor exists to
|
||||
// satisfy would stay false.
|
||||
const requestedMin = dip(request?.minWidth, Math.ceil)
|
||||
const grown = bounds.width + toDip(request?.left) + toDip(request?.right)
|
||||
|
||||
// Order matters: the floor lifts, then the display clamps. A floor wider than
|
||||
// the screen loses — growing off-screen to satisfy a breakpoint would trade a
|
||||
// floating sidebar for an unusable window.
|
||||
// The floor applies before the work area clamp, so a floor wider than the display is dropped rather than
|
||||
// growing the window off-screen to satisfy the breakpoint.
|
||||
const width = Math.min(
|
||||
Math.max(grown, requestedMin ? requestedMin + frameWidth : 0),
|
||||
Math.round(workArea.width * MAX_WORK_AREA)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Failed/uncertain submits retain the original session;
|
||||
* they must never close it or start a second build. */
|
||||
/** Starts the first build in its own session. A submit that fails or is unconfirmed keeps that session:
|
||||
* it must not close the session or start a second build. */
|
||||
import { JsonRpcGatewayError } from '@hermes/shared'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
@@ -20,8 +20,8 @@ export interface HandoffTask {
|
||||
export interface HandoffReceipt extends HandoffTask {
|
||||
runtimeId: string
|
||||
storedId: string
|
||||
/** `connectionId: null` is the ambient route for the profile (a local-only
|
||||
* install, or a legacy primary with no registry id), never a missing owner. */
|
||||
/** `connectionId: null` is the ambient route for the profile: a local-only install, or a legacy primary
|
||||
* with no registry id. It does not mean the owner is unknown. */
|
||||
owner: { connectionId: null | string; profile: typeof BUILD_PROFILE }
|
||||
status: 'created' | 'submitting' | 'accepted'
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export interface HandoffDeps {
|
||||
bind: (receipt: HandoffReceipt, running: boolean, snapshot?: HandoffSnapshot) => void
|
||||
}
|
||||
|
||||
/** Only preflight refusals in methods_prompt authorize another submit. A
|
||||
* generic server error, like a lost ACK, may follow a side effect. */
|
||||
/** Only these preflight refusal codes from methods_prompt allow a second submit. A generic server error,
|
||||
* such as a lost ACK, can arrive after the prompt already started. */
|
||||
const PREFLIGHT_REJECTIONS = new Set([4001, 4004, 4009, 4018, 4090, 4091, 4120, 4121, 5070, 5071, 5072, 5122])
|
||||
|
||||
interface HydratedHandoffSnapshot extends HandoffSnapshot {
|
||||
@@ -88,9 +88,9 @@ export async function startHandoff(deps: HandoffDeps, task: HandoffTask, recover
|
||||
|
||||
receipt = { ...receipt, runtimeId: snapshot.session_id }
|
||||
|
||||
// A visible user turn in this dedicated session is durable acceptance,
|
||||
// even when the build has finished or its context has been compressed.
|
||||
// A confirmed refusal (created) cannot be overturned by a stale busy flag.
|
||||
// A visible user turn in this session records that the brief was accepted, even after the build finished
|
||||
// or its context was compressed. Status 'created' records a confirmed refusal, so a stale running flag
|
||||
// must not mark it accepted.
|
||||
if (
|
||||
(receipt.status === 'submitting' && snapshot.running) ||
|
||||
snapshot.messages.some(message => message.role === 'user' && message.display_kind !== 'hidden')
|
||||
|
||||
@@ -2,8 +2,8 @@ import { readKey, writeJson, writeKey } from '@/lib/storage'
|
||||
|
||||
import type { HandoffReceipt } from './handoff-leg'
|
||||
|
||||
// A failed disk write still remembers the original identity for this window.
|
||||
// Nothing is submitted until the next save verifies durable persistence.
|
||||
// Holds the receipt in memory when the disk write fails, so this window still has the session identity.
|
||||
// saveHandoffReceipt throws when the write does not read back, so nothing is submitted without a saved receipt.
|
||||
const unsavedReceipts = new Map<string, HandoffReceipt>()
|
||||
|
||||
export function markFirstBuildSession(storedId: string): void {
|
||||
@@ -22,7 +22,6 @@ export function isFirstBuildSession(storedId: string | null | undefined): boolea
|
||||
)
|
||||
}
|
||||
|
||||
/** A navigation/submit receipt, never a copy of either profile's memory. */
|
||||
export function handoffReceiptKey(connection: null | string, guideStoredId: string): string {
|
||||
return `hermes.onboarding.handoff.v1.connection.${encodeURIComponent(connection ?? 'ambient')}.profile.default.guide.${encodeURIComponent(guideStoredId)}`
|
||||
}
|
||||
@@ -50,8 +49,8 @@ export function readHandoffReceipt(key: string): HandoffReceipt | null {
|
||||
)
|
||||
}
|
||||
|
||||
// JSON cannot encode a constructor function: only primitive strings have
|
||||
// String as their constructor here. Validate without coercing corrupt ids.
|
||||
// JSON cannot encode a constructor, so only a primitive string has String as its constructor here.
|
||||
// Comparing constructors rejects a corrupt id instead of coercing it to text.
|
||||
const hasTextFields = [value?.storedId, value?.runtimeId, value?.task, value?.brief].every(
|
||||
field => field?.constructor === String
|
||||
)
|
||||
|
||||
@@ -50,8 +50,8 @@ export interface OnboardingHandoffOptions extends Pick<
|
||||
> {
|
||||
createBackendSessionForSend: ReturnType<typeof useSessionActions>['createBackendSessionForSend']
|
||||
requestGateway: AmbientGatewayRequest
|
||||
/** Pin creation to the target profile while the guide remains selected;
|
||||
* the caller's own requestGateway is what reads the pin. */
|
||||
/** Pins session creation to the target profile while the guide chat is still selected.
|
||||
* The caller's own requestGateway reads the pin. */
|
||||
runCreatePinnedTo: <T>(profile: string, create: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -63,13 +63,13 @@ export function useOnboardingHandoff({
|
||||
requestGateway,
|
||||
runCreatePinnedTo
|
||||
}: OnboardingHandoffOptions) {
|
||||
// The receipt survives failure and relaunch; only a confirmed go signal
|
||||
// completes onboarding. Never fall back to building in the guide chat.
|
||||
// The saved receipt survives a failed handoff and a relaunch. Onboarding completes only after the build
|
||||
// session confirms its start.
|
||||
const setupHandoff = useStore($setupHandoff)
|
||||
const selectedStoredId = useStore($selectedStoredSessionId)
|
||||
|
||||
// Resume only an EXISTING receipt when the welcome chat is reopened after
|
||||
// relaunch. Replayed directives stay inert; recovery never mints a new build.
|
||||
// Resume an existing receipt when the welcome chat is reopened after a relaunch. Recovery reads the saved
|
||||
// receipt only; it never creates a new build session.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isOnboardingEnabled() ||
|
||||
@@ -149,8 +149,8 @@ export function useOnboardingHandoff({
|
||||
const { key: receiptKey, receipt: saved } = readGuideHandoffReceipt(setupSession.storedId)
|
||||
receipt = saved
|
||||
const owner: HandoffReceipt['owner'] = receipt?.owner ?? { connectionId, profile: BUILD_PROFILE }
|
||||
// Save facts before session.create freezes the new agent's memory.
|
||||
// A retry never re-creates the session or copies the guide's memory.
|
||||
// personalize runs before session.create because the new agent's memory is fixed at creation time.
|
||||
// A retry reuses the saved receipt; it never creates a second session or copies the guide's memory.
|
||||
receipt = await startHandoff(
|
||||
{
|
||||
read: () => receipt,
|
||||
@@ -234,7 +234,7 @@ export function useOnboardingHandoff({
|
||||
value.storedId
|
||||
)
|
||||
|
||||
// Background recovery must not steal focus.
|
||||
// Recovery in the background must not change which session is active.
|
||||
if ($selectedStoredSessionId.get() === value.storedId) {
|
||||
activeSessionIdRef.current = value.runtimeId
|
||||
setActiveSessionId(value.runtimeId)
|
||||
@@ -246,8 +246,8 @@ export function useOnboardingHandoff({
|
||||
setupHandoff
|
||||
)
|
||||
|
||||
// Create's title is pending metadata on older backends.
|
||||
// Title after acceptance so naming cannot gate submission.
|
||||
// Older backends return the title from session.create as pending metadata, so the title is set here,
|
||||
// after acceptance. A failed session.title call then cannot stop the prompt from being submitted.
|
||||
const chatTitle = firstTaskTitle(receipt.task)
|
||||
await request(receipt.owner, 'session.title', { session_id: receipt.runtimeId, title: chatTitle }).catch(
|
||||
error => console.warn('[handoff] title could not be saved', error)
|
||||
@@ -270,7 +270,7 @@ export function useOnboardingHandoff({
|
||||
activateTreePane(sessionsGroup.id, 'sessions')
|
||||
}
|
||||
|
||||
// This is an informational success note, never an alternate build.
|
||||
// The note tells the guide chat that the build started. It does not start a second build.
|
||||
void requestGatewayForAgent(
|
||||
connectionId,
|
||||
setupSession.profile ?? BUILD_PROFILE,
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface OnboardingKickoffOptions extends Pick<
|
||||
'createBackendSessionForSend' | 'resumeSession'
|
||||
> {
|
||||
requestGateway: AmbientGatewayRequest
|
||||
/** The caller's own requestGateway is what reads the pin. */
|
||||
/** The caller's own requestGateway reads the pin. */
|
||||
runCreatePinnedTo: <T>(profile: string, create: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ async function adoptGuideSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the runbook and banked greeting on hermes-setup before advancing the phase.
|
||||
* The seeded assistant row opens the chat without a model turn. */
|
||||
/** Seeds the runbook and a pre-written greeting on hermes-setup before the phase advances.
|
||||
* The seeded assistant row shows the chat's first message without a model turn. */
|
||||
export function useOnboardingKickoff({
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
@@ -124,8 +124,8 @@ export function useOnboardingKickoff({
|
||||
const guideRequest: AmbientGatewayRequest = (method, params, timeout) =>
|
||||
requestGatewayForProfile(SETUP_PROFILE, method, params, timeout)
|
||||
|
||||
// The exact title is the durable registry: a relaunch adopts the guide
|
||||
// before creating, so UNIQUE(title) cannot strand an untitled duplicate.
|
||||
// Look the guide up by its exact title: a relaunch adopts the existing guide session before creating
|
||||
// one, so the backend's UNIQUE(title) constraint cannot leave an untitled duplicate behind.
|
||||
const registryHit = await guideRequest<{ sessions?: GuideSession[] }>('session.list', {
|
||||
include_hidden: true,
|
||||
title: SETUP_CHAT_TITLE
|
||||
@@ -163,10 +163,10 @@ export function useOnboardingKickoff({
|
||||
storedId
|
||||
})
|
||||
|
||||
// Manual title authority prevents the hidden runbook becoming the title.
|
||||
// Set the title explicitly so the backend does not name the session after the hidden runbook message.
|
||||
await guideRequest('session.title', { session_id: runtimeId, title: SETUP_CHAT_TITLE }).catch(() => undefined)
|
||||
|
||||
// session.create persisted both seed rows before the phase can advance.
|
||||
// session.create has already persisted both seed rows, so the caller may advance the phase.
|
||||
return true
|
||||
} catch (error) {
|
||||
$newChatProfile.set(previousNewChatProfile)
|
||||
|
||||
@@ -29,14 +29,14 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
const firstBuild = isFirstBuildSession(storedId)
|
||||
|
||||
// One live card per offer. Every manage_connections call renders through
|
||||
// here, but only ONE is the card the user acts on; the rest are settled
|
||||
// tool rows. Which one: consecutive calls naming the same apps are one
|
||||
// exchange — connect, the wait the agent parks in while the user signs in,
|
||||
// the status it runs when the connection lands — and the FIRST of the last
|
||||
// exchange is the card. The newest would demote the card mid-authorization
|
||||
// into a row and mint a fresh one below it. A catalog listing (status with
|
||||
// nothing named) after a targeted ask never starts an exchange: it is a
|
||||
// read, not an offer.
|
||||
// here, but only one of them is the card the user acts on; the rest render
|
||||
// as settled tool rows. Consecutive calls naming the same apps are one
|
||||
// exchange: connect, the wait the agent stays in while the user signs in,
|
||||
// and the status it runs once the connection is active. The card is the
|
||||
// first call of the last exchange. Using the newest call would turn the
|
||||
// card into a row during authorization and create a new card below it. A
|
||||
// catalog listing (status with nothing named) after a targeted ask never
|
||||
// starts an exchange; it reads state and offers nothing.
|
||||
const offers = messages
|
||||
.flatMap(message => message.parts)
|
||||
.filter(
|
||||
@@ -87,8 +87,8 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
}
|
||||
|
||||
const historical = liveId !== props.toolCallId
|
||||
// A status call with no target list describes the whole catalog. That is an
|
||||
// answer for the model, not an offer to the user: rendering it as rows put a
|
||||
// A status call with no target list describes the whole catalog. It answers
|
||||
// the model's question, so it renders as a tool row; as cards it would put a
|
||||
// Connect button on every app the gateway knows.
|
||||
const input = recordOf(props.args)
|
||||
|
||||
@@ -97,8 +97,8 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
(input.action ?? 'status') === 'status' &&
|
||||
!(Array.isArray(input.connectors) && input.connectors.length > 0)
|
||||
|
||||
// Neither kind of part owns the live offer, so neither resolves an owner or
|
||||
// polls the gateway.
|
||||
// Neither kind of part is the live offer, so neither resolves a session
|
||||
// owner nor polls the gateway.
|
||||
const inert = historical || untargetedStatus
|
||||
|
||||
const [owner, setOwner] = useState<{
|
||||
@@ -145,13 +145,13 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
const signature = rows.map(row => row.connector).join('|')
|
||||
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
|
||||
|
||||
// The TUI shape, stolen: the agent parks inside manage_connections
|
||||
// The same shape as the TUI: the agent stays inside manage_connections
|
||||
// action="wait", which blocks the turn and polls the gateway, instead of
|
||||
// deciding what "not connected" means and building around the app. Each
|
||||
// card action sends one hidden line so the agent takes the right next call.
|
||||
// Read through a ref so the flow (memoised on identity) always nudges the
|
||||
// live composer target, never the one it was built with. Busy is the
|
||||
// composer's problem: a hidden request mid-turn steers or queues there.
|
||||
// Read through a ref so the flow, memoised on identity, always submits to
|
||||
// the current composer target rather than the one it was built with. The
|
||||
// composer handles busy: a hidden request mid-turn steers or queues there.
|
||||
const nudgeRef = useRef((_text: string) => {})
|
||||
|
||||
nudgeRef.current = (text: string) => {
|
||||
@@ -236,7 +236,7 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
|
||||
interface ConnectorOfferProps {
|
||||
flow: ReturnType<typeof createConnectorFlow>
|
||||
/** The user waved the app off. */
|
||||
/** Called when the user declines the app with Not now. */
|
||||
onSkipped: (slug: string) => void
|
||||
}
|
||||
|
||||
@@ -271,9 +271,9 @@ export function ConnectorOffer({ flow, onSkipped }: ConnectorOfferProps) {
|
||||
|
||||
const rows = state.rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
|
||||
// A targeted ask ("connect Gmail") is one or two cards, each already a
|
||||
// complete question. A heading, a disclaimer and a refresh control over
|
||||
// them is a settings panel dropped into the chat. Only a real catalog — the
|
||||
// model asked for status with nothing named — earns the chrome.
|
||||
// complete question. A heading, a disclaimer and a refresh control over them
|
||||
// read as a settings panel inside the chat. Only a catalog listing, which
|
||||
// the model gets by asking for status with nothing named, shows that chrome.
|
||||
const catalog = state.rows.length > 4
|
||||
|
||||
return (
|
||||
@@ -322,8 +322,8 @@ export function ConnectorOffer({ flow, onSkipped }: ConnectorOfferProps) {
|
||||
const wasPending = ['opening', 'waiting'].includes(row.phase)
|
||||
flow.skip(row.connector)
|
||||
|
||||
// A cancel mid-authorization is not a skip: the agent may be
|
||||
// parked in wait and will hear the timeout itself.
|
||||
// A cancel mid-authorization is not a skip: the agent may
|
||||
// still be in wait, which reports the timeout to it.
|
||||
if (!wasPending) {
|
||||
onSkipped(row.connector)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Intro reveal
|
||||
|
||||
An idealized chat types a request, works through tools and a cube viewport,
|
||||
streams a reply, expands into parallel agents, then closes on the brand.
|
||||
The seven beats take 22 seconds, followed by a 900 ms dissolve. Reduced motion
|
||||
shows the brand briefly. Sound defaults on and respects the haptics mute preference.
|
||||
Fonts are the existing Collapse and JetBrains Mono faces.
|
||||
A scripted chat types a request, runs tool rows beside a cube viewport, streams a
|
||||
reply, expands into parallel agents, then ends on the brand. The seven beats take
|
||||
22 seconds of wall time, followed by a 900 ms dissolve. Reduced motion shows the
|
||||
brand briefly instead. Sound is on by default and respects the haptics mute
|
||||
preference. Fonts are the existing Collapse and JetBrains Mono faces.
|
||||
|
||||
Eligibility is `guestOnboardingEnabled && !firstRunSkipped && !hasSeenIntroReveal()`.
|
||||
Electron sets the flag from `HERMES_GUEST_ONBOARDING=1` or `--guest-onboarding`.
|
||||
@@ -13,18 +13,19 @@ free-tier notice as the cinematic starts. With the flag off, neither gate starts
|
||||
|
||||
| Piece | Path |
|
||||
| --- | --- |
|
||||
| Main-window gate and conductor | `index.tsx` |
|
||||
| Phase, seen-key ownership and IPC listeners | `../../store/intro-reveal.ts` |
|
||||
| Main-window gate and phase timers | `index.tsx` |
|
||||
| Phase, seen key and IPC listeners | `../../store/intro-reveal.ts` |
|
||||
| Overlay boot and surface | `intro-root.tsx`, `intro-reveal-surface.tsx` |
|
||||
| Clock, score, cube and synthesized sound | `use-intro-clock.ts`, `timeline.ts`, `viewport-cube.ts`, `sound.ts` |
|
||||
| Constellation, brand and text effects | `scenes/` |
|
||||
| Native window and preload bridge | `../../../electron/intro-reveal-window.ts`, `../../../electron/preload.ts` |
|
||||
|
||||
The transparent native window (`?win=intro`) covers the primary display while
|
||||
the main app hides. The overlay owns the clock because the hidden main renderer's
|
||||
animation frames are throttled. The main renderer owns the phase and seen key.
|
||||
The transparent native window (`?win=intro`) covers the primary display while the
|
||||
main app is hidden. The clock runs in the overlay because the hidden main
|
||||
renderer's animation frames are throttled. The phase and the seen key live in the
|
||||
main renderer.
|
||||
|
||||
The screen must ALWAYS come back. Four independent layers:
|
||||
The screen must always come back. Four independent layers:
|
||||
|
||||
1. Normal completion: the overlay clock finishes → main renderer closes it.
|
||||
2. Esc/click: local close with a 1.2s fallback that bypasses the main renderer.
|
||||
|
||||
@@ -38,7 +38,7 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
|
||||
|
||||
// Observe the store edge directly: a failed native open can finish before
|
||||
// React renders the playing phase. Take the guide's shape on the same
|
||||
// tick — finishIntroReveal shows the main window right after this fires.
|
||||
// tick: finishIntroReveal shows the main window right after this fires.
|
||||
return $introReveal.listen((state, previous) => {
|
||||
if (state.phase === 'hidden' && previous?.phase !== 'hidden') {
|
||||
queueGuideAfterIntro()
|
||||
@@ -54,7 +54,7 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
|
||||
}
|
||||
}, [enabled, intro.phase, onboarding.firstRunSkipped])
|
||||
|
||||
// The native surface owns rAF: the hidden main renderer's clock is throttled.
|
||||
// The native surface runs the frame loop: the hidden main renderer's animation frames are throttled.
|
||||
useEffect(() => {
|
||||
if (intro.phase === 'hidden') {
|
||||
return
|
||||
|
||||
@@ -13,9 +13,9 @@ export function mountIntroReveal(): void {
|
||||
}
|
||||
|
||||
document.title = 'Hermes'
|
||||
// The intro fills a display the user sits back from; the app's 16 px root
|
||||
// is sized for a working window. Every intro measure is in rem, so one
|
||||
// root scale keeps the composition proportional (director: legibility).
|
||||
// Every intro measure is in rem, so this one root size scales the whole
|
||||
// composition. The app's default 16 px root is sized for a working window, which
|
||||
// is too small on a display the user sits back from.
|
||||
document.documentElement.style.fontSize = '150%'
|
||||
const root = document.getElementById('root')
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
// Hermes blue — the app's --theme-primary (#0053fd), lifted for dark ground.
|
||||
// Hermes blue: the app's --theme-primary (#0053fd), lightened for a dark background.
|
||||
export const BLUE = '#4d8dff'
|
||||
export const BLUE_DIM = 'rgba(77, 141, 255, 0.55)'
|
||||
export const BLUE_FAINT = 'rgba(77, 141, 255, 0.4)'
|
||||
|
||||
// One shadow for every floating surface — --shadow-nous's recipe (single top
|
||||
// light, layered contact→ambient, x=0, negative spread pulling each layer
|
||||
// inward) restated for a dark ground at LOW opacity, so cards sit on the
|
||||
// frost instead of dragging black halos across it.
|
||||
// One shadow for every floating surface. It follows --shadow-nous (single top
|
||||
// light, layered contact to ambient, x = 0, negative spread on each layer) at
|
||||
// lower opacity for the dark background, so cards do not cast black halos over
|
||||
// the frost.
|
||||
export const NOUS_SHADOW =
|
||||
'0 2px 4px -2px rgba(0,0,0,0.3), 0 8px 12px -6px rgba(0,0,0,0.24), 0 20px 28px -14px rgba(0,0,0,0.2), 0 36px 48px -28px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.05)'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Beat scheduling keeps synthesized audio and animation on one clock. */
|
||||
/** Synthesized cues for the intro. The clock in use-intro-clock.ts calls them on the score's beats. */
|
||||
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
@@ -95,7 +95,7 @@ export function startPad(): IntroPad {
|
||||
setLevel: v => {
|
||||
const t = ac.currentTime
|
||||
|
||||
// The filter opens with the swell so the chord brightens as it rises.
|
||||
// The lowpass cutoff rises with the level, so the chord brightens as it swells.
|
||||
lp.frequency.cancelScheduledValues(t)
|
||||
lp.frequency.setTargetAtTime(600 + v * 900, t, 0.5)
|
||||
g.gain.setTargetAtTime(v * 0.11, t, 0.35)
|
||||
|
||||
@@ -25,14 +25,12 @@ export const INTRO_TOTAL_MS = 17600
|
||||
|
||||
export const INTRO_WALL_MS = Math.round(INTRO_TOTAL_MS * INTRO_PACE)
|
||||
|
||||
/** Exit dissolve window after the sequence — kept in one place so the surface
|
||||
* fade and the window self-close agree. A real CSS transition, so it is wall
|
||||
* time and the pace does not touch it. */
|
||||
/** Exit dissolve window after the sequence. index.tsx uses it as a wall-time delay before closing the overlay.
|
||||
* use-intro-clock.ts adds it to score time for the brand fade and the loop cutoff, so INTRO_PACE scales it there. */
|
||||
export const INTRO_EXIT_MS = 900
|
||||
|
||||
/** Overlay deadman margin: the surface force-closes its own window this long
|
||||
* after the nominal end even if the clock stalls, and the main process holds
|
||||
* an independent watchdog above that. The screen ALWAYS comes back. */
|
||||
/** Overlay deadman margin: the surface force-closes its own window this long after the nominal end, even if
|
||||
* the clock stalls. The main process holds an independent watchdog above this. */
|
||||
export const INTRO_DEADMAN_MS = INTRO_WALL_MS + 4000
|
||||
|
||||
export function sampleCurves(t: number) {
|
||||
@@ -60,9 +58,8 @@ export const INTRO_PROMPT = 'Model a hero cube in Blender and cycle it through s
|
||||
export const INTRO_REPLY_WORDS =
|
||||
'Done — materials compiled and previewed on the cube. Want a turntable render exported?'.split(' ')
|
||||
|
||||
/** Tool activity rows that materialize during `working`. `doneAt` flips the
|
||||
* trailing status from running to the check state. Times are absolute
|
||||
* sequence ms so the whole piece stays on one clock. */
|
||||
/** Tool activity rows that appear during the `working` beat. `doneAt` changes the trailing status from
|
||||
* running to done. Times are absolute sequence ms, so the whole piece stays on one clock. */
|
||||
export interface IntroToolRow {
|
||||
at: number
|
||||
doneAt: number
|
||||
@@ -99,9 +96,8 @@ export const INTRO_TOOL_ROWS: IntroToolRow[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/** Per-character reveal times for the typed prompt: human cadence (variable
|
||||
* inter-key delays, tiny pauses after spaces), deterministic via a seeded
|
||||
* LCG so every run is identical and there is nothing to jitter. */
|
||||
/** Per-character reveal times for the typed prompt. Delays vary between keys, with longer pauses after
|
||||
* spaces and punctuation, and come from a seeded LCG so every run is identical. */
|
||||
export function typingSchedule(text: string, startMs: number, endMs: number): number[] {
|
||||
let seed = 1337
|
||||
|
||||
@@ -114,7 +110,7 @@ export function typingSchedule(text: string, startMs: number, endMs: number): nu
|
||||
const weights = Array.from(text, ch => {
|
||||
const base = 1 + rand() * 1.1
|
||||
|
||||
// Breathe after word boundaries; hesitate slightly on punctuation.
|
||||
// The extra weight on a space or a punctuation mark is the delay before that character appears.
|
||||
if (ch === ' ') {
|
||||
return base + 0.9
|
||||
}
|
||||
@@ -139,8 +135,8 @@ export function typingSchedule(text: string, startMs: number, endMs: number): nu
|
||||
return times
|
||||
}
|
||||
|
||||
/** Word reveal times for the streaming reply — front-loaded like real token
|
||||
* streaming (fast burst, gentle tail). */
|
||||
/** Word reveal times for the streaming reply. easeOutQuad on the index, so early words arrive quicker than
|
||||
* late ones. */
|
||||
export function streamingSchedule(wordCount: number, startMs: number, endMs: number): number[] {
|
||||
const times: number[] = []
|
||||
const span = endMs - startMs
|
||||
@@ -148,16 +144,14 @@ export function streamingSchedule(wordCount: number, startMs: number, endMs: num
|
||||
for (let i = 0; i < wordCount; i += 1) {
|
||||
const f = (i + 1) / wordCount
|
||||
|
||||
// easeOutQuad on the index → early words arrive quicker.
|
||||
times.push(startMs + (1 - (1 - f) * (1 - f)) * span)
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
/** Beats that land within (prevT, t] — used to fire sound cues exactly once
|
||||
* even when rAF cadence is irregular. Pass prevT = -1 on the first frame so
|
||||
* the t=0 beat fires. */
|
||||
/** Beats in the half-open range (prevT, t]. Callers fire each sound cue once even when the rAF cadence is
|
||||
* irregular. Pass prevT = -1 on the first frame so the beat at t = 0 fires. */
|
||||
export function beatsBetween(prevT: number, t: number): IntroBeat[] {
|
||||
return INTRO_BEATS.filter(b => b.t > prevT && b.t <= t)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const WORD_TIMES = streamingSchedule(INTRO_REPLY_WORDS.length, REPLY_T + 150, RE
|
||||
interface Frame {
|
||||
beat: number
|
||||
replyWords: number
|
||||
/** 45ms quantized clock — drives braille spinners + scramble decodes. */
|
||||
/** 45ms quantized clock. Drives the braille spinners and the scramble decodes. */
|
||||
tick: number
|
||||
toolDone: number // bitmask
|
||||
toolShown: number // bitmask
|
||||
@@ -130,10 +130,9 @@ export function useIntroClock() {
|
||||
const pad = startPad()
|
||||
const start = performance.now()
|
||||
let prevT = -1
|
||||
// `start` is wall time; everything downstream of `elapsed` is score time.
|
||||
// Dividing once, here, is what makes the whole piece — beats, schedules,
|
||||
// the cube's rotation and tear — play at INTRO_PACE with nothing else to
|
||||
// keep in step.
|
||||
// `start` is wall time; everything downstream of `elapsed` is score time. This
|
||||
// one division is what makes the beats, the schedules and the cube all play at
|
||||
// INTRO_PACE.
|
||||
const elapsed = () => (performance.now() - start) / INTRO_PACE
|
||||
let raf = 0
|
||||
let currentBeat = 0
|
||||
@@ -173,10 +172,10 @@ export function useIntroClock() {
|
||||
return f * f * (3 - 2 * f)
|
||||
}
|
||||
|
||||
// The stage never sits still: a slow drift-up across the whole piece,
|
||||
// a gentle scale breath, and a lateral ease as the constellation opens
|
||||
// (hero sits slightly left once the side agents arrive — asymmetric,
|
||||
// not centered). All one transform, compositor-only.
|
||||
// The stage transform combines a drift up across the whole piece, a scale
|
||||
// oscillation of 0.4%, and a lateral shift of -18px as the constellation
|
||||
// opens, which leaves the hero left of centre once the side agents arrive.
|
||||
// One transform, so the whole stage stays on the compositor.
|
||||
if (stageRef.current) {
|
||||
const rise = -10 - ss(0, INTRO_TOTAL_MS) * 26
|
||||
const breathe = 1 + Math.sin(t / 2600) * 0.004
|
||||
@@ -188,10 +187,9 @@ export function useIntroClock() {
|
||||
stageRef.current.style.opacity = String(1 - brandPush)
|
||||
}
|
||||
|
||||
// The ENTIRE brand close (glow + badge + wordmark + tagline) rides ONE
|
||||
// alpha so nothing is ever readable against a half-faded bloom. It
|
||||
// rises in with the glow and the whole group breathes out together
|
||||
// through the exit window.
|
||||
// The glow, badge, wordmark and tagline share one alpha, so no part of the
|
||||
// brand close is readable against a half-faded glow. The group fades in with
|
||||
// the glow and fades out through the exit window.
|
||||
const brandIn = ss(BRAND_T - 200, BRAND_T + 1300)
|
||||
const brandOut = 1 - ss(INTRO_TOTAL_MS - 500, INTRO_TOTAL_MS + INTRO_EXIT_MS - 100)
|
||||
const brandAlpha = brandIn * brandOut
|
||||
@@ -224,7 +222,7 @@ export function useIntroClock() {
|
||||
}
|
||||
}, [reduceMotion, skip])
|
||||
|
||||
// ── Esc to skip (local — never depends on the main renderer). ───────────
|
||||
// Esc to skip, handled here so it does not depend on the main renderer.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
/** Canvas geometry and texture noise derive from score time so every playback agrees. */
|
||||
/** Canvas geometry and texture noise derive from score time, so every playback draws the same frames. */
|
||||
|
||||
import { INTRO_BEATS } from './timeline'
|
||||
|
||||
const N = 4
|
||||
|
||||
/** How long the cube is alive. The surface stops drawing here, so the last
|
||||
* slot's tear-out is timed against it. */
|
||||
/** End of the cube's draw window. use-intro-clock.ts stops calling drawViewport at this time. */
|
||||
export const VIEWPORT_END_MS = INTRO_BEATS.find(b => b.id === 'everywhere')!.t + 700
|
||||
|
||||
/**
|
||||
* Materials are PLACED, not cycled. A round robin made the mark whichever slot
|
||||
* the modulo happened to land on — five materials at 2.1s each put her 4.5s
|
||||
* after the node appeared, i.e. most of the way through the cube's life. These
|
||||
* are cues like every other schedule in the sequence: she arrives as the node
|
||||
* does, the harder materials fill the middle, and she comes back to tear
|
||||
* herself apart as the scene changes.
|
||||
*/
|
||||
/** Each material has an explicit start time, so the two `texture` slots sit at chosen moments in the
|
||||
* sequence. Cycling the list at a fixed interval placed them wherever the modulo fell. */
|
||||
const VIEWPORT_SCHEDULE = [
|
||||
{ at: 0, mode: 'standard' },
|
||||
{ at: 1900, mode: 'metal' },
|
||||
@@ -37,8 +30,8 @@ export interface ViewportSlot {
|
||||
until: number
|
||||
}
|
||||
|
||||
/** The material showing at `t`, with the window it occupies — the caller needs
|
||||
* the bounds for the crossfade, the tear ramps and the label's decode. */
|
||||
/** The material showing at `t`, with the start and end of its window. Callers use the bounds for the
|
||||
* crossfade, the tear ramps and the label's decode. */
|
||||
export function viewportSlot(t: number): ViewportSlot {
|
||||
let index = 0
|
||||
|
||||
@@ -64,9 +57,8 @@ interface Quad {
|
||||
cell: [number, number]
|
||||
}
|
||||
|
||||
/** Subdivided cube quads, rotated + projected. Always a true cube — the
|
||||
* subdivision exists so per-face shading has facets to work with, and so the
|
||||
* texture pass has small enough cells for an affine map to pass for one. */
|
||||
/** Subdivided cube quads, rotated and projected. The subdivision keeps each texture cell small enough that
|
||||
* an affine map reads as a perspective one. */
|
||||
function cubeQuads(t: number, w: number, h: number): Quad[] {
|
||||
const rx = t * 0.00042
|
||||
const ry = t * 0.00071
|
||||
@@ -74,7 +66,7 @@ function cubeQuads(t: number, w: number, h: number): Quad[] {
|
||||
const sx = Math.sin(rx)
|
||||
const cy = Math.cos(ry)
|
||||
const sy = Math.sin(ry)
|
||||
// Roomy: the cube never grazes the viewport frame.
|
||||
// Scaled off the smaller side so the cube stays clear of the viewport frame.
|
||||
const scale = Math.min(w, h) * 0.24
|
||||
const quads: Quad[] = []
|
||||
|
||||
@@ -143,13 +135,10 @@ function cubeQuads(t: number, w: number, h: number): Quad[] {
|
||||
return quads.sort((a, b) => a.z - b.z)
|
||||
}
|
||||
|
||||
// ── Texture pass ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// The mark itself, mapped onto the cube, torn apart on the way in and out.
|
||||
// The RGB rip is a real channel separation: the cube renders once, is split
|
||||
// into red/green/blue, and the three are re-composited with 'lighter' at
|
||||
// diverging offsets. At zero offset they sum back to the untouched image, so
|
||||
// "settled" costs nothing extra to express — the glitch IS the offset.
|
||||
// Texture pass: the image mapped onto the cube, with an RGB channel separation.
|
||||
// The cube renders once, is split into red, green and blue copies, and the three
|
||||
// are re-composited with 'lighter' at diverging offsets. At zero offset they sum
|
||||
// back to the untouched image.
|
||||
|
||||
const CHANNEL_TINTS = ['#ff0000', '#00ff00', '#0000ff'] as const
|
||||
const SLICES = 14
|
||||
@@ -157,8 +146,8 @@ const SLICES = 14
|
||||
let texture: HTMLImageElement | null = null
|
||||
let textureRequested = false
|
||||
|
||||
/** Kicks the load on first use, then answers from memory. Null until decoded,
|
||||
* which the caller reads as "paint the resting material instead". */
|
||||
/** Starts the image load on the first call and returns the cached image afterwards. Returns null until the
|
||||
* image decodes, and paintTexturedCube draws nothing on those frames. */
|
||||
function textureImage(): HTMLImageElement | null {
|
||||
if (textureRequested || globalThis.document === undefined) {
|
||||
return texture
|
||||
@@ -172,10 +161,10 @@ function textureImage(): HTMLImageElement | null {
|
||||
texture = img
|
||||
}
|
||||
|
||||
// The cinematic's own cut of the mark, not `nous-girl.jpg` — that one is the
|
||||
// BrandMark tile art (dark on white) and reads as a solid white block once
|
||||
// it is wrapped around a cube. This one is light-on-dark line work, so the
|
||||
// cube keeps the viewport's depth and the channel split has edges to tear.
|
||||
// Not `nous-girl.jpg`: that asset is the BrandMark tile art, dark on white, and
|
||||
// reads as a solid white block once it is wrapped around a cube. This one is
|
||||
// light-on-dark line work, so the faces keep their shading and the channel
|
||||
// split has edges to offset.
|
||||
img.src = `${import.meta.env.BASE_URL}intro-nous-girl.png`
|
||||
|
||||
return null
|
||||
@@ -183,8 +172,8 @@ function textureImage(): HTMLImageElement | null {
|
||||
|
||||
const scratch = new Map<string, HTMLCanvasElement>()
|
||||
|
||||
/** A cleared offscreen at device resolution. `scale` bakes in the DPR so
|
||||
* callers keep drawing in the same CSS pixels the quads are projected into. */
|
||||
/** A cleared offscreen context at device resolution. `scale` applies the DPR, so callers keep drawing in the
|
||||
* same CSS pixels the quads are projected into. */
|
||||
function buffer(key: string, w: number, h: number, scale: number): CanvasRenderingContext2D {
|
||||
let canvas = scratch.get(key)
|
||||
|
||||
@@ -208,19 +197,18 @@ function buffer(key: string, w: number, h: number, scale: number): CanvasRenderi
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Deterministic value noise — the tear has to replay identically. */
|
||||
/** Deterministic value noise. The tear has to replay identically on every playback. */
|
||||
function hash(n: number): number {
|
||||
const s = Math.sin(n * 12.9898) * 43758.5453
|
||||
|
||||
return s - Math.floor(s)
|
||||
}
|
||||
|
||||
/** 0 settled, 1 fully torn. Rips in, holds mostly clean with stutters, rips
|
||||
* out — so the mark resolves long enough to be read before it comes apart. */
|
||||
/** 0 is settled, 1 is fully torn. Starts fully torn and settles over the first 460ms, stutters at random
|
||||
* during the hold, then tears out over the last 420ms of the slot. */
|
||||
function tearAmount(local: number, span: number): number {
|
||||
const arriving = 1 - Math.min(1, local / 460)
|
||||
const leaving = Math.max(0, (local - (span - 420)) / 420)
|
||||
// A new draw every 90ms, and most of them are nothing.
|
||||
const step = Math.floor(local / 90)
|
||||
const stutter = hash(step) > 0.88 ? hash(step * 1.7) * 0.5 : 0
|
||||
|
||||
@@ -229,8 +217,8 @@ function tearAmount(local: number, span: number): number {
|
||||
|
||||
let scanPattern: CanvasPattern | null = null
|
||||
|
||||
/** CRT line grille. Built once — it is painted under the buffer's DPR
|
||||
* transform, so it holds a constant weight in CSS pixels at any scale. */
|
||||
/** Scanline pattern of one dark row in three, built once. It is filled under the buffer's DPR transform, so
|
||||
* the lines keep a constant weight in CSS pixels at any scale. */
|
||||
function scanlines(ctx: CanvasRenderingContext2D): CanvasPattern | null {
|
||||
if (!scanPattern) {
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -278,8 +266,8 @@ function paintTexturedCube(
|
||||
|
||||
const local = t - slot.at
|
||||
|
||||
// The surface hands us a DPR-scaled context and CSS-pixel geometry. Match it
|
||||
// on the offscreens, or the whole pass renders at 1x and gets upscaled.
|
||||
// The surface passes a DPR-scaled context and CSS-pixel geometry. The offscreens
|
||||
// use the same DPR, otherwise this pass renders at 1x and is upscaled.
|
||||
const dpr = ctx.getTransform().a || 1
|
||||
const dw = Math.ceil(w * dpr)
|
||||
const dh = Math.ceil(h * dpr)
|
||||
@@ -291,10 +279,9 @@ function paintTexturedCube(
|
||||
|
||||
for (const q of quads) {
|
||||
const [p0, p1, , p3] = q.pts
|
||||
// Cells are clipped, and two clips meeting on an edge each antialias to
|
||||
// half cover — which on a white texture reads as a grey hairline grid.
|
||||
// Overlapping them instead is free: the texture is opaque and drawn back
|
||||
// to front, so a later cell simply repaints the seam.
|
||||
// Two clips that meet on an edge each antialias to half cover, which on a
|
||||
// white texture reads as a grey hairline grid. The cells overlap instead: the
|
||||
// texture is opaque and drawn back to front, so a later cell repaints the seam.
|
||||
const poly = inflate(q.pts, 0.6)
|
||||
|
||||
cube.save()
|
||||
@@ -307,31 +294,28 @@ function paintTexturedCube(
|
||||
|
||||
cube.closePath()
|
||||
cube.clip()
|
||||
// The mark is light-on-dark line work, so the face needs a body of its own
|
||||
// first — otherwise the cube's unlit areas are the same black as the
|
||||
// viewport behind it and the solid dissolves into stray white curves. This
|
||||
// also repaints the inflated overlap opaque before the line work lands.
|
||||
// The image is light-on-dark line work, so each face needs an opaque fill
|
||||
// first. Without it the cube's unlit areas are the same black as the viewport
|
||||
// behind it and only stray white curves show. The fill also covers the
|
||||
// inflated overlap before the line work is drawn.
|
||||
cube.fillStyle = `rgb(${12 + q.shade * 20}, ${13 + q.shade * 22}, ${17 + q.shade * 28})`
|
||||
cube.fillRect(0, 0, w, h)
|
||||
// Affine map from the unit cell to this quad. It ignores the fourth
|
||||
// corner, which is what makes the texture swim slightly across a face —
|
||||
// PS1 warping, and exactly the register this pass is going for.
|
||||
// Affine map from the unit cell to this quad. It ignores the fourth corner, so
|
||||
// the texture swims slightly across a face (affine texture warping).
|
||||
cube.transform(p1[0] - p0[0], p1[1] - p0[1], p3[0] - p0[0], p3[1] - p0[1], p0[0], p0[1])
|
||||
// Add the line work rather than painting over: on this art black is empty,
|
||||
// so 'lighter' IS the lambert — a grazing face contributes less light.
|
||||
// 'lighter' adds the line work instead of covering the fill: black in the
|
||||
// image contributes nothing, and the alpha below scales with the face's shade.
|
||||
cube.globalCompositeOperation = 'lighter'
|
||||
cube.globalAlpha = 0.55 + q.shade * 0.45
|
||||
cube.drawImage(img, q.cell[0] * sw, q.cell[1] * sh, sw, sh, -0.06, -0.06, 1.12, 1.12)
|
||||
cube.restore()
|
||||
}
|
||||
|
||||
// ── CRT pass, inside the cube's own alpha so none of it touches the empty
|
||||
// space around the solid. Both ride the channel split below, so the rip
|
||||
// tears the grille along with the mark rather than sliding over it.
|
||||
// CRT pass, drawn with 'source-atop' so it stays inside the cube's own alpha and
|
||||
// does not touch the empty space around the solid. It is composited before the
|
||||
// channel split below, so the split offsets the sweep and the grille too.
|
||||
cube.globalCompositeOperation = 'source-atop'
|
||||
|
||||
// A read head sweeping the solid: the brightest thing in the viewport, and
|
||||
// what sells the cube as a projection rather than a painted object.
|
||||
const sweep = ((local % 1150) / 1150) * 1.3 - 0.15
|
||||
const bar = cube.createLinearGradient(0, (sweep - 0.13) * h, 0, (sweep + 0.13) * h)
|
||||
|
||||
@@ -349,13 +333,13 @@ function paintTexturedCube(
|
||||
}
|
||||
|
||||
const step = Math.floor(local / 90)
|
||||
// A sliver of separation survives the settle, so even the held frames carry
|
||||
// a little instability rather than snapping to a clean print.
|
||||
// The 0.7 term keeps a minimum separation, so held frames still show a small
|
||||
// offset instead of a clean image.
|
||||
const rip = (tear * 8 + 0.7) * dpr
|
||||
|
||||
ctx.save()
|
||||
// Composite in device space: the offsets are pixel work, and the buffers are
|
||||
// already at device resolution.
|
||||
// Composite in device space: the offsets are in device pixels and the buffers
|
||||
// are already at device resolution.
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.globalCompositeOperation = 'lighter'
|
||||
ctx.globalAlpha = alpha
|
||||
@@ -365,20 +349,20 @@ function paintTexturedCube(
|
||||
|
||||
chan.drawImage(cube.canvas, 0, 0)
|
||||
// Isolate one channel: multiply by a primary, then re-apply the cube's own
|
||||
// alpha, because a full-canvas fill would otherwise tint the empty space.
|
||||
// alpha, because the full-canvas fill also tints the empty space.
|
||||
chan.globalCompositeOperation = 'multiply'
|
||||
chan.fillStyle = CHANNEL_TINTS[c]
|
||||
chan.fillRect(0, 0, dw, dh)
|
||||
chan.globalCompositeOperation = 'destination-in'
|
||||
chan.drawImage(cube.canvas, 0, 0)
|
||||
|
||||
// Red left, blue right, green anchored — the classic separation. Slices
|
||||
// ride on top so the tear breaks the silhouette, not just the colour.
|
||||
// dx offsets red left and blue right and leaves green at 0. The per-slice
|
||||
// jitter below breaks the silhouette as well as the colour.
|
||||
const dx = (c - 1) * rip
|
||||
|
||||
for (let s = 0; s < SLICES; s += 1) {
|
||||
// Integer, abutting bands. Any overlap would be summed twice by
|
||||
// 'lighter' and read as bright rules across the cube.
|
||||
// Bands are integer and abutting. Overlapping rows would be summed twice by
|
||||
// 'lighter' and show as bright lines across the cube.
|
||||
const y0 = Math.round((s * dh) / SLICES)
|
||||
const band = Math.round(((s + 1) * dh) / SLICES) - y0
|
||||
const jitter = (hash(step * 31 + s) - 0.5) * 2 * tear * 11 * dpr
|
||||
@@ -391,16 +375,15 @@ function paintTexturedCube(
|
||||
}
|
||||
|
||||
export function drawViewport(ctx: CanvasRenderingContext2D, w: number, h: number, t: number) {
|
||||
// Start the texture fetch on the very first frame. Its pass is eight seconds
|
||||
// into the sequence, and a cold decode arriving mid-crossfade would show the
|
||||
// wireframe dissolving into an empty cube.
|
||||
// Start the image load on the first frame. The first `texture` slot opens at
|
||||
// 3700ms, and a decode that arrives during a crossfade would show the cube
|
||||
// empty for those frames.
|
||||
textureImage()
|
||||
|
||||
const slot = viewportSlot(t)
|
||||
const mode = slot.mode
|
||||
// Materials CROSSFADE at slot boundaries — the incoming one comes up over
|
||||
// the outgoing, like a shader recompile settling. Never a hard swap. The
|
||||
// geometry is ALWAYS a cube.
|
||||
// Materials crossfade at slot boundaries: the incoming material fades up over
|
||||
// the outgoing one.
|
||||
const prevEntry = VIEWPORT_SCHEDULE[slot.index - 1]
|
||||
const prevMode = prevEntry?.mode ?? mode
|
||||
const fade = Math.min(CROSSFADE_MS, (slot.until - slot.at) * 0.4)
|
||||
@@ -442,8 +425,7 @@ export function drawViewport(ctx: CanvasRenderingContext2D, w: number, h: number
|
||||
ctx.fillText(label, px + 2, py + 3)
|
||||
}
|
||||
|
||||
// Rotation readout, top-left; verts, bottom-right. N=4 → 6·(N+1)² shared
|
||||
// grid verts per face is the honest-ish count for the subdivided cube.
|
||||
// The 6 * 5 * 5 below is 6 faces times (N + 1)² shared grid vertices, for N = 4.
|
||||
const deg = (r: number) => ((((r * 180) / Math.PI) % 360) | 0).toString().padStart(3, ' ')
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.22)'
|
||||
@@ -453,10 +435,8 @@ export function drawViewport(ctx: CanvasRenderingContext2D, w: number, h: number
|
||||
ctx.fillText(verts, w - ctx.measureText(verts).width - 12, h - 10)
|
||||
ctx.restore()
|
||||
|
||||
// One painter per material. `standard` is the resting state: the plain
|
||||
// white default cube under ambient light — lambert with a lifted floor so
|
||||
// no face ever goes black. `texture` is not here: it is a whole-cube pass
|
||||
// (below) because its channel split has to happen in screen space.
|
||||
// One painter per material. `texture` is not handled here: it is a whole-cube
|
||||
// pass below, because its channel split has to happen in screen space.
|
||||
const paint = (m: ViewportMode, q: Quad, alpha: number) => {
|
||||
if (alpha <= 0.01 || m === 'texture') {
|
||||
return
|
||||
@@ -518,10 +498,9 @@ export function drawViewport(ctx: CanvasRenderingContext2D, w: number, h: number
|
||||
if (mode === 'texture') {
|
||||
paintTexturedCube(ctx, quads, w, h, slot, t, blend)
|
||||
} else if (prevMode === 'texture' && prevEntry) {
|
||||
// Still on ITS clock, not the incoming slot's — the tear-out that began at
|
||||
// the end of its own window has to carry through the crossfade. Reading
|
||||
// the new slot's local time restarted the ramp and re-tore a mark that was
|
||||
// supposed to be already in pieces.
|
||||
// The outgoing texture keeps its own slot bounds, so the tear-out that began
|
||||
// at the end of its window carries through the crossfade. Reading the incoming
|
||||
// slot's local time restarts the tear ramp instead.
|
||||
paintTexturedCube(
|
||||
ctx,
|
||||
quads,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* The guided chat starts alone in a small window. Picking a layout assembles
|
||||
* the app around the conversation.
|
||||
* The guided chat runs alone in a small window. Picking a layout assembles the app around the conversation.
|
||||
*
|
||||
* The window grows outward by the minimum the new panes need, with a viewport
|
||||
* floor so the sidebar stays docked. Native window bounds own the animation.
|
||||
* The window grows by the minimum the new panes need, with a viewport floor that keeps the sidebar docked. The main
|
||||
* process animates the growth with setBounds (electron/chat-onboarding-window.ts), so no CSS transition is involved.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
@@ -30,18 +29,18 @@ import { skipGuide } from '@/store/onboarding-gate'
|
||||
import { setOnboardingSurfaceActive } from '@/store/onboarding-presence'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
/** True from guide kickoff until the layout pick assembles the app. */
|
||||
/** True from guide kickoff until assembly places the picked layout. Skip and a failed kickoff also clear it. */
|
||||
export const $chatOnboardingSolo = atom(false)
|
||||
|
||||
// Presence mirror — see onboarding-presence.ts (update toast stands down).
|
||||
// Mirrors solo mode into the presence set, which hides ambient UI such as the update toast (onboarding-presence.ts).
|
||||
$chatOnboardingSolo.subscribe(solo => setOnboardingSurfaceActive('solo-chat', solo))
|
||||
|
||||
/** The thread list keys by stored id; the composer keys by runtime id. Both
|
||||
* identify the conversation that gets onboarding transcript treatment. */
|
||||
export const $chatOnboardingThreadIds = atom<readonly string[]>([])
|
||||
|
||||
/** Bank the localized opener before inference: cold first turns took 10 s.
|
||||
* The typed reveal and seed rows share it so the model sees what the user saw. */
|
||||
/** Holds the localized opener so it is ready before inference: cold first turns took 10 s. The typed reveal and the
|
||||
* seed rows read this same string, so the model receives the text the user saw. */
|
||||
export const $onboardingGreeting = atom('')
|
||||
|
||||
/** First-write-wins keeps the opener stable through profile and backend boot. */
|
||||
@@ -101,7 +100,7 @@ export function startChatOnboardingSolo(): void {
|
||||
applyLayoutPreset('chat-solo', group(['workspace'], { tabStrip: 'never' }))
|
||||
}
|
||||
|
||||
/** A failed kickoff releases the screen so classic onboarding can resume. */
|
||||
/** Called when the guide kickoff fails, so classic onboarding can resume. */
|
||||
export function endChatOnboardingSolo(): void {
|
||||
$chatOnboardingSolo.set(false)
|
||||
$onboardingGreeting.set('')
|
||||
@@ -119,7 +118,8 @@ export function endChatOnboardingSolo(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Grow by what the new panes need, not by a projection that keeps the chat's size (that balloons the window). */
|
||||
/** Per-preset growth in pixels, sized to what the new panes need. Deriving the growth from the chat's own size made
|
||||
* the window much too large. */
|
||||
interface LayoutGrowth {
|
||||
bottom?: number
|
||||
left?: number
|
||||
@@ -159,18 +159,18 @@ function reconcileLayout(id: string, tree: LayoutNode): void {
|
||||
// A persisted closed sidebar would hide the column this pick just requested.
|
||||
setSidebarOpen(true)
|
||||
|
||||
// Solo boot consumed dock enforcement before a sidebar existed. Reset its
|
||||
// ledger so adoption can dock against the newly placed Sessions column.
|
||||
// Solo boot consumed dock enforcement before a sidebar existed. Reset that record so adoption can dock against the
|
||||
// newly placed Sessions column.
|
||||
resetEnforcedDocks()
|
||||
adoptContributedPanes()
|
||||
|
||||
// Visibility can register more plugin panes synchronously. Sweep last to
|
||||
// include those arrivals (Basic otherwise gained an empty Cronjobs column).
|
||||
// Showing the sidebar can register more plugin panes synchronously. Dismiss last so those panes are dismissed as
|
||||
// well; Basic otherwise gained an empty Cronjobs column.
|
||||
dismissUndeclared()
|
||||
}
|
||||
|
||||
/** Grow only when leaving solo mode: repeating a delta would ratchet the window
|
||||
* larger on every re-pick. Reconcile panes on every pick. */
|
||||
/** Grow only when leaving solo mode: repeating the delta would make the window larger on every re-pick.
|
||||
* Reconcile panes on every pick. */
|
||||
export function assembleChatOnboarding(id: string, tree: LayoutNode): void {
|
||||
const firstPick = $chatOnboardingSolo.get()
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The build beat's three cards: choosing what to make, handing it to a session
|
||||
* of its own, and watching it happen. Unlike the setup picks these read the
|
||||
* directive's attrs — the payload is model-written, so each one validates
|
||||
* before it renders.
|
||||
* The three build cards: choosing what to make, handing it to a session of its own, and reporting progress. Unlike the
|
||||
* setup cards these read the directive attrs, which the model writes, so each card validates the payload before it
|
||||
* renders.
|
||||
*/
|
||||
|
||||
import { useAuiState } from '@assistant-ui/react'
|
||||
@@ -34,14 +33,13 @@ import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answer
|
||||
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
|
||||
import { isSessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
/** A tappable option is the user's own reply, so it goes out VISIBLE — the
|
||||
* model's next message answers a real turn, not a hidden [setup] note. */
|
||||
/** A tapped option is submitted as the user's own visible message rather than as a hidden [setup] note, so the
|
||||
* model's next message answers a real turn. */
|
||||
const FALLBACK_OPTION = "Let's figure it out together"
|
||||
|
||||
/**
|
||||
* The "first build" card — the close of the get-to-know-you beat. The model
|
||||
* asks a thoughtful question about what the user wants to BUILD first, then
|
||||
* places this card with the options IT generated from the whole conversation:
|
||||
* The last question card before the handoff. The model asks what the user wants to build first, then places this card
|
||||
* with options it wrote from the conversation so far:
|
||||
* `::onboarding{step="first" options="A Discord bot|A habit tracker|…"}`.
|
||||
*/
|
||||
export function FirstBuildCard({ attrs, locked }: CardProps) {
|
||||
@@ -59,10 +57,9 @@ export function FirstBuildCard({ attrs, locked }: CardProps) {
|
||||
const committed = useStore($onboardingAnswers).committed.find(step => step.startsWith('first:'))?.slice(6) ?? null
|
||||
const picked = committed ?? (answeredInComposer ? '' : null)
|
||||
|
||||
// Parse + validate the model's options: up to 4, each short enough to sit on
|
||||
// a chip, deduped case-insensitively (models repeat themselves). Garbage in
|
||||
// (0-1 usable) must not strand the user — the prose says "pick one below",
|
||||
// so fall back to the one option we can always offer.
|
||||
// The 60-character limit keeps an option on one chip. The dedupe is case-insensitive because models repeat
|
||||
// themselves. Fewer than 2 usable options falls back to FALLBACK_OPTION, because the model's prose has already
|
||||
// told the user to pick one below.
|
||||
const seen = new Set<string>()
|
||||
|
||||
const parsed = (attrs.options ?? '')
|
||||
@@ -105,15 +102,13 @@ export function FirstBuildCard({ attrs, locked }: CardProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The handoff card — where the first build leaves this chat. Setup emits
|
||||
* `::onboarding{step="handoff" task="…" brief="…"}` once the task is decided,
|
||||
* and the card performs it: raise the beacon, and the wiring effect opens a
|
||||
* session on the user's default profile, seeds it, and moves the user there.
|
||||
* Moves the first build out of this chat. Setup emits
|
||||
* `::onboarding{step="handoff" task="…" brief="…"}` once the task is decided. This card sets the request atom, and
|
||||
* the wiring effect then creates a session on the user's default profile, seeds it, and moves the user there.
|
||||
*
|
||||
* Nothing to ask — the build's shape was settled by the `first` step and there
|
||||
* is one surface now, so the card just narrates: opening → landed. The request
|
||||
* atom and accepted receipt make re-parses, re-mounts, and relaunches inert,
|
||||
* and a locked (replayed) transcript never re-fires.
|
||||
* The `first` step already settled what to build, so this card asks nothing and only reports the state of the handoff.
|
||||
* The request atom and the accepted receipt stop a re-parse, a re-mount, or a relaunch from starting a second handoff,
|
||||
* and a locked (replayed) transcript never starts one.
|
||||
*/
|
||||
export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
const view = useSessionView()
|
||||
@@ -226,7 +221,7 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Progress comes from this transcript, so virtualization cannot append history. */
|
||||
/** The earlier steps are derived from this transcript on every render, so a re-mount cannot lose or repeat them. */
|
||||
export function ProgressCard({ attrs, locked }: CardProps) {
|
||||
const view = useSessionView()
|
||||
const messages = useStore(view.$messages)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* What every in-chat onboarding card is made of: the frame it sits in, the
|
||||
* props it receives, and the one thing it does when the user is finished —
|
||||
* report the pick so the model moves on.
|
||||
* The parts every in-chat onboarding card shares: the frame it renders in, the props it receives, and the commit
|
||||
* helper that submits the pick as a hidden [setup] message so the model moves on.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
@@ -13,9 +12,9 @@ import { cn } from '@/lib/utils'
|
||||
import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answers'
|
||||
|
||||
export interface CardProps {
|
||||
/** The directive's raw attrs — the model-written payload. */
|
||||
/** The directive's raw attrs, written by the model. */
|
||||
attrs: Record<string, string>
|
||||
/** True while the surrounding turn is still streaming — same card, no clicks. */
|
||||
/** True while the surrounding turn is still streaming; the card renders but does not accept clicks. */
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
@@ -41,9 +40,8 @@ export function useCardCommit(step: string) {
|
||||
return { commit, done }
|
||||
}
|
||||
|
||||
/** No chrome — the picker sits directly in the transcript like any other
|
||||
* message content. The interaction IS the affordance; a border would make it
|
||||
* read as a form. */
|
||||
/** The frame draws no border or background, so the picker reads as message content in the transcript rather than as
|
||||
* a form. */
|
||||
export function CardFrame({
|
||||
children,
|
||||
continueLabel = 'Continue',
|
||||
@@ -53,7 +51,7 @@ export function CardFrame({
|
||||
onContinue
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
/** The action, named for what it does when the default reads as a shrug —
|
||||
/** The action, named for what it does when the default label says nothing specific.
|
||||
* "Continue with 2" tells them the picks registered. */
|
||||
continueLabel?: string
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The three setup picks — accent, connectors, layout.
|
||||
*
|
||||
* Picks apply live. The shared catalog keeps cards and previews in agreement
|
||||
* without asking the model to enumerate the options.
|
||||
* The three setup cards: accent, connectors, and layout. The accent and layout picks apply as soon as they are
|
||||
* clicked; the connector picks are only recorded. The option lists come from onboarding-chat/options.tsx, so the cards
|
||||
* and the previews stay in agreement without the model listing the options.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
@@ -39,9 +38,9 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
const catalog = useConnectorCatalog(storedId, runtimeId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Only what the gateway actually carries. A pick is a slug the build chat
|
||||
// can hand straight to manage_connections; a name with nothing behind it
|
||||
// is a promise it has to walk back.
|
||||
// Only what the gateway carries. A pick is a slug the build chat can hand
|
||||
// straight to manage_connections; a name the gateway does not carry would be
|
||||
// a pick the build chat cannot honour.
|
||||
const rows = useMemo(() => (catalog.status === 'ready' ? orderConnectorPicks(catalog.rows) : []), [catalog])
|
||||
const shown = rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
|
||||
const picked = rows.filter(row => answers.connectors.includes(row.connector))
|
||||
@@ -54,7 +53,7 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
})
|
||||
|
||||
// Nothing to pick from: the toolset is off or the gateway is unreachable.
|
||||
// The step still has to end, so it ends honestly.
|
||||
// The step still has to end, so the card offers Skip.
|
||||
if (catalog.status === 'unavailable' || (catalog.status === 'ready' && rows.length === 0)) {
|
||||
return (
|
||||
<CardFrame continueLabel="Skip this" done={done} locked={locked} onContinue={() => commit('apps I use: none for now')}>
|
||||
@@ -102,7 +101,7 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Picking is a preference, not an authorization: nothing *** signed into
|
||||
{/* Picking is a preference, not an authorization: nothing is signed into
|
||||
here. Saying so is what keeps the Connect cards later from reading as
|
||||
a second ask for the same thing. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -148,28 +147,25 @@ export function LookCard({ locked }: CardProps) {
|
||||
export function LayoutCard({ locked }: CardProps) {
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const { commit, done } = useCardCommit('layout')
|
||||
// The stored answer defaults to 'basic', but the CHOICE is the point of this
|
||||
// step — nothing renders selected (and Continue stays off) until they click.
|
||||
// Store-backed: the pick's own layout apply remounts this card (the pane
|
||||
// tree is replaced), so local state would drop the highlight instantly.
|
||||
// The stored answer defaults to 'basic', so nothing renders selected and Continue stays disabled until the user
|
||||
// clicks. The flag lives in a store because applying the picked layout replaces the pane tree and remounts this
|
||||
// card, which would clear local state.
|
||||
const picked = useStore($chatLayoutPicked)
|
||||
|
||||
const pickLayout = (id: string) => {
|
||||
$chatLayoutPicked.set(true)
|
||||
setOnboardingAnswers({ layout: id })
|
||||
|
||||
// Live, behind the chat — the panes rearrange as the option is clicked.
|
||||
const preset = registry.getArea('layouts').find(contribution => contribution.id === id)
|
||||
|
||||
if (!preset?.data) {
|
||||
return
|
||||
}
|
||||
|
||||
// Every pick goes through assembly, including re-picks. The first grows
|
||||
// the window and places the panes, keeping the chat (and the cursor over
|
||||
// this card) pixel-fixed; later ones re-arrange in place. Swapping just the
|
||||
// preset tree on a re-pick left the previous layout's dismissals and dock
|
||||
// records in force, and the two layouts came up mixed together.
|
||||
// Every pick goes through assembly, including re-picks. The first pick grows the window and places the panes,
|
||||
// holding the chat and the cursor over this card at the same screen position; later picks rearrange in place.
|
||||
// Swapping only the preset tree on a re-pick kept the previous layout's dismissals and dock records, and the two
|
||||
// layouts came up mixed together.
|
||||
// SAFETY: Layout presets declare data: LayoutNode (pane-shell/tree/presets.ts).
|
||||
assembleChatOnboarding(preset.id, preset.data as LayoutNode)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,15 @@ import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* THE selection style — one vocabulary for every pickable thing in the shell
|
||||
* (chips, connector cards, layout cards): primary outline + tint when on, a
|
||||
* quiet neutral fill when off. No font-weight changes, no fills that shout.
|
||||
*/
|
||||
/** One selection style for every pickable element in the shell: chips, connector cards, and layout cards. */
|
||||
export const selectableClass = (on: boolean) =>
|
||||
cn(
|
||||
'border text-foreground transition-colors',
|
||||
on ? 'border-primary bg-primary/15' : 'border-transparent bg-muted hover:bg-accent/60'
|
||||
)
|
||||
|
||||
/** Toggleable chip — every pickable row/tag in the guided cards. Two shapes:
|
||||
* `card` (connector rows, roomier, fits an icon) and `pill` (compact
|
||||
* tag-cloud toggles). */
|
||||
/** Toggleable chip for the guided cards. ConnectorsCard uses the default `card` variant for its connector rows;
|
||||
* FirstBuildCard uses the compact `pill` variant. */
|
||||
export function Chip({
|
||||
className,
|
||||
icon,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
/**
|
||||
* In-chat onboarding cards — the `::onboarding{step="…"}` transcript
|
||||
* directive. Hermes walks the user through setup in the transcript, and each
|
||||
* step's paragraph renders as an interactive picker with a shared option
|
||||
* catalog and persistence.
|
||||
*
|
||||
* This module is only the dispatcher. Two tables say what a step means — one
|
||||
* writes an answer, the other renders a card — and a step in neither renders
|
||||
* nothing, which is the right answer for the model's invisible acks. The cards
|
||||
* themselves live in ./cards.
|
||||
* Dispatcher for the `::onboarding{step="…"}` transcript directive, which turns a setup step into an interactive
|
||||
* picker in the transcript. Two tables decide what a step does: one writes an answer to the store, the other renders
|
||||
* a card. A step in neither table renders nothing. The cards live in ./cards.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
@@ -17,9 +11,8 @@ import type { CardProps } from '@/components/onboarding-chat/cards/frame'
|
||||
import { ConnectorsCard, LayoutCard, LookCard } from '@/components/onboarding-chat/cards/setup'
|
||||
import { $onboardingAnswers, setOnboardingAnswers } from '@/store/onboarding-answers'
|
||||
|
||||
/** Steps that only carry data — the model handing the renderer what the user
|
||||
* said. Each maps to the answer field it writes ('working' is the guided
|
||||
* flow's name for the context answer: same storage, same consumers). */
|
||||
/** Steps that only carry data, mapped to the answer field each one writes. The runbook names the context step
|
||||
* 'working' (store/onboarding-script.ts), so the step name and the field name differ. */
|
||||
type AnswerField = 'name' | 'context'
|
||||
|
||||
const DATA_STEPS = new Map<string, AnswerField>([
|
||||
@@ -37,9 +30,8 @@ const STEP_CARDS = new Map<string, (props: CardProps) => React.ReactNode>([
|
||||
['progress', ProgressCard]
|
||||
])
|
||||
|
||||
/** Writing an answer is an EFFECT, not a render fact. Doing it inline in the
|
||||
* directive's render triggered React's cross-component setState warning and
|
||||
* re-entrant renders (live desktop.log). */
|
||||
/** Writes the answer from an effect. Writing it during the directive's render triggered React's cross-component
|
||||
* setState warning and re-entrant renders. */
|
||||
function DataDirective({ field, value }: { field: AnswerField; value: string }) {
|
||||
useEffect(() => {
|
||||
if (!value || $onboardingAnswers.get()[field] === value) {
|
||||
@@ -63,8 +55,7 @@ export function OnboardingChatDirective({ attrs, streaming }: { attrs: Record<st
|
||||
|
||||
const Card = STEP_CARDS.get(step)
|
||||
|
||||
// Mount as soon as the directive is parsed — returning null until settle
|
||||
// grows the transcript by a card when the turn finishes. Keep it inert
|
||||
// mid-stream so the growing paragraph can't be clicked through.
|
||||
// Mount as soon as the directive is parsed. Returning null until the turn settles would grow the transcript by a
|
||||
// card when the turn finishes. The card stays inert while streaming so the growing paragraph cannot be clicked.
|
||||
return Card ? <Card attrs={attrs} locked={streaming} /> : null
|
||||
}
|
||||
|
||||
@@ -1,61 +1,49 @@
|
||||
/**
|
||||
* Watching the first build.
|
||||
* Progress check-ins during the first build.
|
||||
*
|
||||
* Setup hands the first task to its own session and stops talking. What the
|
||||
* user feels next used to be nothing until they said something — the guide
|
||||
* scheduled itself a DAILY cron and that was the whole of its "proactivity",
|
||||
* which on a first run means a check-in that arrives tomorrow, about a task
|
||||
* that finished in four minutes.
|
||||
* Setup hands the first task to a session of its own and then stops. This module counts that session's tool
|
||||
* calls and sets `$setupCheckIn` at two of them; the wiring turns each one into a hidden `[setup]` note in the
|
||||
* same session, which asks the agent to say where the work stands and what the user wants next. The note
|
||||
* arrives in the chat the user is already reading. A cron job would not.
|
||||
*
|
||||
* So the check-ins ride the build's own progress instead of a clock. This
|
||||
* module counts the work as it happens and, at a couple of points, raises a
|
||||
* beacon the wiring turns into a hidden `[setup]` note in that same session —
|
||||
* the agent pauses, says where things stand, and asks what the user wants
|
||||
* next. It lands where they are already looking, which a cron never does.
|
||||
* Two rules limit the check-ins:
|
||||
*
|
||||
* Two rules keep it from becoming a nag:
|
||||
*
|
||||
* - It only ever speaks BETWEEN turns (on `message.complete`). A note injected
|
||||
* mid-loop would be a synthetic user message in the middle of an assistant
|
||||
* turn — the alternation the agent core forbids.
|
||||
* - It stays quiet when the turn already ended by asking something. The
|
||||
* runbook has the agent ask for a verdict when the first pass lands; a
|
||||
* check-in stacked under that is two questions and no answer.
|
||||
* - A note is set only between turns, on `message.complete`. A note set mid-turn would become a synthetic
|
||||
* user message inside an assistant turn, which the agent core's role alternation forbids.
|
||||
* - No note when the turn already ended with a question. The runbook has the agent ask for a verdict after
|
||||
* the first pass, and a check-in under that ask puts two questions to the user at once.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
|
||||
|
||||
/** Tool calls at which Setup checks in. Two of them: one once the build is
|
||||
* visibly underway, one deep enough in that "still what you wanted?" is a
|
||||
* real question. A third would be nagging. */
|
||||
/** Tool call counts at which Setup checks in: the first once the build is visibly under way, the second far
|
||||
* enough in that asking whether the work is still what the user wanted is a real question. */
|
||||
const CHECK_IN_AT = [8, 20] as const
|
||||
|
||||
const CHECK_IN_NOTE =
|
||||
'[setup] checkpoint — the user has been watching you work for a while and has not said anything. Before you carry on, say in ONE short line where the work actually stands right now, then end the turn with ::ask{question="What do you want next?" options="…|…|…"} alone as its own paragraph, with two or three options drawn from what would genuinely help here (keep going, change direction, explain something, stop). Emit the ask exactly in that shape. Do not summarize everything you have done, do not apologize for the interruption, and never mention this note.'
|
||||
|
||||
interface FirstBuild {
|
||||
/** Profile the build session lives on. Carried because the whisper has to
|
||||
* be routed explicitly: the user can walk back into Setup's chat while the
|
||||
* build runs, which makes hermes-setup the ACTIVE gateway. */
|
||||
/** Profile of the build session. The note must be routed to this profile explicitly: the user can return to
|
||||
* Setup's chat while the build runs, which makes hermes-setup the active gateway. */
|
||||
profile: string
|
||||
sessionId: string
|
||||
tools: number
|
||||
/** Highest CHECK_IN_AT threshold already spent. */
|
||||
/** Highest CHECK_IN_AT tool count already used, not a timestamp. */
|
||||
checkedInAt: number
|
||||
}
|
||||
|
||||
let build: FirstBuild | null = null
|
||||
|
||||
/** Raised when the build has earned a check-in; the wiring whispers it into
|
||||
* the build's session as a hidden `[setup]` note. Token-bumped so two
|
||||
* check-ins in one run can't be swallowed as a duplicate value. */
|
||||
/** Set when a check-in is due. The wiring submits the note to the build's session as a hidden `[setup]` note.
|
||||
* The token changes on every check-in, so a second check-in with the same note is not read as a duplicate
|
||||
* value. */
|
||||
export const $setupCheckIn = atom<null | { note: string; profile: string; sessionId: string; token: number }>(null)
|
||||
|
||||
let token = 0
|
||||
|
||||
/** Start watching the session Setup just handed the first task to. */
|
||||
export function watchFirstBuild(sessionId: string, profile: string): void {
|
||||
build = { checkedInAt: 0, profile, sessionId, tools: 0 }
|
||||
}
|
||||
@@ -75,8 +63,8 @@ export function reportFirstBuildToolComplete(sessionId: null | string | undefine
|
||||
build.tools += 1
|
||||
}
|
||||
|
||||
/** Called from the gateway stream on message.complete — the only moment a
|
||||
* note may be injected (see the alternation rule in the module header). */
|
||||
/** Called from the gateway stream on message.complete, the only point where a note may be set. The module
|
||||
* header explains the role alternation rule behind that. */
|
||||
export function reportFirstBuildTurnComplete(sessionId: null | string | undefined, finalText: string): void {
|
||||
const current = build
|
||||
|
||||
@@ -86,9 +74,9 @@ export function reportFirstBuildTurnComplete(sessionId: null | string | undefine
|
||||
|
||||
const due = CHECK_IN_AT.filter(at => current.tools >= at && at > current.checkedInAt).pop()
|
||||
|
||||
// The turn already put a question to the user (the runbook's verdict ask, or
|
||||
// one the agent chose). Let them answer it. Parsed, not string-matched — a
|
||||
// `::ask` the agent merely talked ABOUT is not a question.
|
||||
// Skip the check-in when the turn ended with a question, either the runbook's verdict ask or one the agent
|
||||
// chose, so the user can answer it. endsInAsk parses the directives instead of matching text, so an `::ask`
|
||||
// the agent only described in prose does not count.
|
||||
if (due === undefined || endsInAsk(finalText)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export function OnboardingChatGate({ enabled, onKickoff, requestGateway }: Onboa
|
||||
const intro = useStore($introReveal)
|
||||
|
||||
// A guide is owed the moment the renderer knows it (cinematic with the film
|
||||
// seen, or a relaunch mid-guide). Take the solo shape NOW, before the
|
||||
// gateway opens — otherwise the normal shell paints at full size for the
|
||||
// seen, or a relaunch mid-guide). Take the solo shape now, before the
|
||||
// gateway opens. Otherwise the normal shell paints at full size for the
|
||||
// seconds the backend takes to come up, and then snaps down to the guide.
|
||||
useEffect(() => {
|
||||
if (gate.guideQueued && intro.phase === 'hidden') {
|
||||
|
||||
@@ -3,11 +3,10 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { IS_MAC } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Which live-catalog slugs the first-run picker shows, and in what order. The
|
||||
// catalog is the source of truth for WHAT can be connected; this list picks the
|
||||
// few everyday apps out of it (D89). A slug the catalog no longer carries is
|
||||
// simply not shown, and a slug the catalog gains is not shown until it is
|
||||
// added here.
|
||||
// The live-catalog slugs the first-run picker shows, in this order. The catalog
|
||||
// decides what can be connected; this list picks the everyday apps out of it
|
||||
// (decision D89). A slug the catalog no longer carries is not shown, and a slug
|
||||
// the catalog gains is not shown until it is added here.
|
||||
export const CONNECTOR_LEAD_ORDER = [
|
||||
'gmail',
|
||||
'googlecalendar',
|
||||
@@ -23,8 +22,8 @@ export const CONNECTOR_LEAD_ORDER = [
|
||||
'todoist'
|
||||
]
|
||||
|
||||
// Connectors are the apps Hermes reads and acts on FOR the user. Chat channels
|
||||
// (Discord, Telegram, WhatsApp) are how a user talks TO Hermes — those live on
|
||||
// Connectors are the apps Hermes reads and acts on for the user. Chat channels
|
||||
// (Discord, Telegram, WhatsApp) are how a user talks to Hermes; those live on
|
||||
// the Messaging page, and offering them here as if they were data sources
|
||||
// taught users the wrong thing about what "connect" does. The catalog
|
||||
// carries them for the agent's sake; the first-run picker leaves them out.
|
||||
@@ -45,10 +44,9 @@ export function orderConnectorPicks<T extends { connector: string; enabled?: boo
|
||||
})
|
||||
}
|
||||
|
||||
// Big accent swatches, Dia-style. Each seeds `retintTheme` through the accent
|
||||
// override, so a click repaints the surface live. Nous blue is the default =
|
||||
// no override. Mono seeds the current mode's pole — black in light, white in
|
||||
// dark — for a full monochrome look.
|
||||
// Each swatch sets the accent override, which `retintTheme` uses to repaint
|
||||
// the active skin as soon as the swatch is clicked. Nous blue is the default
|
||||
// and sets no override. Mono is black in light mode and white in dark mode.
|
||||
export const NOUS_ACCENT = '#0053fd'
|
||||
|
||||
export const accentsFor = (dark: boolean): Array<{ hex: string; name: string }> => [
|
||||
@@ -79,7 +77,7 @@ export function AccentSwatch({
|
||||
aria-label={name}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
// The hairline keeps the mono swatch visible on its own pole.
|
||||
// The border keeps the mono swatch visible when its colour matches the background.
|
||||
'size-9 rounded-full border border-foreground/15 transition-transform duration-150',
|
||||
!active && 'hover:scale-105'
|
||||
)}
|
||||
@@ -94,13 +92,11 @@ export function AccentSwatch({
|
||||
)
|
||||
}
|
||||
|
||||
// Mini layout trees mirror the basic (BASIC_TREE) and terminal-deck
|
||||
// (TERMINAL_TREE) presets registered in app/contrib/controller.tsx, drawn in
|
||||
// the layout editor's thumbnail language, upscaled.
|
||||
// These mini trees copy the basic (BASIC_TREE) and terminal-deck
|
||||
// (TERMINAL_TREE) presets in app/contrib/layout-presets.ts, drawn like the
|
||||
// layout editor's thumbnails at a larger size.
|
||||
export type MiniNode = 1 | { dir: 'column' | 'row'; children: MiniNode[]; weights: number[] }
|
||||
|
||||
/** The power-user layout. Picking it is the most explicit thing a user does
|
||||
* in the whole first run to say how they work. */
|
||||
export const ELITE_LAYOUT_ID = 'terminal-deck'
|
||||
|
||||
export const LAYOUTS: Array<{ id: string; name: string; tree: MiniNode }> = [
|
||||
@@ -133,14 +129,9 @@ export function MiniTree({ node }: { node: MiniNode }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The window buttons on the preview, drawn the way this machine draws them.
|
||||
*
|
||||
* The card is a picture of the user's own window, so it follows the split
|
||||
* `main.ts` already makes when it builds one: macOS gets the traffic lights on
|
||||
* the left (`trafficLightPosition`), everywhere else the native controls ride
|
||||
* on the right as monochrome glyphs (`titleBarOverlay`). Three coloured dots on
|
||||
* a Windows machine is a picture of somebody else's computer — a small tell, in
|
||||
* the one moment the app is claiming to show you yours.
|
||||
* The window buttons on the preview, drawn the way this machine draws them, so the card matches the user's own window.
|
||||
* `main.ts` makes the same split: macOS puts the traffic lights on the left (`trafficLightPosition`), every other
|
||||
* platform puts monochrome native controls on the right (`titleBarOverlay`).
|
||||
*/
|
||||
function MiniWindowButtons() {
|
||||
if (IS_MAC) {
|
||||
@@ -153,8 +144,8 @@ function MiniWindowButtons() {
|
||||
)
|
||||
}
|
||||
|
||||
// Minimize, maximize, close — at 6px the glyphs themselves are mush, so each
|
||||
// is the shape it would be: a bar, a box, and a cross that reads as one.
|
||||
// Minimize, maximize, close. At 6 px the real glyphs are illegible, so each
|
||||
// one is a plain shape: a bar, a box, and a cross.
|
||||
return (
|
||||
<span aria-hidden className="flex items-center justify-end gap-1.5 text-foreground/40">
|
||||
<span className="h-px w-1.5 bg-current" />
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
/**
|
||||
* The welcome chat — the profile guided onboarding runs in.
|
||||
* The welcome chat that guided onboarding runs in, and the seed prompts for the first build session.
|
||||
*
|
||||
* It is not an anonymous session: it belongs to a persistent `hermes-setup`
|
||||
* profile, so the conversation survives onboarding and can be found again. An
|
||||
* ordinary profile with an ordinary visible chat — there is no bot surface
|
||||
* here, and nothing in this flow mints one.
|
||||
* The chat belongs to a persistent `hermes-setup` profile, so it survives onboarding and can be found again. `setup`
|
||||
* is the internal name throughout this module (the profile key, the atoms, the hidden `[setup]` notes); the user sees
|
||||
* only Hermes and the title `Welcome to Hermes`.
|
||||
*
|
||||
* `setup` is the INTERNAL name throughout this module (the profile key, the
|
||||
* atoms, the hidden `[setup]` notes). It is never what the user reads: to
|
||||
* them the voice is just Hermes, and the chat is titled `Welcome to Hermes`.
|
||||
*
|
||||
* When the first task is decided it is NOT built in this chat. The model emits
|
||||
* `::onboarding{step="handoff" task="…" brief="…"}` and the renderer opens a
|
||||
* NEW session on the user's default profile, seeded with the work-side
|
||||
* runbook, and starts the build there. The welcome chat hears how it went
|
||||
* through a hidden `[setup]` note.
|
||||
*
|
||||
* This module owns the pure pieces (names, souls, seed prompts, the handoff
|
||||
* request atom). The side effects — profiles.create, session.create, the chat
|
||||
* switch — live in the wiring's handoff effect so they run with real
|
||||
* gateway/session hooks.
|
||||
* This module holds the pure pieces: names, souls, seed prompts, and the handoff request atom. The side effects
|
||||
* (profiles.create, session.create, the chat switch) run in the wiring's kickoff and handoff effects, which hold the
|
||||
* gateway and session hooks.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
@@ -35,32 +23,17 @@ import type { OnboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { PLAIN_SPEECH } from '@/store/onboarding-script'
|
||||
import { getSessionOwnerHint } from '@/store/session'
|
||||
|
||||
/** Profile name of the onboarding guide. Prefixed so it can't collide with a
|
||||
* profile a user actually named "setup". */
|
||||
/** Profile name of the onboarding guide. Prefixed so it cannot collide with a profile the user named "setup". */
|
||||
export const SETUP_PROFILE = 'hermes-setup'
|
||||
|
||||
/** Title of the welcome chat, and the row the user sees in their sessions
|
||||
* list. Exact-title lookup is how kickoff re-finds it across relaunches, so
|
||||
* this string is also a registry key — change the words, keep them stable. */
|
||||
/** Title of the welcome chat, and the row the user sees in the sessions list. Kickoff re-finds the chat by exact
|
||||
* title after a relaunch, so this string is also a lookup key. */
|
||||
export const SETUP_CHAT_TITLE = 'Welcome to Hermes'
|
||||
|
||||
export type SetupHandoffPhase = 'done' | 'error' | 'opening' | 'pending'
|
||||
|
||||
/** What KIND of first job this is. Two shapes we script ourselves:
|
||||
*
|
||||
* 'machine-setup' — the work is known (audit the box, then install), the user
|
||||
* can't brief it, and the agent needs permission discipline the moment it
|
||||
* starts touching the system.
|
||||
*
|
||||
* 'plugin' — the first build is a piece of THEIR app. A plugin is a single
|
||||
* file the runtime hot-loads on save, so the payoff lands inside the window
|
||||
* they are already looking at instead of somewhere on disk, and their first
|
||||
* session ends with a surface nobody else has. Not every first task suits it
|
||||
* (see the runbook's own test), which is why it is a plan rather than a
|
||||
* default.
|
||||
*
|
||||
* Everything else is 'build' — the user's own idea, in whatever shape it
|
||||
* wants. */
|
||||
/** Which runbook planRunbook() selects for the first build session. Set from the plan attribute on the model's
|
||||
* handoff directive. */
|
||||
export type HandoffPlan = 'build' | 'machine-setup' | 'plugin'
|
||||
|
||||
const HANDOFF_PLANS: readonly HandoffPlan[] = ['build', 'machine-setup', 'plugin']
|
||||
@@ -77,16 +50,16 @@ export interface SetupHandoffState {
|
||||
brief: string
|
||||
phase: SetupHandoffPhase
|
||||
plan: HandoffPlan
|
||||
/** Title of the session the build landed in, once it exists. */
|
||||
sessionTitle?: string
|
||||
}
|
||||
|
||||
/** The handoff beacon: HandoffCard raises it, the wiring effect performs it.
|
||||
* Null until the model emits the handoff directive. */
|
||||
/** Set by HandoffCard, or restored from a saved receipt by the wiring's recovery effect. The wiring's handoff effect
|
||||
* then advances phase. Null until the model emits the handoff directive. */
|
||||
export const $setupHandoff = atom<null | SetupHandoffState>(null)
|
||||
export const $handoffError = atom<string | null>(null)
|
||||
|
||||
/** Only a deliberate retry lifts an error; re-rendering a directive does not. */
|
||||
/** Called only by the Retry control in HandoffCard and by the "Retry first build" toast, so a re-rendered handoff
|
||||
* directive cannot clear the error. */
|
||||
export function retrySetupHandoff(): void {
|
||||
const state = $setupHandoff.get()
|
||||
|
||||
@@ -98,7 +71,8 @@ export function retrySetupHandoff(): void {
|
||||
$setupHandoff.set({ ...state, phase: 'pending' })
|
||||
}
|
||||
|
||||
/** The issuing welcome chat owns the completion note, even in a background tile. */
|
||||
/** Identifies the welcome chat that issued the handoff. The handoff wiring submits the completion note to this
|
||||
* session, not to whichever session is active when the build starts. */
|
||||
export interface SetupSession {
|
||||
connectionId: null | string
|
||||
profile: string
|
||||
@@ -108,8 +82,8 @@ export interface SetupSession {
|
||||
|
||||
export const $setupSession = atom<null | SetupSession>(null)
|
||||
|
||||
/** A null connection is the ambient profile route. Substituting 'local'
|
||||
* would retarget a legacy remote primary onto this machine. */
|
||||
/** Returns null for the ambient profile route. Returning 'local' instead would retarget a legacy remote primary onto
|
||||
* this machine. */
|
||||
export function guideSourceConnectionId(guideStoredId: null | string | undefined): null | string {
|
||||
return (guideStoredId && getSessionOwnerHint(guideStoredId)?.connectionId) || activeGatewayConnectionId() || null
|
||||
}
|
||||
@@ -143,15 +117,13 @@ export function resetSetupHandoffForTests(): void {
|
||||
$setupSession.set(null)
|
||||
}
|
||||
|
||||
/** Short display title for the first build's session row. */
|
||||
export function firstTaskTitle(task: string): string {
|
||||
const trimmed = task.trim()
|
||||
|
||||
return trimmed.length > 28 ? `${trimmed.slice(0, 27).trimEnd()}…` : trimmed || 'First build'
|
||||
}
|
||||
|
||||
/** SOUL.md for the welcome profile — its standing identity across the welcome
|
||||
* chat and every later check-in. */
|
||||
/** SOUL.md for the welcome profile. It applies to the welcome chat and to every later check-in. */
|
||||
export function composeSetupSoul(): string {
|
||||
return [
|
||||
'# Hermes',
|
||||
@@ -168,10 +140,6 @@ export function composeSetupSoul(): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** The hidden runbook seeded into the first build's session — the work-side
|
||||
* half of the old single-chat script: the permissions note, the live progress
|
||||
* cards, and, when the user picked apps during setup, the connect-first
|
||||
* opening. With no picks the first build stays account-free. */
|
||||
export function buildFirstTaskRunbook(
|
||||
task: string,
|
||||
answers: OnboardingAnswers,
|
||||
@@ -212,9 +180,8 @@ export function buildFirstTaskRunbook(
|
||||
const NO_AUTH_RULE =
|
||||
'CRITICAL: this first build must be finishable with NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connectors get wired only with their consent, and an app that is already connected may be used, one that is not may be offered. Everything else is fair game and the more visible the better: web research with the browser shown to the user as you work, scripts, computer use, a small app, a file-based tracker, a scheduled reminder, a generated page. If the idea needs an account that is not connected, build the no-auth core first and offer the connection as the next step. NEVER route around a connector: an unconnected Gmail is not a cue to install an IMAP client, ask for an app password, or find another way into the same account. The connector IS the way in; if they decline it, the app is out of this build.'
|
||||
|
||||
/** The picks are gateway slugs the user chose during setup; connecting them is
|
||||
* the session's first act, and the agent owns the wait (D85). The app, not the
|
||||
* agent, opens the sign-ins and reports back with a hidden note. */
|
||||
/** The picks are gateway slugs the user chose during setup. The agent, rather than the app, waits for the connection
|
||||
* result, as decided in D85. */
|
||||
function connectFirstRunbook(picks: string[]): string[] {
|
||||
const named = picks.map(slug => `${slug} (${connectorTitle(slug)})`).join(', ')
|
||||
|
||||
@@ -231,12 +198,8 @@ function connectFirstRunbook(picks: string[]): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** The one first job we script end to end. Setting up a machine is the task a
|
||||
* brand-new user most wants and can least brief, so the agent does the
|
||||
* briefing: look first, propose, then install with consent. Audit-before-plan
|
||||
* is the load-bearing part — a plan invented before looking is how an agent
|
||||
* ends up installing a second copy of something, or "fixing" drivers that
|
||||
* were already fine. */
|
||||
/** The machine-setup runbook. The audit comes before the plan because a plan written before looking is how an agent
|
||||
* installs a second copy of something, or "fixes" drivers that were already correct. */
|
||||
const MACHINE_SETUP_RUNBOOK = [
|
||||
'THIS IS A MACHINE SETUP JOB: get this computer genuinely ready to use, end to end, with the terminal. It is the one first task that does not need an account anywhere — never send them to a sign-in to complete it.',
|
||||
'START BY LOOKING, NOT PLANNING. Before proposing anything, use the terminal to find out what is actually here: OS name and version, architecture, pending system updates, free disk, which package manager exists (Homebrew / winget / apt / dnf), and which everyday things are already installed (a browser, an editor, git, python, node, docker, and whatever tools they mentioned earlier). On an NVIDIA machine also check the GPU and driver (nvidia-smi) and whether a container runtime and CUDA toolchain are present. Report what you found in a few short lines — plainly, no tables.',
|
||||
@@ -248,19 +211,7 @@ const MACHINE_SETUP_RUNBOOK = [
|
||||
'FINISH with a few lines: what changed, what you skipped and why, and what is left for them. If a reboot is needed, say so plainly.'
|
||||
]
|
||||
|
||||
/** The other scripted job: the first build is a piece of their own app.
|
||||
*
|
||||
* A desktop plugin is one file — plain ESM, `jsx()` calls, no build step —
|
||||
* that the runtime loader hot-loads the moment it is written (see
|
||||
* contrib/runtime-loader.ts, whose whole design is "agent rewrites a plugin
|
||||
* file, clean reload"). That is what makes this a good FIRST task rather than
|
||||
* an ambitious one: the payoff appears inside the window the user is already
|
||||
* looking at, seconds after the file lands, and it is theirs in a way a file
|
||||
* on disk never is.
|
||||
*
|
||||
* The catalog is reference, not a dependency: thirteen reviewed plugins in
|
||||
* NousResearch/plugins show the shapes that work. Reading one beats inventing
|
||||
* an API, and the agent is told to look before it writes. */
|
||||
/** The plugin runbook. The save-time reload it promises is implemented in src/contrib/runtime-loader.ts. */
|
||||
const pluginRunbook = (root: string) => [
|
||||
'THIS IS A PLUGIN JOB: the thing you are building is a piece of the Hermes app itself, and it will appear in the window the user is looking at right now. That is the whole point — do not let it become a script in a folder.',
|
||||
`A plugin is ONE file: \`${root}/<name>/plugin.js\`. Plain ESM, no build step, no package.json, no install. It imports from \`@hermes/plugin-sdk\` and calls \`jsx()\` from \`react/jsx-runtime\` directly (there is no JSX compiler in this path — writing \`<div>\` will not work). It default-exports \`{ id, name, register(ctx) }\` and \`register\` calls \`ctx.register({ id, area, order, render })\`. The runtime loads it the moment you save, and reloads it on every later save, so there is no restart to ask them for.`,
|
||||
@@ -270,21 +221,19 @@ const pluginRunbook = (root: string) => [
|
||||
'Never ask them to restart the app, never edit anything outside their plugin folder, and never touch the Hermes install itself. If the plugin errors on load, the app toasts it and keeps running — read the error, fix the file, save again.'
|
||||
]
|
||||
|
||||
/** The plan's own instructions, or the no-auth rule when the shape is the
|
||||
* user's own idea. One switch so a new plan cannot half-land: adding a case
|
||||
* here is what makes `plan="…"` mean anything at the other end. */
|
||||
/** A new HandoffPlan takes effect only once it has a case here. */
|
||||
function planRunbook(plan: HandoffPlan, pluginRoot: string, connectFirst: boolean): string[] {
|
||||
switch (plan) {
|
||||
case 'machine-setup':
|
||||
return machineSetupRunbook()
|
||||
|
||||
case 'plugin':
|
||||
// Without picks NO_AUTH_RULE still applies: a plugin that needs an API
|
||||
// key on its first run is the same dead end as any other first build.
|
||||
if (!pluginRoot) {
|
||||
throw new Error('The desktop plugin folder is unavailable. Retry before starting the first build.')
|
||||
}
|
||||
|
||||
// With no picks NO_AUTH_RULE still applies: a plugin that needs an API key on its first run is as
|
||||
// unfinishable as any other first build that needs an account.
|
||||
return connectFirst ? pluginRunbook(pluginRoot) : [...pluginRunbook(pluginRoot), NO_AUTH_RULE]
|
||||
|
||||
default:
|
||||
@@ -292,10 +241,8 @@ function planRunbook(plan: HandoffPlan, pluginRoot: string, connectFirst: boolea
|
||||
}
|
||||
}
|
||||
|
||||
/** The same runbook, opening with what the app already knows about the machine
|
||||
* — freshness first. That fact decides whether the job is an afternoon of real
|
||||
* work or a tour of things already handled, and the agent should not spend its
|
||||
* first two turns discovering what one IPC already answered. */
|
||||
/** Prefixes MACHINE_SETUP_RUNBOOK with machineDescription(), so the agent does not spend its first turns finding out
|
||||
* what the app already reports. */
|
||||
function machineSetupRunbook(): string[] {
|
||||
const description = machineDescription()
|
||||
|
||||
@@ -304,9 +251,8 @@ function machineSetupRunbook(): string[] {
|
||||
: MACHINE_SETUP_RUNBOOK
|
||||
}
|
||||
|
||||
/** Seed rows for the build session's session.create — just the hidden runbook;
|
||||
* the visible go-signal (the task brief) is submitted as a real turn right
|
||||
* after, which is what starts the build. */
|
||||
/** Seed rows for the build session's session.create: the hidden runbook only. The task brief is submitted as a real
|
||||
* turn right after, and that is what starts the build. */
|
||||
export async function buildFirstTaskSeedMessages(
|
||||
task: string,
|
||||
answers: OnboardingAnswers,
|
||||
@@ -317,17 +263,14 @@ export async function buildFirstTaskSeedMessages(
|
||||
return [{ content: buildFirstTaskRunbook(task, answers, plan, root), display_kind: 'hidden', role: 'user' }]
|
||||
}
|
||||
|
||||
/** The hidden note whispered into the Setup chat once the build session is
|
||||
* live — Setup's cue to close the loop and stand down. The check-ins that
|
||||
* follow are driven by the build's own progress (see first-build.ts), not by
|
||||
* a schedule Setup has to remember to create. */
|
||||
/** The hidden note sent to the welcome chat once the build session is live. The check-ins after it come from the
|
||||
* build's own progress, in first-build.ts. */
|
||||
export function buildHandoffCompleteNote(task: string): string {
|
||||
return `[setup] handoff complete — "${task.trim()}" is now building in its own session on the default profile, and the user is watching it there. The app is showing them a short tour of the profile rail and the sessions list right now, so do not describe either. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.`
|
||||
}
|
||||
|
||||
// ── gateway helpers (called from the wiring's kickoff + handoff effects) ─────
|
||||
|
||||
/** Create the guide once with the default profile’s configured providers and shared OAuth. */
|
||||
/** Creates the guide profile. The catch treats an already-existing profile as success, so kickoff can call this on
|
||||
* every run. */
|
||||
export async function ensureSetupProfile(request: GatewayRequest): Promise<void> {
|
||||
try {
|
||||
await request('profiles.create', {
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
/**
|
||||
* THE HANDOFF TOUR — three steps, at the one moment the ground moves.
|
||||
* The handoff tour: three steps shown when the build session first appears, because the profile changed under
|
||||
* the user without their asking. The user was talking to Hermes on its own profile and now sits mid-build in a
|
||||
* session of their own. Nothing on screen says where the welcome chat went, or that the sessions list now
|
||||
* belongs to a different profile.
|
||||
*
|
||||
* The handoff is the only point in the run where the user changes profile
|
||||
* without asking to: they were talking to Hermes on its own profile, and they
|
||||
* land mid-build in a session of their own. Nothing on screen says where the
|
||||
* welcome chat went, or that the sessions list they now see belongs to a
|
||||
* different profile than the one they were in a moment ago.
|
||||
*
|
||||
* The guide cannot narrate this itself: the tour bridge only paints for the
|
||||
* session the user is looking at, and after the handoff the guide is a
|
||||
* background session (desktop AGENTS.md: offer, don't hijack). So the app runs
|
||||
* the same three steps the guide would have asked for, in the user's language,
|
||||
* and the guide's one line in its own chat says nothing about them.
|
||||
* The guide cannot describe this itself: the tour bridge only runs a tour for the session the user is looking
|
||||
* at, and after the handoff the guide is a background session (desktop AGENTS.md requires offering rather than
|
||||
* taking over). The app runs the same three steps instead, in the user's language, and the guide's own note in
|
||||
* the welcome chat does not mention them.
|
||||
*/
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
/** Tour handles (`data-tour`), the same selectors the model gets back from a
|
||||
* targets scan, so a curated step and a model-driven one point at one thing. */
|
||||
/** Tour handles (`data-tour`). A targets scan returns these same selectors, so a curated step and a
|
||||
* model-driven step point at the same node. */
|
||||
const RAIL = '[data-tour="profile-rail"]'
|
||||
const SESSIONS = '[data-tour="sessions-sidebar"]'
|
||||
|
||||
/** The rail mounts a render or two after the handoff swaps profiles, so wait
|
||||
* for the node rather than firing into an empty DOM (the engine would return
|
||||
* a no-match and the moment would pass silently). Gives up quietly. */
|
||||
/** Waits for a visible node. The profile rail mounts a render or two after the handoff switches profiles, and
|
||||
* the tour engine returns a no-match for a selector that is not in the DOM yet. Returns false on timeout. */
|
||||
async function waitFor(selector: string, timeoutMs = 6000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
@@ -43,7 +38,7 @@ async function waitFor(selector: string, timeoutMs = 6000): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Run the handoff tour. Never throws, never blocks the handoff. */
|
||||
/** The caller does not await this, so the tour does not delay the handoff. */
|
||||
export async function showHandoffTour(): Promise<void> {
|
||||
if (!(await waitFor(RAIL))) {
|
||||
return
|
||||
@@ -51,9 +46,8 @@ export async function showHandoffTour(): Promise<void> {
|
||||
|
||||
const sessionsVisible = await waitFor(SESSIONS, 1500)
|
||||
const copy = (key: string) => translateNow(`handoffTour.${key}`)
|
||||
// Imported here, not at the top: this module is reachable from the boot path
|
||||
// through the handoff hook, and driver.js plus its stylesheet are exactly
|
||||
// what run-tour.ts keeps off it.
|
||||
// Imported here instead of at the top: this module is reachable from the boot path through the handoff
|
||||
// hook, and run-tour.ts keeps driver.js and its stylesheet out of that path.
|
||||
const { startTour } = await import('@/lib/tour')
|
||||
|
||||
await startTour([
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* The guided setup's escape hatch. Rides the composer's floating strip — the
|
||||
* same band the action badges and suggestion pills use — so it shares the
|
||||
* composer's edges instead of floating at an arbitrary offset. Skip assembles
|
||||
* the default layout, marks onboarding done, and drops the user in the full
|
||||
* app; the guided chat stays in the transcript. Visible from guide kickoff
|
||||
* until the layout pick assembles ($chatOnboardingSolo).
|
||||
* Skips the guided setup. Rendered in the composer's floating strip, the same row as the action badges and the
|
||||
* suggestion pills, so it aligns with the composer's edges. Skipping assembles the basic layout, sets the onboarding
|
||||
* phase to skipped, and leaves the user in the full app; the guided chat stays in the transcript. Shown from guide
|
||||
* kickoff until the layout pick assembles the app ($chatOnboardingSolo).
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
@@ -24,7 +24,7 @@ export function latestConnectorPart(messages: ChatMessage[]) {
|
||||
.at(-1)
|
||||
}
|
||||
|
||||
/** Connector names/results as presentation data, never authorization. */
|
||||
/** Connector names and statuses from the tool payload, for display only. No field here grants access. */
|
||||
export interface ConnectorRow {
|
||||
connector: string
|
||||
connected?: boolean
|
||||
@@ -172,7 +172,7 @@ export function connectionRows(
|
||||
return [...rows.values()]
|
||||
}
|
||||
|
||||
/** Token-bearing auth links are opened only by a deliberate user action. */
|
||||
/** The connect URL carries an authorization token, so only https with no embedded credentials is returned. */
|
||||
export function connectorAuthorizationUrl(value: ToolCallMessagePart['result']): string | null {
|
||||
const text = connectorText(value)
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* The onboarding picker used to be a hardcoded list, and it drifted from the
|
||||
* deployed catalog: it offered apps the gateway does not carry and spelled
|
||||
* others with hyphens the gateway does not use. The pick was then a promise
|
||||
* the build chat had to walk back. This hook asks the gateway what is
|
||||
* actually there, through the same session-owned RPC the connector cards
|
||||
* use, so the picker can only ever offer what can be connected.
|
||||
* others with hyphens the gateway does not use. The build chat then had to
|
||||
* tell the user the pick could not be connected. This hook asks the gateway
|
||||
* what is there, through the same session-owned RPC the connector cards use,
|
||||
* so the picker can only offer what can be connected.
|
||||
*
|
||||
* `available: false` (toolset off, signed out), a failed request, and a
|
||||
* request that takes longer than 15 s all resolve to `unavailable`; the caller
|
||||
|
||||
@@ -23,9 +23,8 @@ export interface FirstBuildConnectorState {
|
||||
toolCallId: string
|
||||
rows: FirstBuildConnectorRow[]
|
||||
started: boolean
|
||||
/** The "[setup] links opened" note, held until the session is idle. A submit
|
||||
* while the model's turn runs is rejected by the gateway, and the model
|
||||
* usually calls wait in that same turn, so the note is a fallback cue. */
|
||||
/** The "[setup] links opened" note, held until the session is idle. The gateway rejects a submit while the
|
||||
* model's turn runs, and the model usually calls wait in that same turn, so this note is only a fallback. */
|
||||
pendingNote?: string
|
||||
}
|
||||
|
||||
@@ -106,9 +105,8 @@ export async function openFirstBuildLinks(storedId: string, part: FirstBuildConn
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver the held note once the session is idle, if the connect that minted
|
||||
* the links is still the newest connector part. A newer part means the model
|
||||
* already moved on (it called wait itself), and the note would only confuse it. */
|
||||
/** Delivers the held note once the session is idle, and only while the connect call that produced the links is
|
||||
* still the newest connector part. A newer part means the model already called wait itself. */
|
||||
export function flushFirstBuildNote(
|
||||
storedId: string,
|
||||
newestToolCallId: string | undefined,
|
||||
@@ -260,7 +258,7 @@ export function watchFirstBuildRows(
|
||||
}
|
||||
}
|
||||
|
||||
/** A true submit result means the composer owns delivery through send, steer or queue. */
|
||||
/** A true result from submit means the composer delivered the text through send, steer or queue. */
|
||||
export function startFirstBuild(storedId: string, submit: (text: string) => boolean): void {
|
||||
const state = $firstBuildConnections.get()[storedId]
|
||||
const key = `hermes.onboarding.started.v1.${storedId}`
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* The main renderer owns the phase; the native overlay owns the clock because
|
||||
* animation frames in the hidden main window are throttled. Native skip/close
|
||||
* events return here so every exit records seen and restores the main window.
|
||||
* The phase lives in this store, in the main renderer; the clock runs in the native overlay, because
|
||||
* animation frames in the hidden main window are throttled. Native skip and close events come back here, so
|
||||
* every exit records the seen key and restores the main window.
|
||||
*
|
||||
* This store alone owns hermes-intro-reveal-seen-v1. First-run eligibility is
|
||||
* guest onboarding enabled, not explicitly skipped, and not seen. The gate
|
||||
* observes completion to queue the guided chat without coupling this store to it.
|
||||
* This store is the only writer of hermes-intro-reveal-seen-v1. First-run eligibility is guest onboarding
|
||||
* enabled, not explicitly skipped, and not seen. The gate observes completion to queue the guided chat, so
|
||||
* this store does not depend on the gate.
|
||||
*/
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
@@ -45,7 +45,7 @@ export function startIntroReveal(): void {
|
||||
}
|
||||
|
||||
$introReveal.set({ phase: 'playing' })
|
||||
// The film plays over the desktop; every exit path must restore the app.
|
||||
// The overlay covers the desktop, so every exit path has to restore the main window.
|
||||
void window.hermesDesktop?.introReveal?.open({ hideMain: true }).catch(finishIntroReveal)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Machine facts load before the runbook so a new computer or Spark can lead
|
||||
* with machine setup as the first task. */
|
||||
/** Machine facts load before the runbook is built so a new computer or a Spark can lead with machine setup as
|
||||
* the first task. */
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { DesktopMachineProfile } from '@/global'
|
||||
|
||||
/** Allow time to get around to setup without treating a daily-use machine as new. */
|
||||
/** 21 days leaves time to finish setup without counting a daily-use machine as new. */
|
||||
const NEW_MACHINE_DAYS = 21
|
||||
|
||||
export const $machine = atom<DesktopMachineProfile | null>(null)
|
||||
@@ -22,15 +22,15 @@ export async function loadMachineProfile(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Unknown counts as not-new: the option is always offered, it just doesn't
|
||||
* lead unless we can see a reason for it to. */
|
||||
/** An unknown age counts as not new. Machine setup is still offered; age makes it lead only when the age is
|
||||
* known and within NEW_MACHINE_DAYS. */
|
||||
export function machineLooksNew(): boolean {
|
||||
const age = $machine.get()?.ageDays
|
||||
|
||||
return age != null && age <= NEW_MACHINE_DAYS
|
||||
}
|
||||
|
||||
/** Login names that are not a name. 'akp' suggests fine; 'user' does not. */
|
||||
/** Generic login names that are not a person's name. A short handle such as 'akp' is still usable. */
|
||||
const NON_NAME_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
@@ -55,8 +55,8 @@ export function machineUserName(): string | null {
|
||||
return NON_NAME_USERNAMES.has(raw.toLowerCase()) ? null : raw
|
||||
}
|
||||
|
||||
/** Name the OS language for the model, independent of the UI's bundled locales.
|
||||
* English, missing or invalid tags need no language instruction. */
|
||||
/** Names the OS language for the model, independent of the UI's bundled locales. Returns null for English and
|
||||
* for a missing or invalid tag, which need no language instruction. */
|
||||
export function machineLanguageName(): string | null {
|
||||
const tag = ($machine.get()?.locale ?? '').trim()
|
||||
|
||||
@@ -88,13 +88,11 @@ export function machineIsSpark(): boolean {
|
||||
return rtx || dgx
|
||||
}
|
||||
|
||||
/** True when setting the machine up should be the only thing on offer, with
|
||||
* everything else folded away behind one more tap. */
|
||||
/** True when machine setup should be the only first task shown, with the other options behind one more tap. */
|
||||
export function machineSetupLeads(): boolean {
|
||||
return machineIsSpark() || machineLooksNew()
|
||||
}
|
||||
|
||||
/** What the user calls the thing in front of them. */
|
||||
export function machineKind(): string {
|
||||
if (machineIsSpark()) {
|
||||
return 'Spark'
|
||||
@@ -112,8 +110,8 @@ export function machineKind(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Age leads the setup brief because a new machine needs work that a
|
||||
* daily-use machine may already have done. */
|
||||
/** The age comes first in the description because a new machine needs setup work that a daily-use machine may
|
||||
* already have done. */
|
||||
export function machineDescription(): string {
|
||||
const profile = $machine.get()
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ function loadGate(): OnboardingGateState {
|
||||
|
||||
// Two phases owe a kickoff at boot. `cinematic` with the film already seen
|
||||
// is the film-to-guide seam. `guided` is a relaunch mid-guide: without a
|
||||
// kickoff the normal app boots around the persisted solo layout — the
|
||||
// kickoff the normal app boots around the persisted solo layout (the
|
||||
// connected splash, the stock composer and model picker, a small window
|
||||
// whose sidebars cannot open — while the gate still says the guide is on.
|
||||
// whose sidebars cannot open) while the gate still says the guide is on.
|
||||
// The kickoff adopts the existing guide chat by title, so nothing is lost.
|
||||
return { phase, guideQueued: (phase === 'cinematic' && hasSeenIntroReveal()) || phase === 'guided' }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* The words Hermes says during the guided first run.
|
||||
* The text Hermes sends during the guided first run: the runbook handed to the model at session.create, its persona,
|
||||
* the voice rules, and the option pills.
|
||||
*
|
||||
* Everything here is script, not state: the pre-banked greeting, the runbook
|
||||
* the model is handed at session.create, its persona, and the option pills the
|
||||
* runbook pins EXACTLY (a model that invents a pill strands the user, since
|
||||
* nothing downstream can interpret one the script never defined).
|
||||
*
|
||||
* Kept apart from the answers store on purpose — this is the file that gets
|
||||
* re-read and re-tuned by hand, and it should not mean scrolling past a state
|
||||
* machine to find it.
|
||||
* The runbook pins the option pill values exactly, because the app matches on that text. A pill the model invents
|
||||
* cannot be interpreted downstream.
|
||||
*/
|
||||
|
||||
import { machineKind, machineLanguageName, machineSetupLeads, machineUserName } from '@/store/machine'
|
||||
@@ -16,16 +11,12 @@ import { machineKind, machineLanguageName, machineSetupLeads, machineUserName }
|
||||
const VOICE_RULES =
|
||||
'Voice rules for EVERYTHING you write: plain declaratives in active voice. No em dashes (use commas or periods). No exclamation marks. Never praise the user. No AI diction (delve, seamless, robust, crucial, pivotal, landscape, testament, elevate, empower). No "not just X, it\'s Y" constructions. No forced lists of three. No generic closers ("you\'re all set", "happy to help", "the future looks bright") — end on the last real point. Contractions are fine. Specifics over adjectives.'
|
||||
|
||||
/** How Hermes talks for the whole of the first run — the guided chat and the
|
||||
* build session it hands off to. One constant because it was two, written by
|
||||
* hand in two files, already drifted, and it is the line that gets re-tuned
|
||||
* most often. */
|
||||
/** Voice rules for the whole first run. setup-profile.ts appends this to the build session's runbook, so the guided
|
||||
* chat and the build session use one copy. */
|
||||
export const PLAIN_SPEECH = `${VOICE_RULES} Keep every turn short. This is a chat, not a form: no headers, no bullet lists, no emoji, no restating their answer back at them before you reply to it, and none of "Great choice", "Perfect!", "Absolutely", "Certainly", "Great question", "Let me go ahead and". Read each line back as if you were saying it out loud to someone sitting beside you — say the thing itself, not a description of the thing. If it sounds like a form letter or a support macro, write it again.`
|
||||
|
||||
/** The seed rows for the guided chat's session.create: the invisible runbook
|
||||
* (model-visible, never rendered) followed by the pre-written greeting.
|
||||
* Pass the banked greeting the client is typing in (pickOnboardingGreeting)
|
||||
* so the canonical row and the animated reveal are the same words. */
|
||||
/** Seed rows for the guided chat's session.create: the hidden runbook row, then the greeting. Pass the greeting the
|
||||
* client is already animating (pickOnboardingGreeting) so the stored row and the animation hold the same words. */
|
||||
export function buildChatOnboardingSeedMessages(
|
||||
greeting: string,
|
||||
signedIn = false
|
||||
@@ -42,9 +33,7 @@ export function buildChatOnboardingSeedMessages(
|
||||
|
||||
const FORK_QUESTION = "Know what you'd like it to make?"
|
||||
|
||||
/** The fork's pills. Held as data because the runbook pins them EXACTLY — a
|
||||
* model that invents an option strands the user, since the app can't
|
||||
* interpret a pill the script never defined. */
|
||||
/** The fork's pills. Held as data because the runbook pins the same values and the app matches on the exact text. */
|
||||
const FORK_OPTIONS = {
|
||||
automate: 'Automate something I already do',
|
||||
figure: "Let's figure it out together",
|
||||
@@ -52,27 +41,18 @@ const FORK_OPTIONS = {
|
||||
skip: 'Skip this for now'
|
||||
} as const
|
||||
|
||||
/** "Help me set up this Spark" / "…this Mac" — named as the thing in front of
|
||||
* them, because being recognised is the whole trick. */
|
||||
export function machineForkOption(): string {
|
||||
return `Help me set up this ${machineKind()}`
|
||||
}
|
||||
|
||||
const SOMETHING_ELSE = 'Something else'
|
||||
|
||||
/** The look-around offer, placed the turn after the layout lands — the first
|
||||
* moment there is an app to look AT. Before the layout pick the window is
|
||||
* the conversation and nothing else, so a tour there would highlight a chat
|
||||
* pane and stop. Held as data for the same reason the fork is: the script
|
||||
* pins these three exactly. */
|
||||
/** The look-around offer. The runbook places it in the turn after the layout step, because until the layout is
|
||||
* applied the window holds only the chat pane and the tour would have nothing else to point at. */
|
||||
const TOUR_QUESTION = 'Want a look around first?'
|
||||
|
||||
/** Lightest first. Both of the first two run the tour — the difference is three
|
||||
* steps against six — and the short one reads as the easy answer when it is
|
||||
* the one their eye lands on, leaving the full look around as the deliberate
|
||||
* step up rather than the default. Nobody wants to open a new app into a
|
||||
* click-through, but three highlighted buttons with a line each beats three
|
||||
* lines of prose describing buttons the user then has to go find. */
|
||||
/** The tour pills. The runbook lists them as basics, tour, none, so the short tour reads first; the object below is
|
||||
* key-sorted. 'basics' and 'tour' both run the tour tool, and differ in length: three steps against four to six. */
|
||||
export const TOUR_OPTIONS = {
|
||||
basics: 'Quick tour',
|
||||
none: 'Skip, let’s build something',
|
||||
@@ -80,20 +60,8 @@ export const TOUR_OPTIONS = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Who the user is talking to.
|
||||
*
|
||||
* The rest of the runbook is mechanics and the voice rules are prohibitions,
|
||||
* and prohibitions can only ever remove things. Stack "no exclamation marks,
|
||||
* never praise the user, no closers, plain declaratives, short sentences" with
|
||||
* nothing pulling the other way and you get a competent stranger reading out a
|
||||
* form — which is exactly what the first draft of this flow sounded like.
|
||||
*
|
||||
* So this says who is talking, positively, and shows it rather than naming it:
|
||||
* the contrast pairs do more work than any adjective, because "be warm" is
|
||||
* unfalsifiable and "you mentioned Notion earlier" is not. Warmth here lives in
|
||||
* paying attention and in rhythm, never in punctuation or compliments — the
|
||||
* anti-slop rules still hold, and a chirpy Hermes would be worse than a flat
|
||||
* one.
|
||||
* Who the user is talking to. The rest of the runbook is mechanics and the voice rules are prohibitions, which can
|
||||
* only remove things; without this block the model's turns read as a form letter.
|
||||
*/
|
||||
const PERSONA = [
|
||||
'WHO YOU ARE, in voice: the person at the front desk of somewhere good. Pleased they walked in, and not performing it. Quick, unhurried, never flustered. You make the next thing easy without making a production of it. You have opinions and you offer them lightly ("most people go with the second one"). You remember what they said and use it two beats later instead of repeating it back at them. A little dry humour is welcome when it lands on its own; never reach for it.',
|
||||
@@ -102,21 +70,14 @@ const PERSONA = [
|
||||
'You are allowed to be brief to the point of terse when the moment is just a card and a nudge. Most of these turns are one sentence. That is not coldness, it is not wasting their time, and it is the main way this reads as a person rather than a wizard.'
|
||||
] as const
|
||||
|
||||
/** The cards that hand control to the user, and so end the turn that places
|
||||
* one. Named in RULE 3 rather than left implicit: a fast model reading a
|
||||
* numbered list reads it as a script to perform, and will happily ask for
|
||||
* their colour and their tools in the same breath — which puts two live cards
|
||||
* on screen, each waiting on an answer the other one is covering up. */
|
||||
/** The cards that wait on an answer, so the turn that places one ends there. RULE 3 lists them by name because a fast
|
||||
* model reads the numbered steps as one script to run through and places two cards in a single message, which leaves
|
||||
* two cards on screen, each waiting on an answer. */
|
||||
const QUESTION_CARDS = ['look', 'connectors', 'layout', 'first', 'handoff'].map(step => `::onboarding{step="${step}"}`)
|
||||
|
||||
/** Setting the machine up is always on offer: it is a first task Hermes can do
|
||||
* end to end with no account anywhere, and the one everybody with a new
|
||||
* computer already wants.
|
||||
*
|
||||
* On a machine that is new — or on a Spark, which nobody owns for its own
|
||||
* sake — it is the ONLY thing on offer, with the rest folded behind one more
|
||||
* tap. Four alternatives beside the obvious answer is a menu; the obvious
|
||||
* answer plus a way out is an offer. */
|
||||
/** The pills the runbook places at the fork. Setting the machine up is always offered, because it is the one first
|
||||
* task that needs no account anywhere. When machineSetupLeads() is true it is the only offer, and the rest move
|
||||
* behind "Something else". */
|
||||
export function forkOptions(): string[] {
|
||||
const { automate, figure, mind, skip } = FORK_OPTIONS
|
||||
|
||||
@@ -125,8 +86,7 @@ export function forkOptions(): string[] {
|
||||
: [mind, automate, machineForkOption(), figure, skip]
|
||||
}
|
||||
|
||||
/** The second tier — what "Something else" opens onto. Empty when the fork
|
||||
* already listed everything. */
|
||||
/** What "Something else" opens onto. Empty when forkOptions() already listed every pill. */
|
||||
export function forkFallbackOptions(): string[] {
|
||||
const { automate, figure, mind, skip } = FORK_OPTIONS
|
||||
|
||||
@@ -142,10 +102,8 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
return [
|
||||
"You are Hermes, and this is a brand-new user's very first conversation with you. Your job right now is to get the app arranged around them and their first real job started.",
|
||||
...PERSONA,
|
||||
// The machine's own language, not a guess from what they typed: this has
|
||||
// to hold on the FIRST turn, which answers a one-word name and carries no
|
||||
// signal at all. They can switch by simply writing in another language —
|
||||
// an OS setting is strong evidence, never an instruction to ignore them.
|
||||
// machineLanguageName() reports the OS language. The prompt uses that rather than the language of what the user
|
||||
// typed, because the first turn answers a one-word name and carries no language signal.
|
||||
...(language
|
||||
? [
|
||||
`This computer is set to ${language}, so write every visible word to them in ${language} — starting now, including the option pills you place. The greeting they have already seen was in ${language} too. If they write to you in a different language, follow THEM from that point on. Everything below describes what to say, not which language to say it in; the ::onboarding and ::ask directive names, their attribute names, and the exact option values pinned below stay verbatim in English because the app matches on them.`
|
||||
@@ -157,11 +115,9 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
'RULE 1 — never think out loud. Every visible word you write is spoken TO the user. Never write "Let me check/re-read/reconsider", never recap what step you are on, never mention steps, directives, [setup], prompts, or any mechanics in visible text. When you use tools, visible text is at most ONE short sentence to the user before the work and one after. Planning happens silently or not at all — a message that narrates your process instead of talking to the user is a failure.',
|
||||
'RULE 2 — images are welcome but never a surprise and never a delay: deliver the TEXT deliverable first, and only then, when a visual genuinely helps (a header image for an announcement, a mock for a page), you may generate ONE image — always introduced with a short line naming what you made and why ("I generated a header image for the announcement — swap or drop it"). Never let image generation stall or replace the text answer, never more than one per turn, and never for plain lists, plans, or checklists.',
|
||||
`RULE 3 — ONE question per turn, then stop. These hand control back to the user and END your turn the moment you write one: ${QUESTION_CARDS.join(', ')}, and every ::ask. Place exactly one, then stop: never ask the next thing in the same message, and never tell them what is coming. Their answer arrives as the next message, and that is what moves you forward. Two questions in one message is a failure: you asked something whose answer you have not heard yet, and they are looking at two half-answered cards stacked on top of each other. (::onboarding{step="name"} and ::onboarding{step="working"} are NOT questions — they render as nothing and only save what the user just told you, so they belong in the same turn as the question that follows them.)`,
|
||||
// RULE 4 exists because of a live run: the user typed "brooke" and the
|
||||
// model spent SIX API calls and thirty-six seconds writing the same fact to
|
||||
// memory over and over, saying "Brooke it is." between each one, and never
|
||||
// reached the colour card. Nothing told it the save was already done, and a
|
||||
// returning tool result reads to a flash model as a cue to speak again.
|
||||
// RULE 4 comes from a live run: after the user typed their name, the model made six API calls over thirty-six
|
||||
// seconds writing the same fact to memory, and never reached the colour card. Nothing in the prompt said the save
|
||||
// was already done, and a returned tool result reads to a fast model as a cue to speak again.
|
||||
'RULE 4 — the card beats carry NO tool calls. Placing an ::onboarding card is pure text plus the directive, nothing else: the directive itself is what saves the answer, so there is no tool to reach for. And in any turn at all, never call the same tool twice — a returned tool result means that work is DONE, not that you should speak again and re-do it. When a call comes back, finish your one line and stop.',
|
||||
'Your first message has ALREADY been sent for you: it greeted them and asked what you should call them. Do not greet again — their next message is their answer.',
|
||||
...(suggestedName
|
||||
@@ -173,14 +129,8 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
'1. This turn is exactly four things and then you stop: a few warm words about their name, then ::onboarding{step="name" value="THEIR_NAME"} on a line of its own (THEIR_NAME being the name they actually gave; it renders as nothing and just saves it), then one short sentence about their colour, then ::onboarding{step="look"} on a line of its own. That is one turn, not two, and it is not a conflict with RULE 3: the name line is not a question, the look card is, and it is the last thing you write.',
|
||||
'2. Then the apps they already use, so Hermes can connect to them later: one short sentence that makes clear what connecting means — you would read and act inside those apps for them (their inbox, their calendar, their repos), not message them there — then ::onboarding{step="connectors"} on a line of its own. Chat apps like Discord or Telegram are a different thing (how they reach you) and are not what this card is asking about; if they bring one up, say it lives in Messaging in the app’s settings and move on.',
|
||||
'CONNECTING, IF THEY ASK FOR IT HERE. The picks are preferences, not connections — but if at any point they ask you to connect an app, or say they want one wired up now, do it in this chat: call manage_connections action="status" once, then one action="connect" with EVERY app they named as a batch (connectors=["gmail","googlecalendar"], not one call per app). The app renders that as a Connect card per app — the card is the ask, so write one short line and END YOUR TURN; never paste the links, never describe a settings page. Their click arrives as a hidden [connectors] message telling you the exact next call; follow it, and when it says wait, call action="wait" and hold. Never call connect a second time for an app that already has a card: a new link cancels the one they are signing in with. If an app is not in the status catalog, say so plainly. There is no Connectors page in Settings; do not send them to one.',
|
||||
// The one place sign-in is named BEFORE it is needed. It goes here because
|
||||
// this beat already put the idea in their head — they just listed the
|
||||
// accounts they live in — so "you'll want an account for that" reads as an
|
||||
// answer rather than a sales pitch. And it says FREE in the same breath:
|
||||
// the fear being headed off is not signing up, it is being asked for a
|
||||
// card two minutes into an app they have not decided about yet. Once, in
|
||||
// passing, never again — a second mention is nagging, and the real ask
|
||||
// comes later on its own.
|
||||
// The only place sign-in is named before it is needed. It sits at the connectors step because the user has just
|
||||
// listed the accounts they use.
|
||||
...(signedIn
|
||||
? []
|
||||
: [
|
||||
@@ -203,11 +153,6 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
' - SPECIFIC task in mind: skip the options card — go straight to the handoff.',
|
||||
` - "${machine}": the machine itself is the job. Ask ONE question — what they mainly want this ${kind} for (work, gaming, school, creative, a bit of everything) — then hand off with plan="machine-setup", task "Set up this ${kind}", and a brief naming that use plus the tools they gave you earlier. Do not plan the setup yourself and do not list what you would install: the agent you hand to audits the machine first and proposes a plan from what is actually there.`,
|
||||
` - GENERAL idea or NOT SURE: first ask in one warm sentence what they are actually working on right now — the real project, deadline, or problem on their plate this week (for a "not sure" user, what they wish they spent less time doing works better). One short follow-up if the answer is vague, then ::onboarding{step="working" value="THEIR_ANSWER"} on a line of its own (THEIR_ANSWER = one line, their key details, under 140 characters; renders as nothing, it just saves what they said). Then a card of options built from that answer plus their apps, again on a line of its own: ::onboarding{step="first" options="First idea|Second idea|Third idea"} — 2 to 4 options, each a short phrase (under 60 chars), spanning simple (a reminder) to complex (a dashboard), all specific to THIS user, separated by |. THE APPS THEY PICKED DRIVE THESE OPTIONS: someone who picked Gmail and Calendar should see an inbox or schedule idea ("A morning brief of today's meetings and unread mail"), someone who picked GitHub and Linear should see a repo or ticket idea, and someone who picked nothing gets ideas that need no account at all. At least one option should stand on its own without any connection, so there is always a pick that runs today. Their tap IS their reply — hand off from it.`,
|
||||
// Plugins are the strongest first build we can offer — the result lands
|
||||
// inside the window they are already looking at, in seconds, and it is
|
||||
// theirs. But only for the answers that actually suit it: forcing one on
|
||||
// "write my standup email" produces a worse version of a simple task.
|
||||
// Hence a test the model applies, not a quota it fills.
|
||||
' WHEN A PLUGIN FITS, MAKE IT ONE OF THOSE OPTIONS. Hermes can build pieces of its own interface — a small chip in the status bar, a button by the composer, a panel beside the chat — and the user watches it appear in this window as you write it. That is the best first build available whenever what they described is something they would want to SEE or REACH at a glance: a number they keep checking, a list they keep opening, a status they keep asking about, a thing they wish were one click instead of five. Phrase it as the outcome, never as the mechanism ("A panel with today\'s tickets", not "Write a plugin"). Roughly one option, not the whole card, and only alongside the other shapes — a task that is genuinely just a task (draft this, research that, rename these files) should not be bent into an interface.',
|
||||
' If they pick that one, hand off with plan="plugin" on the handoff line.',
|
||||
` - "${FORK_OPTIONS.skip}": say one short line that the app is theirs and this chat stays here if they ever want a hand, then stand down. No more questions, no handoff.`,
|
||||
@@ -223,9 +168,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
'Memory: the card beats need no memory tool. The ::onboarding lines persist their answers, and the handoff saves the agreed name, context and app preferences into their working profile for later conversations. Do not duplicate that write or narrate its mechanics.',
|
||||
'Their picks arrive as invisible messages prefixed [setup] — acknowledge each in a few words, in your own words, never the same phrase twice, and move to the next step.',
|
||||
PLAIN_SPEECH,
|
||||
// Last thing the model reads, and it is the persona rather than the ban
|
||||
// list — end on a wall of prohibitions and it writes like someone trying
|
||||
// not to get in trouble.
|
||||
// Kept last because a prompt that ends on the list of prohibitions produces flat, cautious turns.
|
||||
'Above all of that: someone just walked in and you are glad to see them. Sound like it.'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
"""Guided tour (highlight + narrate UI elements) in the Hermes desktop GUI: the agent discovers
|
||||
targets (``action="targets"``), then highlights one step at a time (``show``) or hands over a
|
||||
step list the user pages (``start``). Round-trips through the gateway blocking-prompt bridge
|
||||
(``tour.request``/``tour.respond``) so the agent learns whether the selector matched. Lives in
|
||||
``desktop_ui`` and withdraws itself when tours are off: a tour takes the whole screen, so "off"
|
||||
(``tour.request``/``tour.respond``) so the agent learns whether the selector matched. Registered in
|
||||
``desktop_ui`` and hidden from the model when tours are off: a tour covers the whole screen, so "off"
|
||||
must mean the model is never told the tool exists rather than offered a call that fails."""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Session-owned connector UI RPCs; authorization is not consent to read app data.
|
||||
"""Connector list and connect RPCs for one session.
|
||||
|
||||
Both calls run on the RPC pool. WS upgrade authentication (including legacy
|
||||
local/SSH tokens) and live transport membership are the authority, not a
|
||||
renderer-supplied profile or identity. No agent build, browser, or wait loop.
|
||||
Both calls run on the RPC pool. Authorization comes from the WebSocket upgrade
|
||||
authentication (including legacy local and SSH tokens) and from live transport
|
||||
membership; a profile or identity sent by the renderer does not grant it.
|
||||
Neither call builds an agent, opens a browser, or waits in a loop.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
@@ -43,7 +44,8 @@ def _connector_rpc(rid, params, action):
|
||||
if _session_uses_compute_host(owner):
|
||||
return _connector_rpc_error(rid, 5033, "UNSUPPORTED_RUNTIME", "Connectors must be managed on the session's compute host.")
|
||||
allowed = {"session_id"} if action == "status" else {"session_id", "connectors", "reconnect"}
|
||||
# Shared-primary routing adds this metadata; the live transport above owns authorization.
|
||||
# Shared-primary routing adds a profile parameter. Authorization comes from the live transport checked
|
||||
# above, so this parameter is accepted and unused.
|
||||
allowed.add("profile")
|
||||
if set(params) - allowed:
|
||||
return _connector_rpc_error(rid, 4000, "INVALID_PARAMS", "unsupported connector parameters")
|
||||
@@ -117,7 +119,7 @@ def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
|
||||
|
||||
@method("connectors.list")
|
||||
def _(rid, params):
|
||||
"""{session_id} -> {available, connectors}; raw metadata additions survive."""
|
||||
"""{session_id} -> {available, connectors}; unknown fields in the connector metadata are passed through."""
|
||||
return _connector_rpc(rid, params, "status")
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Explicit handoff of agreed setup facts, not shared profile memory."""
|
||||
"""Writes the setup facts agreed during onboarding into the default profile's user memory."""
|
||||
import json
|
||||
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
@@ -29,14 +29,14 @@ def remember_onboarding(answers: dict) -> dict:
|
||||
if len(content) > 2000:
|
||||
raise ValueError('Onboarding facts are too long to remember')
|
||||
|
||||
# Resolve the named default through the same path authority as profiles,
|
||||
# even when this RPC arrived on the guide's backend or a custom root.
|
||||
# The entry must land in the 'default' profile directory even when this RPC arrives on the guide's
|
||||
# backend or under a custom Hermes home.
|
||||
token = set_hermes_home_override(get_profile_dir('default'))
|
||||
try:
|
||||
result = json.loads(memory_tool(action='add', target='user', content=content, store=load_on_disk_store()))
|
||||
if not result.get('success') or result.get('staged'):
|
||||
raise ValueError(result.get('error') or result.get('message') or 'Memory was not saved')
|
||||
# An ACK is not persistence: read back the exact entry on a fresh store.
|
||||
# memory_tool can report success without the entry reaching disk, so read it back from a fresh store.
|
||||
if content not in load_on_disk_store().user_entries:
|
||||
raise ValueError('Could not verify saved onboarding facts')
|
||||
return {'saved': True, 'profile': 'default', 'target': 'user'}
|
||||
|
||||
Reference in New Issue
Block a user