[eric] onboarding: entire-chat harvest + clustering profile + four-capability reveal

This commit is contained in:
ciregenz
2026-07-19 22:57:16 -07:00
parent 33c0cd12af
commit f3012d184a
19 changed files with 460 additions and 92 deletions
+8
View File
@@ -54,6 +54,9 @@ class PrepRequest(BaseModel):
class PrepResponse(BaseModel): class PrepResponse(BaseModel):
model_config = ConfigDict(validate_assignment=True) model_config = ConfigDict(validate_assignment=True)
# A punchy <=10-word identity hook, read at a glance in the reveal's focal beat (the greeting is the
# longer warm read for the chat; the headline is the scannable one-liner most people actually read).
headline: str = ""
greeting: str = "" greeting: str = ""
starters: List[PersonalizedStarter] = Field(default_factory=list) starters: List[PersonalizedStarter] = Field(default_factory=list)
app_title: str = "" app_title: str = ""
@@ -64,4 +67,9 @@ class PrepResponse(BaseModel):
research_title: str = "" research_title: str = ""
research_prompt: str = "" research_prompt: str = ""
research_reason: str = "" research_reason: str = ""
# The "watch it drive a real browser" card: an agent opens a public site and does a multi-step task
# live, so the reveal shows off browser control alongside app-building, research, and scheduling.
browser_title: str = ""
browser_prompt: str = ""
browser_reason: str = ""
automations: List[PersonalizedAutomation] = Field(default_factory=list) automations: List[PersonalizedAutomation] = Field(default_factory=list)
+15 -11
View File
@@ -47,16 +47,20 @@ async def post_prep(body: PrepRequest) -> dict:
from backend.apps.onboarding.usage.claude_usage import harvest_claude_usage from backend.apps.onboarding.usage.claude_usage import harvest_claude_usage
from backend.apps.settings.store import load_settings from backend.apps.settings.store import load_settings
# The frontend read needs a logged-in provider CARD, which a fresh install lacks. Fill the gap from what we already have: ChatGPT via the codex connect token, Claude via the user's own browser session cookies. Each fails open to "", so a missing one just drops out. # ALWAYS read the ENTIRE recent conversations (not just titles) from the rich providers, ChatGPT via
if not body.usage_summary.strip(): # the codex connect token, Claude via the user's own browser session cookies, and PREFER that over
parts: list[str] = [] # whatever the frontend read. The frontend reads only the single connected provider, which for a
chatgpt = await harvest_chatgpt_usage() # Gemini/antigravity user is a titles-only DOM scrape that can surface a stale topic (the "skincare
if chatgpt: # app" the user hasn't touched in ages). Multiple providers connected? We take all the rich ones and
parts.append("ChatGPT usage:\n" + chatgpt) # let the clustering pass merge them into one profile. Each fails open to "", so a missing one drops.
claude = await harvest_claude_usage() parts: list[str] = []
if claude: chatgpt = await harvest_chatgpt_usage()
parts.append("Claude usage:\n" + claude) if chatgpt:
if parts: parts.append("ChatGPT conversations:\n" + chatgpt)
body.usage_summary = "\n\n".join(parts) claude = await harvest_claude_usage()
if claude:
parts.append("Claude conversations:\n" + claude)
if parts:
body.usage_summary = "\n\n".join(parts) # entire-chat content wins over the frontend's titles
return (await build_prep(load_settings(), body)).model_dump() return (await build_prep(load_settings(), body)).model_dump()
+128 -17
View File
@@ -27,19 +27,33 @@ P_SYSTEM = (
"You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can " "You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can "
"organize local files, browse the web in a real browser, build small apps, and run agents in parallel. " "organize local files, browse the web in a real browser, build small apps, and run agents in parallel. "
"Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: " "Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: "
'{"greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. ' '{"headline": string, "greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "browser_title": string, "browser_prompt": string, "browser_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. '
"First, silently infer a short, confident profile of this user: who they are and what they are working on. " "First, silently infer a short, confident profile of this user: who they are and what they are working on. "
"If usage_summary is present it is the STRONGEST signal (it is what they actually ask their AI about and facts " "If usage_summary is present it is the STRONGEST signal (a distilled profile of who this person is and what "
"their AI remembers about them); weight it above everything else, then signal_apps (the high-signal tools they " "they actually work on, read from their real AI conversations); weight it above everything else. Do NOT let a "
"have installed, like an IDE, a design app, or a DAW: these reveal their craft), then folders, plan tier, email " "single installed app override it: signal_apps (an IDE, a design app, a DAW) is only a WEAK hint about their "
"domain. Tune every task and the personal app to that profile; do not output the profile. " "craft (having Xcode installed does not make someone an iOS developer), used only to color what the profile "
"already says, then folders, plan tier, email domain. Tune every task and the personal app to that profile; "
"do not output the profile. "
"THE BAR every single item must clear: it is either (a) SPECIFICALLY useful to THIS person's real work in a way " "THE BAR every single item must clear: it is either (a) SPECIFICALLY useful to THIS person's real work in a way "
"they could not quickly get elsewhere (it uses their ACTUAL files, projects, or data to produce a real finished " "they could not quickly get elsewhere (it uses their ACTUAL files, projects, or data to produce a real finished "
"thing worth keeping), OR (b) a genuine 'oh, it can do THAT?' that makes them see a hundred uses (a surprising " "thing worth keeping), OR (b) a genuine 'oh, it can do THAT?' that makes them see a hundred uses (a surprising "
"capability shown on their real stuff). A generic chore FAILS the bar and must be replaced: a file-cleanup report, " "capability shown on their real stuff). A generic chore FAILS the bar and must be replaced: a file-cleanup report, "
"a folder audit, a read-only summary or mirror dashboard of their data, a research overview they could google, an " "a folder audit, a read-only summary or mirror dashboard of their data, a research overview they could google, an "
"empty log file, or any 'set up / organize / plan' task is BANNED. Depth over breadth: two things that hit the bar " "empty log file, or any 'set up / organize / plan' task is BANNED. "
"beat four that do not. " "SPAN THE WHOLE PERSON. First, silently list this person's DISTINCT life threads from the profile (for example: "
"their main work or product, their sport or fitness, their food or local life, their tech or tooling curiosity, "
"their games or hobbies, a recurring practical need). Then assign the four starters to FOUR DIFFERENT threads, "
"one each. HARD RULE: treat EVERYTHING about their company, product, startup, pitch, competitive landscape, "
"positioning, pricing, fundraising, growth, or the thing they are building as ONE single 'work' bucket. EXACTLY "
"ONE of the four starters may come from that work bucket, no more, count them before you answer. The other THREE "
"must each come from a clearly DIFFERENT NON-work thread you actually see in the profile (their sport or fitness, "
"their food or local life, their games or hobbies, a personal curiosity or practical need). Anything work-adjacent, "
"even a browser tool 'for the product' or research 'for the pitch', is WORK and does NOT count as a non-work "
"thread. Before you finalize, verify that THREE of the four starters have nothing to do with their work; if they "
"do, replace them. The app and the research should also lean to NON-work threads, not pile onto the work one. The "
"ONLY exception: if the profile genuinely shows they talk about almost nothing but that one thing, follow the real "
"data instead of forcing variety. Every item still clears the bar on its own. "
"Exactly 4 starters. Each title is 2-5 words that PLAINLY say what the task does (like 'Frame my screenshots' or " "Exactly 4 starters. Each title is 2-5 words that PLAINLY say what the task does (like 'Frame my screenshots' or "
"'Compare headphones'), never clever, punny, or brand-style. Each prompt is a concrete, safe, immediately runnable " "'Compare headphones'), never clever, punny, or brand-style. Each prompt is a concrete, safe, immediately runnable "
"task referencing the user's real folders, files, or picked apps; never invent facts. The FIRST starter is the one " "task referencing the user's real folders, files, or picked apps; never invent facts. The FIRST starter is the one "
@@ -57,13 +71,18 @@ P_SYSTEM = (
"the 'oh, it can build me THAT?' moment. It must DO something: take their input and produce useful output, or " "the 'oh, it can build me THAT?' moment. It must DO something: take their input and produce useful output, or "
"automate a fiddly micro-task they repeat in their ACTUAL work (inferred from usage_summary + signal_apps). It is " "automate a fiddly micro-task they repeat in their ACTUAL work (inferred from usage_summary + signal_apps). It is "
"a real interactive tool they would reopen and USE, NOT a read-only dashboard, NOT a mirror of their data, NOT a " "a real interactive tool they would reopen and USE, NOT a read-only dashboard, NOT a mirror of their data, NOT a "
"summary, NOT a feed. Examples of the SHAPE only (never copy, always tailor to THEM): for someone shipping iOS " "summary, NOT a feed. Examples of the SHAPE only (never copy, always tailor to THEM, and never "
"apps, a tool that frames a raw screenshot into App Store device mockups, or previews an app icon at every iOS " "default to an iOS, app, or coding tool just because it is a familiar example): for a writer, a tool that "
"size on a mock home screen; for a writer, a tool that rewrites a pasted paragraph across tones; for a data " "rewrites a pasted paragraph across tones; for a data person, a tool that pastes a CSV and instantly charts it; "
"person, a tool that pastes CSV and instantly charts it. app_title 2-4 words that plainly name what it DOES " "for a musician, a tool that transposes a chord progression; for a language learner, a drill built from words "
"they paste. Match the shape to THIS person's actual craft from the profile. app_title 2-4 words that plainly name what it DOES "
"(like 'Icon Previewer' or 'Screenshot Framer'), never punny. app_prompt starts with 'Build me' and specifies the " "(like 'Icon Previewer' or 'Screenshot Framer'), never punny. app_prompt starts with 'Build me' and specifies the "
"tool's INPUT, what it PRODUCES, and the interaction; fully client-side and self-contained (works with no " "tool's INPUT, what it PRODUCES, and the interaction; fully client-side and self-contained. It MUST run "
"accounts, no API keys, no backend, all in the browser). app_reason follows the same one-clause grounded-" "on the user's input with DETERMINISTIC logic only (math, parsing, formatting, layout, charts, filtering, "
"transforms). It must NEVER call an AI model, an LLM, a chat completion, or any network/remote API, those "
"only work inside a published app and will fail in the reveal with 'make sure you're on a published app'. "
"If the idea would need AI generation to work, pick a DIFFERENT tool that doesn't (no accounts, no API keys, "
"no backend, no fetch, everything computed in the browser). app_reason follows the same one-clause grounded-"
"observation rule as a starter reason and says why THIS tool fits their real work. " "observation rule as a starter reason and says why THIS tool fits their real work. "
"Also pick the SINGLE topic this user most repeatedly asks their AI about (from usage_summary; if it is thin, use " "Also pick the SINGLE topic this user most repeatedly asks their AI about (from usage_summary; if it is thin, use "
"their strongest work signal from signal_apps or folders) and turn it into a live web-research task. research_title " "their strongest work signal from signal_apps or folders) and turn it into a live web-research task. research_title "
@@ -72,6 +91,23 @@ P_SYSTEM = (
"current answer or comparison of THAT topic for this user (an actual answer, never a plan); it must demand " "current answer or comparison of THAT topic for this user (an actual answer, never a plan); it must demand "
"THIS-YEAR information with publication dates on sources, so the answer cannot quietly be stale training data. " "THIS-YEAR information with publication dates on sources, so the answer cannot quietly be stale training data. "
"research_reason follows the one-clause grounded-observation rule and names the specific recurring question you saw. " "research_reason follows the one-clause grounded-observation rule and names the specific recurring question you saw. "
"Also design ONE browser task that shows the agent DRIVING a real website live (so the user watches it control a "
"browser, not just fetch text). browser_title is 2-4 words plainly naming it (like 'Nearby Michelin' or 'Jump "
"Threads'). browser_prompt tells the agent to OPEN a specific real, PUBLIC website by name and do a genuinely "
"MULTI-STEP task there: navigate, search, click through, read across a few pages, compare, then report what it "
"found. It must be safe read-only browsing on public pages only, NEVER log in, buy, post, submit, or act on the "
"user's behalf. Pick a topic from a DIFFERENT thread than the app and the research, ideally a fun or personal "
"interest (food, sport, travel, a hobby), not their work. browser_reason follows the one-clause grounded rule. "
"THE FOUR THINGS THAT ACTUALLY RUN are the app, the research, the browser task, and the first automation. This "
"rule OVERRIDES every 'pick their craft / their top topic' hint above: assign each of these four to a DIFFERENT "
"thread of this person's life, and AT MOST ONE of the four may touch their work/company/product/pitch/competitors, "
"count them before you answer. FIXED ASSIGNMENT: the APP must be built for a NON-work thread (a hobby, sport, "
"food, or personal need), it is a delightful surprise precisely because it is NOT about their job (a jump-log "
"tool, a restaurant picker, a practice-drill tool, never a pitch/competitor/metrics dashboard). The BROWSER task "
"must also take a clearly personal or fun NON-work thread. Only the RESEARCH may be about their work, and only if "
"that is genuinely their burning question; if you use work for the research, then the first automation must be "
"NON-work too, so no more than ONE of the four is ever about work. Make all four GENUINELY MULTI-STEP (several "
"real actions, never a one-liner). "
"Also propose 1-2 automations: recurring routines that genuinely help THIS user and clear the bar (NEVER a folder " "Also propose 1-2 automations: recurring routines that genuinely help THIS user and clear the bar (NEVER a folder "
"cleanup, NEVER an empty log, NEVER 'keep a dashboard updated'). A good one delivers something the user actually " "cleanup, NEVER an empty log, NEVER 'keep a dashboard updated'). A good one delivers something the user actually "
"wants on a cadence, e.g. a daily digest of what is new in their SPECIFIC niche (named from usage_summary and " "wants on a cadence, e.g. a daily digest of what is new in their SPECIFIC niche (named from usage_summary and "
@@ -82,10 +118,19 @@ P_SYSTEM = (
"schedules or reminders (the schedule already exists), and write the result to a concrete file (like " "schedules or reminders (the schedule already exists), and write the result to a concrete file (like "
"Documents/<name>_<date>.md). 'Search X and write the result to Y' is right; 'remind me' or 'set up a log' is wrong. " "Documents/<name>_<date>.md). 'Search X and write the result to Y' is right; 'remind me' or 'set up a log' is wrong. "
"Safe to run unattended (never delete without review). " "Safe to run unattended (never delete without review). "
"The greeting is one or two warm sentences: first say out loud, specifically and confidently, what this person is " "The HEADLINE is the single most important line: a punchy, specific, SCANNABLE identity hook of AT MOST 10 "
"into or working on (grounded in usage_summary, signal_apps, and folders, for example 'Looks like you live in " "words that this person reads in one second and thinks 'yes, that's me'. Name their actual work and their one "
"Xcode and ship iOS apps'), then name 2-3 concrete things you actually saw. Be specific, never generic, and never " "defining trait, no filler, no full sentence, no period. It is read at a glance in big type, so it must NOT be "
"name boring system apps. Never use em-dashes or en-dashes anywhere. No markdown, no commentary, JSON only." "a paragraph. Example shapes only (never copy, tailor to THEM): 'OpenSwarm founder who measures everything, "
"vertical jump to agent latency' or 'Ships iOS apps, obsessed with the last 5% of polish'. Sharp, not wordy. "
"The greeting is one or two warm, punchy sentences that make this person feel INSTANTLY understood, the "
"'wait, it actually gets me' hook. Lead with the single most specific true thing about them from the profile "
"(their actual project BY NAME, their real craft, the obsession they keep returning to), then add ONE more "
"concrete detail that proves you get them. Ground it in the profile above all; nod to a folder or tool only if "
"it sharpens the picture, never lead with a generic installed app, and never name boring system apps. It should "
"read like a sharp friend who knows exactly what you're about, not a system reciting what it scanned. Do not be "
"creepy: name their work and interests, not private personal numbers. Never use em-dashes or en-dashes anywhere. "
"No markdown, no commentary, JSON only."
) )
@@ -183,6 +228,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
data = p_load_object(text) data = p_load_object(text)
starters = p_build_starters(data.get("starters") if isinstance(data.get("starters"), list) else []) starters = p_build_starters(data.get("starters") if isinstance(data.get("starters"), list) else [])
automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else []) automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else [])
headline = str(data.get("headline", "")).strip()
greeting = str(data.get("greeting", "")).strip() greeting = str(data.get("greeting", "")).strip()
app_title = str(data.get("app_title", "")).strip() app_title = str(data.get("app_title", "")).strip()
app_prompt = str(data.get("app_prompt", "")).strip() app_prompt = str(data.get("app_prompt", "")).strip()
@@ -190,6 +236,9 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
research_title = str(data.get("research_title", "")).strip() research_title = str(data.get("research_title", "")).strip()
research_prompt = str(data.get("research_prompt", "")).strip() research_prompt = str(data.get("research_prompt", "")).strip()
research_reason = str(data.get("research_reason", "")).strip() research_reason = str(data.get("research_reason", "")).strip()
browser_title = str(data.get("browser_title", "")).strip()
browser_prompt = str(data.get("browser_prompt", "")).strip()
browser_reason = str(data.get("browser_reason", "")).strip()
# Truncation / trailing comma / smart quotes broke the strict load: salvage the complete pieces # Truncation / trailing comma / smart quotes broke the strict load: salvage the complete pieces
# rather than throwing the whole personalized reveal away for one bad character. # rather than throwing the whole personalized reveal away for one bad character.
@@ -201,6 +250,8 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
automations = p_build_automations([o for o in objs if "cadence" in o]) automations = p_build_automations([o for o in objs if "cadence" in o])
# Top-level string fields don't live in the flat objects above, so recover them by name when the # Top-level string fields don't live in the flat objects above, so recover them by name when the
# strict load dropped them (a malformed response was still yielding starters but a blank app). # strict load dropped them (a malformed response was still yielding starters but a blank app).
if not headline:
headline = p_extract_string_field(text, "headline")
if not greeting: if not greeting:
greeting = p_extract_string_field(text, "greeting") greeting = p_extract_string_field(text, "greeting")
if not app_title: if not app_title:
@@ -215,10 +266,17 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
research_prompt = p_extract_string_field(text, "research_prompt") research_prompt = p_extract_string_field(text, "research_prompt")
if not research_reason: if not research_reason:
research_reason = p_extract_string_field(text, "research_reason") research_reason = p_extract_string_field(text, "research_reason")
if not browser_title:
browser_title = p_extract_string_field(text, "browser_title")
if not browser_prompt:
browser_prompt = p_extract_string_field(text, "browser_prompt")
if not browser_reason:
browser_reason = p_extract_string_field(text, "browser_reason")
if not starters: if not starters:
return None return None
return PrepResponse( return PrepResponse(
headline=p_strip_dashes(headline),
greeting=p_strip_dashes(greeting), greeting=p_strip_dashes(greeting),
starters=starters[:4], starters=starters[:4],
app_title=p_strip_dashes(app_title), app_title=p_strip_dashes(app_title),
@@ -227,6 +285,9 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
research_title=p_strip_dashes(research_title), research_title=p_strip_dashes(research_title),
research_prompt=p_strip_dashes(research_prompt), research_prompt=p_strip_dashes(research_prompt),
research_reason=p_strip_dashes(research_reason), research_reason=p_strip_dashes(research_reason),
browser_title=p_strip_dashes(browser_title),
browser_prompt=p_strip_dashes(browser_prompt),
browser_reason=p_strip_dashes(browser_reason),
automations=automations[:3], automations=automations[:3],
) )
@@ -276,6 +337,48 @@ def p_scan_grounded_fallback(request: PrepRequest) -> PrepResponse:
) )
# The clustering pass: one cheap read that turns the raw chat dump into a tight character read, so the
# reveal reasons over "who is this person" instead of skimming fragments and latching onto a stray word.
P_PROFILE_SYSTEM = (
"You are reading a person's OWN recent AI chat conversations (their messages and the AI's replies, "
"most recent first). Write a SHORT, confident, specific profile of who this person actually is and "
"what they genuinely work on and care about, grounded ONLY in what you see. 3 to 5 sentences, plain "
"prose, no lists, no hedging, no preamble, no 'based on'. Name concrete specifics: the projects they "
"are building, the tools and languages they use, the topics they return to again and again, their "
"interests and side-obsessions, how they think. Separate a real recurring throughline from a one-off "
"tangent, weight what they keep coming back to. If one thing is clearly their main focus right now, "
"say so plainly; if their attention is split across a few real threads, name them. Never invent "
"anything not present. No markdown. Never use em-dashes or en-dashes."
)
# Below this the usage text is just titles/memories (thin); above it there is real conversation content
# worth a distill pass. Keep the distill input bounded so the cheap call stays a couple cents.
P_PROFILE_DISTILL_THRESHOLD = 1500
P_PROFILE_INPUT_CAP = 140000
@typechecked
async def p_distill_profile(settings: AppSettings, usage_text: str) -> str:
"""One cheap aux call: raw chat content -> a tight 'who is this person' profile. "" on any failure,
so build_prep just falls back to feeding the raw usage text (today's behavior)."""
try:
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
client = get_anthropic_client_for_model(settings, aux_model)
resp = await client.messages.create(
model=aux_model,
max_tokens=aux_max_tokens_for(aux_model, base=600),
system=P_PROFILE_SYSTEM,
messages=[{"role": "user", "content": usage_text[:P_PROFILE_INPUT_CAP]}],
timeout=45.0,
)
return p_strip_dashes(safe_resp_text(resp).strip())
except Exception:
return ""
@typechecked @typechecked
async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepResponse: async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepResponse:
from datetime import date from datetime import date
@@ -284,6 +387,14 @@ async def build_prep(settings: AppSettings, request: PrepRequest) -> PrepRespons
# The aux otherwise assumes its training-cutoff year and writes stale ranges like "2024-2025" # The aux otherwise assumes its training-cutoff year and writes stale ranges like "2024-2025"
# into research prompts; telling it today's date keeps "current" meaning current. # into research prompts; telling it today's date keeps "current" meaning current.
facts["today"] = date.today().isoformat() facts["today"] = date.today().isoformat()
# If the usage text carries real conversation content, distill it to a tight profile FIRST so the
# reveal call reasons over who this person is, not raw logs (and stays in budget). Fail-open: a blank
# profile just leaves the raw text in place, which is today's behavior.
usage = str(facts.get("usage_summary", ""))
if len(usage) > P_PROFILE_DISTILL_THRESHOLD:
profile = await p_distill_profile(settings, usage)
if profile:
facts["usage_summary"] = profile
try: try:
from backend.apps.agents.providers.registry import resolve_aux_model from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.settings.credentials import get_anthropic_client_for_model
+57 -5
View File
@@ -8,6 +8,7 @@ machine. Read-only, capped, and fails open to "" on anything (expired token,
Cloudflare, shape drift) so prep just falls back to the local scan. Cloudflare, shape drift) so prep just falls back to the local scan.
""" """
import asyncio
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import httpx import httpx
@@ -22,6 +23,11 @@ CAP_PAGES = 40
# Prep only reads ~150 titles; this endpoint is ~4s/page, so fetching 1000 cost 38s of onboarding # Prep only reads ~150 titles; this endpoint is ~4s/page, so fetching 1000 cost 38s of onboarding
# runway for nothing. Cap the title pull; the real conversation total comes from the API's own count. # runway for nothing. Cap the title pull; the real conversation total comes from the API's own count.
CAP_TITLES = 200 CAP_TITLES = 200
# Depth: full text of the most recent CONVO_N chats. Per-convo cap stops one marathon dominating; the
# total cap bounds the block (a clustering pass distills it downstream, so it can be generous).
CONVO_N = 10
CONVO_CHARS = 30000
TOTAL_CONVO_CHARS = 130000
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
@@ -37,15 +43,56 @@ def p_codex_creds() -> Optional[Tuple[str, Optional[str]]]:
@typechecked @typechecked
def summarize_chatgpt_usage(total: int, memories: List[str], titles: List[str], capped: bool = False) -> str: def summarize_chatgpt_usage(total: int, memories: List[str], titles: List[str], convos: List[str], capped: bool = False) -> str:
parts: List[str] = [] parts: List[str] = []
if total > 0: if total > 0:
parts.append(f"They have {total}{'+' if capped else ''} past AI conversations.") parts.append(f"They have {total}{'+' if capped else ''} past AI conversations.")
if memories: if memories:
parts.append("Facts their AI remembers about them: " + "; ".join(memories)) parts.append("Facts their AI remembers about them: " + "; ".join(memories))
if titles: if titles:
parts.append("Topics they keep coming back to (recent first): " + "; ".join(titles[:150])) parts.append("Recent conversation titles (breadth): " + "; ".join(titles[:150]))
return "\n".join(parts)[:4000] if convos:
block: List[str] = []
used = 0
for cv in convos:
if used + len(cv) > TOTAL_CONVO_CHARS:
break
block.append(cv)
used += len(cv)
if block:
parts.append("Full text of their most recent conversations (their real asks + the exchange):\n\n" + "\n\n---\n\n".join(block))
return "\n".join(parts)
@typechecked
async def p_fetch_chatgpt_convo(client: httpx.AsyncClient, cid: str) -> str:
"""One conversation's full text, both sides, ordered, capped. "" on any failure (convo skipped)."""
try:
r = await client.get(f"{BASE}/conversation/{cid}")
if r.status_code != 200:
return ""
data = r.json()
mapping = data.get("mapping") if isinstance(data, dict) else None
if not isinstance(mapping, dict):
return ""
rows: List[Tuple[float, str]] = []
for node in mapping.values():
m = node.get("message") if isinstance(node, dict) else None
if not isinstance(m, dict):
continue
role = (m.get("author") or {}).get("role")
if role not in ("user", "assistant"):
continue
content = m.get("content") or {}
if content.get("content_type") != "text":
continue
text = " ".join(str(p) for p in (content.get("parts") or []) if p).strip()
if len(text) > 5:
rows.append((float(m.get("create_time") or 0), ("You: " if role == "user" else "AI: ") + text))
rows.sort(key=lambda x: x[0])
return "\n".join(t for _, t in rows)[:CONVO_CHARS]
except Exception:
return ""
@typechecked @typechecked
@@ -64,10 +111,12 @@ async def harvest_chatgpt_usage() -> str:
if acct: if acct:
headers["chatgpt-account-id"] = acct headers["chatgpt-account-id"] = acct
titles: List[str] = [] titles: List[str] = []
conv_ids: List[str] = []
seen: set = set() seen: set = set()
memories: List[str] = [] memories: List[str] = []
convos: List[str] = []
try: try:
async with httpx.AsyncClient(timeout=15.0, headers=headers) as client: async with httpx.AsyncClient(timeout=20.0, headers=headers) as client:
offset = 0 offset = 0
for _ in range(CAP_PAGES): for _ in range(CAP_PAGES):
if len(titles) >= CAP_TITLES: if len(titles) >= CAP_TITLES:
@@ -83,6 +132,7 @@ async def harvest_chatgpt_usage() -> str:
cid = it.get("id") cid = it.get("id")
if cid and cid not in seen: if cid and cid not in seen:
seen.add(cid) seen.add(cid)
conv_ids.append(str(cid))
title = it.get("title") title = it.get("title")
if title: if title:
titles.append(str(title)) titles.append(str(title))
@@ -96,6 +146,8 @@ async def harvest_chatgpt_usage() -> str:
memories = [str(m.get("content")) for m in (mr.json() or {}).get("memories", []) if m.get("content")][:40] memories = [str(m.get("content")) for m in (mr.json() or {}).get("memories", []) if m.get("content")][:40]
except Exception: except Exception:
pass pass
# Depth pass: full text of the most recent few, fetched in parallel.
convos = [c for c in await asyncio.gather(*(p_fetch_chatgpt_convo(client, cid) for cid in conv_ids[:CONVO_N])) if c]
except Exception: except Exception:
return "" return ""
return summarize_chatgpt_usage(len(seen), memories, titles, capped=len(titles) >= CAP_TITLES) return summarize_chatgpt_usage(len(seen), memories, titles, convos, capped=len(titles) >= CAP_TITLES)
+65 -10
View File
@@ -1,10 +1,12 @@
"""Read the user's real Claude conversation topics from claude.ai using their own """Read the user's real Claude history from claude.ai using their own logged-in browser
logged-in browser cookies (see browser_cookies), no in-app login. Claude's website cookies (see browser_cookies), no in-app login. Claude's website session is the only way
session is the only way in (its API token is a different realm), and a plain request in (its API token is a different realm), and a plain request carries it fine (unlike
carries it fine (unlike ChatGPT, claude.ai does not fingerprint-block). Capped, ChatGPT, claude.ai does not fingerprint-block). We pull the recent conversation titles for
read-only, fails open to "" on anything so prep falls back to the local scan. breadth AND the FULL text of the most recent few for depth: their actual asks + the exchange
are far stronger signal than a vague title. Capped, read-only, fails open to "" on anything.
""" """
import asyncio
from typing import List from typing import List
import httpx import httpx
@@ -17,16 +19,64 @@ UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML,
PAGE = 100 PAGE = 100
CAP_PAGES = 40 CAP_PAGES = 40
CAP_TITLES = 1000 CAP_TITLES = 1000
# Depth: full text of the most recent CONVO_N chats. A per-convo cap keeps one marathon from
# dominating; the total cap bounds the whole block (a clustering pass distills it downstream, so
# this can be generous). ~130K chars is ~32K tokens, a couple cents for the cheap aux model.
CONVO_N = 10
CONVO_CHARS = 30000
TOTAL_CONVO_CHARS = 130000
@typechecked @typechecked
def summarize_claude_usage(total: int, titles: List[str]) -> str: def summarize_claude_usage(total: int, titles: List[str], convos: List[str]) -> str:
parts: List[str] = [] parts: List[str] = []
if total > 0: if total > 0:
parts.append(f"They have {total} past Claude conversations.") parts.append(f"They have {total} past Claude conversations.")
if titles: if titles:
parts.append("Topics they keep coming back to (recent first): " + "; ".join(titles[:150])) parts.append("Recent conversation titles (breadth): " + "; ".join(titles[:150]))
return "\n".join(parts)[:4000] if convos:
block: List[str] = []
used = 0
for cv in convos:
if used + len(cv) > TOTAL_CONVO_CHARS:
break
block.append(cv)
used += len(cv)
if block:
parts.append("Full text of their most recent conversations (their real asks + the exchange):\n\n" + "\n\n---\n\n".join(block))
return "\n".join(parts)
@typechecked
async def p_fetch_claude_convo(client: httpx.AsyncClient, org: str, cid: str) -> str:
"""One conversation's full text, both sides, capped. "" on any failure so a bad convo is skipped."""
try:
r = await client.get(
f"{BASE}/api/organizations/{org}/chat_conversations/{cid}",
params={"tree": "True", "rendering_mode": "raw"},
)
if r.status_code != 200:
return ""
data = r.json()
msgs = data.get("chat_messages") if isinstance(data, dict) else None
if not isinstance(msgs, list):
return ""
lines: List[str] = []
for m in msgs:
if not isinstance(m, dict):
continue
sender = m.get("sender")
if sender not in ("human", "assistant"):
continue
text = m.get("text") or ""
if not text and isinstance(m.get("content"), list):
text = " ".join(str(x.get("text", "")) for x in m["content"] if isinstance(x, dict) and x.get("text"))
text = str(text).strip()
if len(text) > 5:
lines.append(("You: " if sender == "human" else "AI: ") + text)
return "\n".join(lines)[:CONVO_CHARS]
except Exception:
return ""
@typechecked @typechecked
@@ -36,9 +86,10 @@ async def harvest_claude_usage() -> str:
return "" return ""
headers = {"Cookie": cookie_header(jar), "User-Agent": UA, "Accept": "application/json"} headers = {"Cookie": cookie_header(jar), "User-Agent": UA, "Accept": "application/json"}
titles: List[str] = [] titles: List[str] = []
conv_ids: List[str] = []
seen: set = set() seen: set = set()
try: try:
async with httpx.AsyncClient(timeout=15.0, headers=headers) as client: async with httpx.AsyncClient(timeout=20.0, headers=headers) as client:
org_res = await client.get(f"{BASE}/api/organizations") org_res = await client.get(f"{BASE}/api/organizations")
if org_res.status_code != 200: if org_res.status_code != 200:
return "" return ""
@@ -64,6 +115,7 @@ async def harvest_claude_usage() -> str:
cid = it.get("uuid") cid = it.get("uuid")
if cid and cid not in seen: if cid and cid not in seen:
seen.add(cid) seen.add(cid)
conv_ids.append(str(cid))
name = it.get("name") name = it.get("name")
if name: if name:
titles.append(str(name)) titles.append(str(name))
@@ -71,6 +123,9 @@ async def harvest_claude_usage() -> str:
if fresh == 0 or len(items) < PAGE: if fresh == 0 or len(items) < PAGE:
break break
offset += PAGE offset += PAGE
# Depth pass: full text of the most recent few, fetched in parallel.
top = conv_ids[:CONVO_N]
convos = [c for c in await asyncio.gather(*(p_fetch_claude_convo(client, org, cid) for cid in top)) if c]
except Exception: except Exception:
return "" return ""
return summarize_claude_usage(len(seen), titles) return summarize_claude_usage(len(seen), titles, convos)
+2
View File
@@ -68,6 +68,8 @@ class AppSettings(BaseModel):
# Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas. # Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas.
accent_gradient: Optional[list[str]] = None accent_gradient: Optional[list[str]] = None
personalized_greeting: Optional[str] = None personalized_greeting: Optional[str] = None
# Short one-glance identity hook for the reveal's focal beat (greeting is the longer warm read).
personalized_headline: Optional[str] = None
personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list) personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list)
personalized_automations: list["PersonalizedAutomation"] = Field(default_factory=list) personalized_automations: list["PersonalizedAutomation"] = Field(default_factory=list)
# Distilled from the user's provider chat history the first time they open ChatGPT/Claude in-app; re-feeds prep to sharpen suggestions. # Distilled from the user's provider chat history the first time they open ChatGPT/Claude in-app; re-feeds prep to sharpen suggestions.
+46 -17
View File
@@ -30,15 +30,16 @@ function configure(opts) {
} }
// Runs in the page context. Sweeps recent conversation titles (paginated + deduped) // Runs in the page context. Sweeps recent conversation titles (paginated + deduped)
// plus ChatGPT Memory. Bounded on EVERY dimension so no provider's endpoint speed can // plus ChatGPT Memory, THEN pulls the FULL text of the CONVO_N most recent conversations
// wedge the read: a wall-clock BUDGET_MS (ChatGPT's /conversations is ~4s/page, so an // (the user's real asks + the exchange, far higher signal than a vague title). Bounded on
// unbounded loop over a many-chat account would run minutes and outlive the offscreen // EVERY dimension so no provider's endpoint speed can wedge the read: a wall-clock BUDGET_MS,
// window, losing everything), a per-fetch abort, and hard page/title caps. The most // a per-fetch abort, hard page/title caps, and a per-conversation char cap so one marathon
// recent titles are the strongest personalization signal, so a partial is a good result. // chat can't dominate. A partial read is a good result; the recent stuff is the strongest signal.
const PREAMBLE = ` const PREAMBLE = `
// Prep reads only ~150 titles, so pulling 1000 just burned onboarding runway (up to the 14s budget) // Prep reads only ~150 titles, so pulling 1000 just burned onboarding runway for signal we throw
// for signal we throw away. Cap the pull; the count becomes an honest "N+" floor when we stop early. // away. Cap the pull; the count becomes an honest "N+" floor when we stop early. CONVO_N full convos
const BUDGET_MS=14000, PAGE=100, CAP_PAGES=60, CAP_TITLES=200, GAP_MS=120, FETCH_MS=6000; // (capped per convo) are the real payload; the budget is raised to fit their detail fetches.
const BUDGET_MS=20000, PAGE=100, CAP_PAGES=60, CAP_TITLES=200, GAP_MS=120, FETCH_MS=6000, CONVO_N=10, CONVO_CHARS=8000;
const startedAt = Date.now(); const startedAt = Date.now();
const haveTime = () => Date.now() - startedAt < BUDGET_MS; const haveTime = () => Date.now() - startedAt < BUDGET_MS;
const jget = async (url, extra) => { const jget = async (url, extra) => {
@@ -50,49 +51,77 @@ const SCRIPT = {
codex: `(async () => {${PREAMBLE} codex: `(async () => {${PREAMBLE}
try { try {
const sess = await jget('/api/auth/session'); const sess = await jget('/api/auth/session');
if (!sess || !sess.accessToken) return {ok:false, total:0, titles:[], memories:[]}; if (!sess || !sess.accessToken) return {ok:false, total:0, titles:[], memories:[], convos:[]};
const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}}; const H = {headers:{Authorization:'Bearer '+sess.accessToken, accept:'application/json'}};
const seen = new Set(); const titles = []; const seen = new Set(); const titles = []; const convList = [];
let offset = 0, page = 0; let offset = 0, page = 0;
while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) { while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) {
const j = await jget('/backend-api/conversations?offset='+offset+'&limit='+PAGE+'&order=updated', H); const j = await jget('/backend-api/conversations?offset='+offset+'&limit='+PAGE+'&order=updated', H);
const items = (j && j.items) || []; const items = (j && j.items) || [];
if (!items.length) break; if (!items.length) break;
let fresh = 0; let fresh = 0;
for (const c of items) { if (c && c.id && !seen.has(c.id)) { seen.add(c.id); if (c.title) titles.push(c.title); fresh++; } } for (const c of items) { if (c && c.id && !seen.has(c.id)) { seen.add(c.id); if (c.title) titles.push(c.title); convList.push({id:c.id, title:c.title||''}); fresh++; } }
if (fresh === 0 || items.length < PAGE) break; if (fresh === 0 || items.length < PAGE) break;
offset += PAGE; page++; offset += PAGE; page++;
await new Promise(r=>setTimeout(r, GAP_MS)); await new Promise(r=>setTimeout(r, GAP_MS));
} }
const mem = await jget('/backend-api/memories?include_memory_entries=true', H); const mem = await jget('/backend-api/memories?include_memory_entries=true', H);
// The payload: FULL text of the most recent convos, fetched in parallel (bounded by the budget).
const top = convList.slice(0, CONVO_N);
const details = await Promise.all(top.map(cv => haveTime() ? jget('/backend-api/conversation/'+cv.id, H) : Promise.resolve(null)));
const convos = [];
for (let i=0;i<top.length;i++) {
const d = details[i]; if (!d || !d.mapping) continue;
const msgs = Object.keys(d.mapping).map(k=>d.mapping[k] && d.mapping[k].message)
.filter(m=>m && m.author && (m.author.role==='user'||m.author.role==='assistant') && m.content && m.content.content_type==='text' && m.content.parts && m.content.parts.length)
.sort((a,b)=>(a.create_time||0)-(b.create_time||0))
.map(m=>(m.author.role==='user'?'You: ':'AI: ')+String(m.content.parts.join(' ')).trim())
.filter(s=>s.length>5);
let text = msgs.join('\\n'); if (text.length > CONVO_CHARS) text = text.slice(0, CONVO_CHARS)+' …';
if (text) convos.push({title: top[i].title, text});
}
return { return {
ok: true, ok: true,
total: seen.size, total: seen.size,
capped: seen.size >= CAP_TITLES, capped: seen.size >= CAP_TITLES,
titles: titles.slice(0, CAP_TITLES), titles: titles.slice(0, CAP_TITLES),
memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0, 40), memories: ((mem && mem.memories) || []).map(m=>m.content).filter(Boolean).slice(0, 40),
convos,
}; };
} catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } } catch (e) { return {ok:false, total:0, titles:[], memories:[], convos:[]}; }
})()`, })()`,
claude: `(async () => {${PREAMBLE} claude: `(async () => {${PREAMBLE}
try { try {
const orgs = await jget('/api/organizations', {headers:{accept:'application/json'}}); const orgs = await jget('/api/organizations', {headers:{accept:'application/json'}});
if (!Array.isArray(orgs) || !orgs.length) return {ok:false, total:0, titles:[], memories:[]}; if (!Array.isArray(orgs) || !orgs.length) return {ok:false, total:0, titles:[], memories:[], convos:[]};
const org = orgs[0].uuid; const org = orgs[0].uuid;
const seen = new Set(); const titles = []; const seen = new Set(); const titles = []; const convList = [];
let offset = 0, page = 0; let offset = 0, page = 0;
while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) { while (page < CAP_PAGES && titles.length < CAP_TITLES && haveTime()) {
const convs = await jget('/api/organizations/'+org+'/chat_conversations?limit='+PAGE+'&offset='+offset, {headers:{accept:'application/json'}}); const convs = await jget('/api/organizations/'+org+'/chat_conversations?limit='+PAGE+'&offset='+offset, {headers:{accept:'application/json'}});
const items = Array.isArray(convs) ? convs : []; const items = Array.isArray(convs) ? convs : [];
if (!items.length) break; if (!items.length) break;
let fresh = 0; let fresh = 0;
for (const c of items) { const id = c && c.uuid; if (id && !seen.has(id)) { seen.add(id); if (c.name) titles.push(c.name); fresh++; } } for (const c of items) { const id = c && c.uuid; if (id && !seen.has(id)) { seen.add(id); if (c.name) titles.push(c.name); convList.push({id:id, title:c.name||''}); fresh++; } }
if (fresh === 0 || items.length < PAGE) break; if (fresh === 0 || items.length < PAGE) break;
offset += PAGE; page++; offset += PAGE; page++;
await new Promise(r=>setTimeout(r, GAP_MS)); await new Promise(r=>setTimeout(r, GAP_MS));
} }
return {ok:true, total:seen.size, capped:seen.size >= CAP_TITLES, titles:titles.slice(0, CAP_TITLES), memories:[]}; // The payload: FULL text of the most recent convos (both sides), fetched in parallel + capped.
} catch (e) { return {ok:false, total:0, titles:[], memories:[]}; } const top = convList.slice(0, CONVO_N);
const details = await Promise.all(top.map(cv => haveTime() ? jget('/api/organizations/'+org+'/chat_conversations/'+cv.id+'?tree=True&rendering_mode=raw', {headers:{accept:'application/json'}}) : Promise.resolve(null)));
const convos = [];
for (let i=0;i<top.length;i++) {
const d = details[i]; if (!d) continue;
const cms = (d && d.chat_messages) || (Array.isArray(d) ? d : []);
const msgs = cms.filter(m=>m && (m.sender==='human'||m.sender==='assistant'))
.map(m=>{ let t = m.text || ''; if(!t && Array.isArray(m.content)) t = m.content.map(x=>x&&x.text).filter(Boolean).join(' '); return (m.sender==='human'?'You: ':'AI: ')+String(t).trim(); })
.filter(s=>s.length>5);
let text = msgs.join('\\n'); if (text.length > CONVO_CHARS) text = text.slice(0, CONVO_CHARS)+' …';
if (text) convos.push({title: top[i].title, text});
}
return {ok:true, total:seen.size, capped:seen.size >= CAP_TITLES, titles:titles.slice(0, CAP_TITLES), memories:[], convos};
} catch (e) { return {ok:false, total:0, titles:[], memories:[], convos:[]}; }
})()`, })()`,
// Gemini has no clean history JSON (it's the obfuscated batchexecute RPC), so we scrape the // Gemini has no clean history JSON (it's the obfuscated batchexecute RPC), so we scrape the
// rendered rail. Robust by design, one bounded loop that: gates on real TEXT (empty conversation // rendered rail. Robust by design, one bounded loop that: gates on real TEXT (empty conversation
@@ -445,6 +445,16 @@ const AppShell: React.FC = () => {
const sidebarAway = (sidebarCollapsed || fsActive) && isDashboardViewActive; const sidebarAway = (sidebarCollapsed || fsActive) && isDashboardViewActive;
const [sidePeek, setSidePeek] = useState(false); const [sidePeek, setSidePeek] = useState(false);
useEffect(() => { if (!sidebarAway) setSidePeek(false); }, [sidebarAway]); useEffect(() => { if (!sidebarAway) setSidePeek(false); }, [sidebarAway]);
// When the sidebar docks away, the canvas runs flush to the window's left edge, so the floating
// dashboard header would sit right under the macOS traffic lights. Publish an inset the header reads
// (only on macOS, where the lights exist) so it clears them; the sidebar carries its own clearance.
useEffect(() => {
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
const root = document.documentElement;
if (sidebarAway && isMac) root.style.setProperty('--osw-header-inset', '80px');
else root.style.removeProperty('--osw-header-inset');
return () => { root.style.removeProperty('--osw-header-inset'); };
}, [sidebarAway]);
// Close-on-leave with a grace delay (cancelled on re-enter): a bare mouseLeave closed the peek the // Close-on-leave with a grace delay (cancelled on re-enter): a bare mouseLeave closed the peek the
// instant the cursor dipped past the panel edge while reaching for an item, so clicks never landed. // instant the cursor dipped past the panel edge while reaching for an item, so clicks never landed.
const peekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const peekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -24,6 +24,7 @@ export interface ScanResult {
} }
export interface PrepResponse { export interface PrepResponse {
headline: string;
greeting: string; greeting: string;
starters: PersonalizedStarter[]; starters: PersonalizedStarter[];
app_title: string; app_title: string;
@@ -32,6 +33,9 @@ export interface PrepResponse {
research_title: string; research_title: string;
research_prompt: string; research_prompt: string;
research_reason: string; research_reason: string;
browser_title: string;
browser_prompt: string;
browser_reason: string;
automations: PersonalizedAutomation[]; automations: PersonalizedAutomation[];
} }
@@ -92,15 +92,16 @@ export function useOnboardingV3Pipeline() {
}, []); }, []);
// Fire one real background agent; the session exists in redux without a card until the reveal composes the canvas. // Fire one real background agent; the session exists in redux without a card until the reveal composes the canvas.
const launchJob = useCallback((title: string, prompt: string, kind: 'audit' | 'app' | 'research', reason: string) => { const launchJob = useCallback((title: string, prompt: string, kind: 'app' | 'research' | 'browser', reason: string) => {
const { model: liveModel } = launchCtxRef.current; const { model: liveModel } = launchCtxRef.current;
const dashboardId = getLastDashboardId() ?? undefined; const dashboardId = getLastDashboardId() ?? undefined;
// The audit reads the user's REAL files unattended, so it runs read-only (Edit/Bash blocked); the // All three run with full tools: the app build writes its own workspace, research + the browser task
// app build writes into its own fresh workspace, so it keeps full tools. // save their findings. The browser task is kept safe by its PROMPT (public pages, never log in/buy).
const config: AgentConfig = { name: title, model: liveModel, mode: 'agent', dashboard_id: dashboardId, read_only: kind === 'audit' }; const config: AgentConfig = { name: title, model: liveModel, mode: 'agent', dashboard_id: dashboardId };
const draftId = dispatch(createDraftSession({ mode: 'agent', model: liveModel, dashboardId: dashboardId ?? '', setActive: false })).payload.draftId; const draftId = dispatch(createDraftSession({ mode: 'agent', model: liveModel, dashboardId: dashboardId ?? '', setActive: false })).payload.draftId;
// expand: false keeps the reveal's left stack as compact cards instead of overlapping transcripts. // Reveal cards open ENLARGED so the user sees the real work (not tiny collapsed stubs), and the
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt, mode: 'agent', model: liveModel, expand: false })) // yellow minimize button then has something to collapse. The seeder stacks them at expanded height.
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt, mode: 'agent', model: liveModel, expand: true }))
.then((action) => { .then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) { if (launchAndSendFirstMessage.fulfilled.match(action)) {
dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind, reason })); dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind, reason }));
@@ -141,11 +142,12 @@ export function useOnboardingV3Pipeline() {
prepReadyRef.current = prep; prepReadyRef.current = prep;
if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return; if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return;
launchedRef.current = true; launchedRef.current = true;
if (prep.starters.length > 0) launchJob(prep.starters[0].title, prep.starters[0].prompt, 'audit', prep.starters[0].reason ?? ''); // The four auto-run showcase jobs, one per capability: build an app, dig the web, drive a real
// browser, and set up a scheduled task. The lame file-audit is gone; the browser task shows the
// agent controlling a real site live.
if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app', prep.app_reason ?? ''); if (prep.app_title && prep.app_prompt) launchJob(prep.app_title, prep.app_prompt, 'app', prep.app_reason ?? '');
// The "looked into this for you" card: a real live web-research task on the one thing this user
// keeps asking about, so the reveal shows OpenSwarm going and finding it, not just planning.
if (prep.research_title && prep.research_prompt) launchJob(prep.research_title, prep.research_prompt, 'research', prep.research_reason ?? ''); if (prep.research_title && prep.research_prompt) launchJob(prep.research_title, prep.research_prompt, 'research', prep.research_reason ?? '');
if (prep.browser_title && prep.browser_prompt) launchJob(prep.browser_title, prep.browser_prompt, 'browser', prep.browser_reason ?? '');
// The scheduled task is a first-class part of the reveal (the "it automates for me" capability), so // The scheduled task is a first-class part of the reveal (the "it automates for me" capability), so
// guarantee one: use the model's automation when it emitted one (it sometimes drops the last JSON // guarantee one: use the model's automation when it emitted one (it sometimes drops the last JSON
// field), else fall back to a safe, universally-useful weekly Downloads sweep. // field), else fall back to a safe, universally-useful weekly Downloads sweep.
@@ -178,6 +180,7 @@ export function useOnboardingV3Pipeline() {
if (!prep) return; if (!prep) return;
dispatch(updateSettingsPatch({ dispatch(updateSettingsPatch({
personalized_greeting: prep.greeting?.trim() || null, personalized_greeting: prep.greeting?.trim() || null,
personalized_headline: prep.headline?.trim() || null,
personalized_starters: prep.starters ?? [], personalized_starters: prep.starters ?? [],
personalized_automations: prep.automations ?? [], personalized_automations: prep.automations ?? [],
})); }));
@@ -5,7 +5,7 @@ import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { deleteSession } from '@/shared/state/agentsSlice'; import { deleteSession } from '@/shared/state/agentsSlice';
import { removeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; import { removeCard, closeWorkflowMonitor } from '@/shared/state/dashboardLayoutSlice';
import { deleteWorkflow } from '@/shared/state/workflowsSlice'; import { deleteWorkflow } from '@/shared/state/workflowsSlice';
import { clearPrepped } from '@/shared/state/onboardingV3Slice'; import { clearPrepped } from '@/shared/state/onboardingV3Slice';
@@ -33,7 +33,7 @@ const PrepKeepToast: React.FC = () => {
const discard = useCallback(() => { const discard = useCallback(() => {
for (const job of prepped) { for (const job of prepped) {
if (job.kind === 'schedule' && job.workflowId) { if (job.kind === 'schedule' && job.workflowId) {
dispatch(removeWorkflowCard(job.workflowId)); dispatch(closeWorkflowMonitor());
void dispatch(deleteWorkflow(job.workflowId)); void dispatch(deleteWorkflow(job.workflowId));
} else { } else {
dispatch(removeCard(job.sessionId)); dispatch(removeCard(job.sessionId));
@@ -192,8 +192,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
zIndex: 10, zIndex: 10,
pointerEvents: 'none', pointerEvents: 'none',
// p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel. // p: 3 (24px) was leaving a chunky air gap between the sidebar edge and the dashboard header that read as "two disconnected panels" rather than one continuous surface. 0.75 (6px) tightens the inset so the header floats just inside the content area without losing its breathing room from the top-most pixel.
p: 0.75, pt: 0.75,
pr: 0.75,
pb: 0, pb: 0,
// Clears the macOS traffic lights when the sidebar is docked away (AppShell sets the var); 6px otherwise.
pl: 'var(--osw-header-inset, 6px)',
// No scrim: the header carries its own translucent pill (DashboardHeader), so a full-width // No scrim: the header carries its own translucent pill (DashboardHeader), so a full-width
// page->transparent fade here just read as a light-leak band over the themed canvas. // page->transparent fade here just read as a light-leak band over the themed canvas.
}} }}
@@ -9,7 +9,6 @@ import MissedRunsToast from '@/app/pages/Workflows/MissedRunsToast';
import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast'; import ProviderHealthToast from '@/app/components/overlays/ProviderHealthToast';
import ScheduleOfferToast from '@/app/components/nudges/ScheduleOfferToast'; import ScheduleOfferToast from '@/app/components/nudges/ScheduleOfferToast';
import PrepKeepToast from '@/app/components/nudges/PrepKeepToast'; import PrepKeepToast from '@/app/components/nudges/PrepKeepToast';
import RevealHero from '@/app/components/nudges/RevealHero';
import type { AgentSession } from '@/shared/state/agentsSlice'; import type { AgentSession } from '@/shared/state/agentsSlice';
import type { import type {
CardPosition, CardPosition,
@@ -163,8 +162,8 @@ const DashboardOverlays: React.FC<DashboardOverlaysProps> = ({
{/* One-shot dependency beat: first completed personalized starter offers to become a weekly job */} {/* One-shot dependency beat: first completed personalized starter offers to become a weekly job */}
<ScheduleOfferToast dashboardId={dashboardId} /> <ScheduleOfferToast dashboardId={dashboardId} />
{/* Unmissable top-center legend of the work the reveal did, with live working/done status */} {/* The reveal's payoff is the hold-to-enter gradient flood (BeatEnter) landing you on the live work,
<RevealHero /> not a summary card, so no modal here. */}
{/* Accept-or-deny for the audit + app the flow started on the user's behalf */} {/* Accept-or-deny for the audit + app the flow started on the user's behalf */}
<PrepKeepToast /> <PrepKeepToast />
@@ -594,12 +594,15 @@ const AgentCard: React.FC<Props> = ({
if (dir.includes('s')) newH = origH + dy; if (dir.includes('s')) newH = origH + dy;
if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; }
// An enlarged card can't be shrunk below its content-showing height, else the user resizes the
// chat down until the transcript vanishes (which felt broken). Collapsed cards keep the tiny floor.
const minH = expanded ? EXPANDED_OVERLAY_H : MIN_H;
if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; }
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } if (newH < minH) { if (dir.includes('n')) newY = origY + origH - minH; newH = minH; }
return { x: newX, y: newY, w: newW, h: newH }; return { x: newX, y: newY, w: newW, h: newH };
}, },
[getCanvasState], [getCanvasState, expanded],
); );
const handleResizeMove = useCallback( const handleResizeMove = useCallback(
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { store } from '@/shared/state/store'; import { store } from '@/shared/state/store';
import { import {
placeCard, addWorkflowCard, setWorkflowCardPosition, setViewCardPosition, placeCard, openWorkflowMonitor, setWorkflowsMonitorPosition, setViewCardPosition,
DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H,
} from '@/shared/state/dashboardLayoutSlice'; } from '@/shared/state/dashboardLayoutSlice';
import { clearReveal, setRevealAnchor } from '@/shared/state/onboardingV3Slice'; import { clearReveal, setRevealAnchor } from '@/shared/state/onboardingV3Slice';
@@ -18,6 +18,9 @@ interface Args {
} }
const GAP = 48; const GAP = 48;
// The scheduled task renders as a RunMonitor card, which is taller than a job card; reserve its slot so
// the camera frames it. Mirrors the monitor default height in dashboardLayoutSlice.openWorkflowMonitor.
const WORKFLOW_MONITOR_H = 560;
/** Where the reveal's app view card is born: right of the welcome chat, top-aligned. The "here's what I did" legend is the fixed RevealHero panel, not a canvas note, so the app sits right next to the chat. */ /** Where the reveal's app view card is born: right of the welcome chat, top-aligned. The "here's what I did" legend is the fixed RevealHero panel, not a canvas note, so the app sits right next to the chat. */
export function revealAppSpot(anchor: { cx: number; cy: number }): { x: number; y: number } { export function revealAppSpot(anchor: { cx: number; cy: number }): { x: number; y: number } {
@@ -40,18 +43,24 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
const a = anchorRef.current; const a = anchorRef.current;
if (!a) return; if (!a) return;
prepped.forEach((job) => { prepped.forEach((job) => {
// Every job shows its AGENT card (its live transcript), including the app builder, so the reveal
// makes it obvious the agent is BUILDING the app, not just a "building..." box. Its finished app
// view card appears beside it when it renders (birth-position path in useDashboardLifecycle).
const key = job.workflowId || job.sessionId; const key = job.workflowId || job.sessionId;
if (placedRef.current.has(key)) return; if (placedRef.current.has(key)) return;
const i = placedRef.current.size; const i = placedRef.current.size;
// Left column, top-aligned with the expanded welcome chat, tight vertical rhythm. // Left column, top-aligned with the expanded welcome chat. Cards open ENLARGED, so the vertical
// rhythm is the expanded height, not the compact one, or enlarged cards would overlap.
const x = a.cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W; const x = a.cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W;
const y = a.cy - EXPANDED_CARD_MIN_H / 2 + i * (DEFAULT_CARD_H + 24); const y = a.cy - EXPANDED_CARD_MIN_H / 2 + i * (EXPANDED_CARD_MIN_H + 24);
if (job.kind === 'schedule' && job.workflowId) { if (job.kind === 'schedule' && job.workflowId) {
// The scheduled task is a workflow, not an agent session: place its workflow card in the same stack. // The scheduled task is a workflow. The canvas only draws ONE workflow card (the "monitor"), so
dispatch(addWorkflowCard({ workflowId: job.workflowId, expandedSessionIds })); // open the scheduled workflow AS that monitor card, else it never renders (the workflowCards map
dispatch(setWorkflowCardPosition({ workflowId: job.workflowId, x, y })); // it used to go into is not drawn). Its taller monitor card gets its own slot below the jobs.
dispatch(openWorkflowMonitor({ workflowId: job.workflowId }));
dispatch(setWorkflowsMonitorPosition({ x, y }));
} else { } else {
dispatch(placeCard({ sessionId: job.sessionId, x, y, width: DEFAULT_CARD_W, height: DEFAULT_CARD_H, expandedSessionIds, exact: true })); dispatch(placeCard({ sessionId: job.sessionId, x, y, width: DEFAULT_CARD_W, height: EXPANDED_CARD_MIN_H, expandedSessionIds, exact: true }));
} }
placedRef.current.add(key); placedRef.current.add(key);
}); });
@@ -87,11 +96,21 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
} }
} }
createWelcomeDraft(); createWelcomeDraft();
// Frame the whole cluster (jobs column + chat + note) so the reveal is readable, not scattered. // Frame the whole cluster (left job column + chat + app view card on the right) so the reveal is
// readable, not scattered. The pipeline ALWAYS sets up one scheduled task, whose RunMonitor card is
// tall and lands late; reserve its slot now (2 short job cards + the monitor) so the camera never
// cuts it off when it arrives. Width runs from the left column to the far edge of the app view card.
const left = cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W; const left = cx - DEFAULT_CARD_W / 2 - GAP - DEFAULT_CARD_W;
const top = cy - EXPANDED_CARD_MIN_H / 2; const top = cy - EXPANDED_CARD_MIN_H / 2;
const right = cx + DEFAULT_CARD_W / 2 + GAP + DEFAULT_CARD_W;
// The left column is now enlarged agent cards (audit + app-builder + research) + the tall
// scheduled-task monitor below them, so reserve for three cards plus the monitor.
const stackH = 3 * (EXPANDED_CARD_MIN_H + 24) + WORKFLOW_MONITOR_H;
// Reserve headroom at the top so the topmost cards clear the macOS traffic lights + the floating
// dashboard title pill + the run-status pill, instead of the chat header landing under them.
const TOP_CHROME_PAD = 130;
fitToCards( fitToCards(
[{ x: left, y: top, width: (DEFAULT_CARD_W * 2) + (GAP * 2), height: Math.max(EXPANDED_CARD_MIN_H, DEFAULT_CARD_H * 3 + 48) }], [{ x: left, y: top - TOP_CHROME_PAD, width: right - left, height: Math.max(EXPANDED_CARD_MIN_H, stackH) + TOP_CHROME_PAD }],
0.9, 0.9,
true, true,
); );
@@ -5,7 +5,7 @@ import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import AgentChat from '@/app/pages/AgentChat/AgentChat'; import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { fetchRuns, controlWorkflowRun } from '@/shared/state/workflowsSlice'; import { fetchRuns, controlWorkflowRun } from '@/shared/state/workflowsSlice';
import type { Workflow, WorkflowRun } from '@/shared/state/workflowsSlice'; import type { Workflow, WorkflowRun, ScheduleConfig } from '@/shared/state/workflowsSlice';
import { import {
bringToFront, closeWorkflowMonitor, setWorkflowsMonitorPosition, bringToFront, closeWorkflowMonitor, setWorkflowsMonitorPosition,
} from '@/shared/state/dashboardLayoutSlice'; } from '@/shared/state/dashboardLayoutSlice';
@@ -27,6 +27,27 @@ function kindLabel(run: WorkflowRun | null): string {
return 'SCHEDULED RUN'; return 'SCHEDULED RUN';
} }
const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function fmtTime(hour: number, minute: number): string {
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:${String(minute).padStart(2, '0')} ${hour < 12 ? 'AM' : 'PM'}`;
}
// Plain-English cadence for a scheduled task that hasn't run yet, so the card says WHEN, not "0%".
function formatSchedule(s: ScheduleConfig): string {
const time = fmtTime(s.hour ?? 9, s.minute ?? 0);
if (s.repeat_unit === 'week' && s.on_days?.length) {
const days = s.on_days.slice().sort((a, b) => a - b);
if (days.length === 5 && days.every((d, i) => d === i + 1)) return `Every weekday at ${time}`;
return `Every ${days.map((d) => WEEKDAY_NAMES[d] ?? '').filter(Boolean).join(', ')} at ${time}`;
}
if (s.repeat_unit === 'day') return `Every day at ${time}`;
if (s.repeat_unit === 'hour') return 'Every hour';
if (s.repeat_unit === 'month') return `Monthly at ${time}`;
return `Scheduled for ${time}`;
}
interface Props { interface Props {
workflow: Workflow; workflow: Workflow;
cardX: number; cardX: number;
@@ -204,10 +225,34 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<AgentChat sessionId={sessionId} embedded readOnly /> <AgentChat sessionId={sessionId} embedded readOnly />
</div> </div>
) : ( ) : run ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: c.text.tertiary }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: c.text.tertiary }}>
{isRunning ? 'Waiting for the run to start…' : failed ? 'This run failed before any agent ran.' : 'No agent chat for this run.'} {isRunning ? 'Waiting for the run to start…' : failed ? 'This run failed before any agent ran.' : 'No agent chat for this run.'}
</div> </div>
) : (
/* Never-run scheduled task: show WHEN it runs and WHAT it does, not an empty 0% run pane. */
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '16px 18px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.07em', textTransform: 'uppercase', color: c.text.tertiary, marginBottom: 6 }}>Schedule</div>
<div style={{ fontSize: 14, fontWeight: 600, color: c.text.primary }}>{formatSchedule(workflow.schedule)}</div>
{workflow.next_run_at && (
<div style={{ fontSize: 12, color: c.text.tertiary, marginTop: 3 }}>
Next run {new Date(workflow.next_run_at).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</div>
)}
</div>
<div>
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.07em', textTransform: 'uppercase', color: c.text.tertiary, marginBottom: 8 }}>What it does</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{steps.map((st, idx) => (
<div key={st.id || idx} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{ width: 20, height: 20, borderRadius: 6, flex: 'none', background: c.bg.secondary, color: c.text.tertiary, fontSize: 11, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{idx + 1}</div>
<div style={{ fontSize: 13, color: c.text.secondary, lineHeight: 1.45 }}>{st.label || st.text.trim()}</div>
</div>
))}
</div>
</div>
</div>
)} )}
{/* footer only while live: Stop fully fails the in-flight run. Once it's {/* footer only while live: Stop fully fails the in-flight run. Once it's
+22 -3
View File
@@ -9,14 +9,33 @@ export interface ProviderUsage {
capped?: boolean; capped?: boolean;
titles: string[]; titles: string[];
memories: string[]; memories: string[];
// FULL text of the most recent few conversations (their real asks + the exchange). The payload:
// titles are the vague label, this is the substance a clustering pass turns into a real profile.
convos?: { title: string; text: string }[];
} }
// Turn the raw read into a compact profile block for the prep prompt: the memory facts (strongest), the scale, and the most-recent topics. Capped hard so we never ship a wall of PII even for a heavy user; the aux model turns this into the profile. // ~130K chars is ~32K tokens: a generous budget for the full-convo block, since a downstream clustering
// pass distills it before the reveal (so a couple cents, not a bloated final prompt).
const TOTAL_CONVO_CHARS = 130000;
// Turn the raw read into the profile block for prep: memory facts, the scale, recent titles for breadth,
// then the FULL recent conversations (the payload). Bounded so even a heavy user can't ship a wall of PII;
// the backend then distills it to a tight "who is this person" profile.
export function summarizeUsage(u: ProviderUsage | null): string { export function summarizeUsage(u: ProviderUsage | null): string {
if (!u || !u.ok) return ''; if (!u || !u.ok) return '';
const parts: string[] = []; const parts: string[] = [];
if (u.total > 0) parts.push(`They have ${u.total}${u.capped ? '+' : ''} past AI conversations.`); if (u.total > 0) parts.push(`They have ${u.total}${u.capped ? '+' : ''} past AI conversations.`);
if (u.memories.length > 0) parts.push('Facts their AI remembers about them: ' + u.memories.join('; ')); if (u.memories.length > 0) parts.push('Facts their AI remembers about them: ' + u.memories.join('; '));
if (u.titles.length > 0) parts.push('Topics they keep coming back to (recent first): ' + u.titles.slice(0, 150).join('; ')); if (u.titles.length > 0) parts.push('Recent conversation titles (breadth): ' + u.titles.slice(0, 150).join('; '));
return parts.join('\n').slice(0, 4000); if (u.convos && u.convos.length > 0) {
const block: string[] = [];
let used = 0;
for (const cv of u.convos) {
if (used + cv.text.length > TOTAL_CONVO_CHARS) break;
block.push(cv.text);
used += cv.text.length;
}
if (block.length) parts.push('Full text of their most recent conversations (their real asks + the exchange):\n\n' + block.join('\n\n---\n\n'));
}
return parts.join('\n');
} }
@@ -7,7 +7,7 @@ export interface PreppedJob {
/** Agent-session id for audit/app/research jobs; '' for a scheduled workflow (keyed by workflowId instead). */ /** Agent-session id for audit/app/research jobs; '' for a scheduled workflow (keyed by workflowId instead). */
sessionId: string; sessionId: string;
title: string; title: string;
kind: 'audit' | 'app' | 'schedule' | 'research'; kind: 'audit' | 'app' | 'schedule' | 'research' | 'browser';
/** The one-clause "why we started this for you", shown in the reveal note. */ /** The one-clause "why we started this for you", shown in the reveal note. */
reason?: string; reason?: string;
/** Set for kind 'schedule': the created scheduled workflow, placed as a workflow card + deletable on discard. */ /** Set for kind 'schedule': the created scheduled workflow, placed as a workflow card + deletable on discard. */
@@ -83,6 +83,8 @@ export interface AppSettings {
/** Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas. */ /** Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas. */
accent_gradient?: string[] | null; accent_gradient?: string[] | null;
personalized_greeting?: string | null; personalized_greeting?: string | null;
/** The short one-glance identity hook shown in the reveal's focal beat (greeting is the longer read). */
personalized_headline?: string | null;
personalized_starters?: PersonalizedStarter[]; personalized_starters?: PersonalizedStarter[];
personalized_automations?: PersonalizedAutomation[]; personalized_automations?: PersonalizedAutomation[];
personalized_usage_summary?: string | null; personalized_usage_summary?: string | null;