diff --git a/agent/initiate_setup_prompt.py b/agent/initiate_setup_prompt.py index b103473163..aee8fc205e 100644 --- a/agent/initiate_setup_prompt.py +++ b/agent/initiate_setup_prompt.py @@ -97,7 +97,7 @@ def build_initiate_setup_prompt(surface: str, tools, primary_profile: str, sessi return f"{HEADER}\n\n{skill}\n\n```json\n{facts}\n```" -_NAME_QUESTION = "What should I call you?" +NAME_QUESTION = "What should I call you?" # A closed card counts as answered: the skill takes the default and never re-asks it. _ANSWERED = ("submitted", "cancelled") @@ -118,7 +118,7 @@ def _opening_step(call) -> str | None: args = _json_object(function.get("arguments")) if args.get("kind") == "accent": return "accent" - return "name" if args.get("kind") == "question" and args.get("question") == _NAME_QUESTION else "" + return "name" if args.get("kind") == "question" and args.get("question") == NAME_QUESTION else "" def _opening_so_far(history): @@ -172,7 +172,7 @@ def _opening(suggested: str | None, said: set, replies: dict): reply = replies.get("name") if reply is None: # ``options`` is required by the tool schema; the history must carry it even when empty. - card = {"kind": "question", "question": _NAME_QUESTION, + card = {"kind": "question", "question": NAME_QUESTION, "options": [{"id": "suggested", "label": suggested}] if suggested else [], "multi_select": False} reply = _json_object((yield "" if INTRO in said else INTRO, "setup_choose", card)) if "accent" in replies: diff --git a/optional-skills/productivity/initiate-setup/SKILL.md b/optional-skills/productivity/initiate-setup/SKILL.md index a392cad39a..8b3b61fd74 100644 --- a/optional-skills/productivity/initiate-setup/SKILL.md +++ b/optional-skills/productivity/initiate-setup/SKILL.md @@ -192,7 +192,7 @@ One short sentence: the work gets its own chat so it has room, and this one stay - `profile`: `primary_profile`. Without it the task opens here, with no terminal or file tools. - `title`: the task's name. -- `message`: the handoff message, written exactly as the fork result's `handoff.message` says, with `handoff.plan` as its plan. +- `message`: the handoff message, written exactly as the fork result's `handoff.message` says, with `handoff.plan` as its plan. The app appends a "What setup learned" block (their picks and the scan) under it; do not write that part. The task chat talks first: it offers a few directions in plain text and builds once they pick. ### Beat 8: after the handoff @@ -220,7 +220,7 @@ When `surface` is not `desktop` or a tool is not in `tools_present`: ## Pitfalls - Repeating the card's question in text. -- A handoff message missing a part: the task chat sees nothing else. +- A handoff message missing a part: the task chat sees only it and the app's "What setup learned" block. ## Verification diff --git a/optional-skills/productivity/initiate-setup/scripts/host_facts.py b/optional-skills/productivity/initiate-setup/scripts/host_facts.py index 6d6e7f6942..75b5e9ed47 100644 --- a/optional-skills/productivity/initiate-setup/scripts/host_facts.py +++ b/optional-skills/productivity/initiate-setup/scripts/host_facts.py @@ -798,11 +798,34 @@ def _handoff(facts: dict) -> dict: "machine": '"' + " ".join(part for part in parts if part) + '"'} +_LEVELS = {"beginner": "new to AI agent apps", "power-user": "a power user", "expert": "an expert"} + + +def _learned(facts: dict) -> list[str]: + """The scan, in plain first-person lines, for the block ``start_chat`` appends to the task chat's first + message. Only interpreted fields: no names, paths or raw values from the scan.""" + scan, machine = facts.get("scan") or {}, facts["machine"] + ram = f", {machine['ram_gb']} GB RAM" if machine.get("ram_gb") else "" + lines = [f"This {facts['signals']['machine_kind']}: {facts['signals']['description']}{ram}."] + if scan.get("apps_used"): + lines.append(f"Apps I use on it: {', '.join(scan['apps_used'])}.") + if scan.get("runs_agents") is True: + lines.append("I already run AI agents here.") + level = _LEVELS.get(scan.get("user_level"), "") + if scan.get("developer") is True: + level = f"{level}, a developer" if level else "a developer" + if level: + lines.append(f"I am {level}.") + return lines + + def setup_cards(facts: dict) -> dict: """What the setup cards take from these facts: the fork rows, the rows the apps and plugins cards start - with picked (apps seen in use; Blender when it is here), and the handoff text the fork result carries.""" + with picked (apps seen in use; Blender when it is here), the handoff text the fork result carries, and + the scan lines ``start_chat`` appends to the task chat's first message.""" used = (facts.get("scan") or {}).get("apps_used") or [] return { + "learned": _learned(facts), "fork": facts["fork"], "preselected": { "connectors": [_SCAN_CONNECTORS[app] for app in used if app in _SCAN_CONNECTORS], diff --git a/optional-skills/productivity/initiate-setup/templates/handoff.md b/optional-skills/productivity/initiate-setup/templates/handoff.md index 3d8a11b297..09c77177c1 100644 --- a/optional-skills/productivity/initiate-setup/templates/handoff.md +++ b/optional-skills/productivity/initiate-setup/templates/handoff.md @@ -4,7 +4,7 @@ ## message -The handoff message is the new chat's first user message. It is visible, so write it as their own ask, in their language, in the first person. Nothing else reaches the task chat: no memory, no hidden note. +The handoff message is the new chat's first user message. It is visible, so write it as their own ask, in their language, in the first person. The app adds one block under it, "What setup learned about me": their name, app, plugin and layout picks, and what the scan saw. Do not list those picks or scan facts again. Nothing else reaches the task chat: no memory, no hidden note. The install list: the plugins they picked that the task needs (all of them for machine setup or a task naming the app), plus a picked first task's own `plugins`. The connect list: apps the task needs that setup did not connect; none for machine setup. @@ -12,14 +12,14 @@ Write these parts in order, one paragraph each (part names are not text). Copy t 1. The ask, in one or two sentences, in their words where they gave them, keeping the named app and the outcome. No machine specs. 2. "Call me ." Then what they are working on, if they said it. -3. "I use: . Connected during setup: ." -4. Only with an install or connect list: "In your first turn, before anything else: install with one `manage_catalog` install call carrying all of them, then connect with one `manage_connections` connect call carrying all of them. The ids are exact; skip search and status checks, and start the work when they return. If I skip some, go on without them and tell me in one line what each would have added; do not offer them again." Drop the install or the connect clause when its list is empty. With an install list, add: "Find plugin tools with `tool_search` and read a plugin's skill with `skill_view` by its exact name; if its app is not running, tell me plainly." With other plugin picks, add: "Picked during setup, not needed yet: ." +3. "Connected during setup: ." +4. Only with an install or connect list: "Once I pick a direction or say go, before building: install with one `manage_catalog` install call carrying all of them, then connect with one `manage_connections` connect call carrying all of them. The ids are exact; skip search and status checks, and start the work when they return. If I skip some, go on without them and tell me in one line what each would have added; do not offer them again." Drop the install or the connect clause when its list is empty. With an install list, add: "Find plugin tools with `tool_search` and read a plugin's skill with `skill_view` by its exact name; if its app is not running, tell me plainly." With other plugin picks, add: "Picked during setup, not needed yet: ." 5. The plan below, word for word. -6. Last (drop the first sentence when `scan.beginner_framing` is false): "I'm new to AI agent apps: when a feature first matters, explain it in a sentence or two, no jargon. As you start, tell me in one short sentence that you'll ask for permissions as you go and I can say no or redirect you. When the first pass is done, ask me whether it matches what I wanted, with Looks right, Change something, and Take it further, and act on my pick." +6. Last (drop the first sentence when `scan.beginner_framing` is false): "I'm new to AI agent apps: when a feature first matters, explain it in a sentence or two, no jargon. When you start building, tell me in one short sentence that you'll ask for permissions as you go and I can say no or redirect you. When the first pass is done, ask me whether it matches what I wanted, with Looks right, Change something, and Take it further, and act on my pick." ## build -"Plan briefly, then build: scaffold, research, first artifact. Use real data only from connected apps and find their tools with `tool_search`; tools already signed in on this computer, like a logged-in gh, are fair to use, and say so in one line. Never route around a connector: no IMAP client, app password or other way into the same account; if I decline one, that app is out of this build. Ask before sending, deleting or scheduling anything, and set up no recurring job unless I asked. Make the result something I can open: one HTML page if the idea allows it." With a connect list, add: "Include at least one real reading or action through a connected app." Without one, add: "Make this first version finishable with no account I have not connected: web research with the browser visible to me, scripts, a small app, a file-based tracker, a generated page. If the idea needs an account, build the no-account core first and offer the connection next." +"Talk with me before you build. In your first reply, say in two or three plain lines what you take from what setup learned, then ask me in plain text, not a card, one short question with two to four concrete directions drawn from my apps and this computer, like: Should I start with A, B or C? Or tell me more about what you want. Name the apps each direction needs and offer to connect them. Build only after I pick one or say go; then plan briefly and build. Use real data only from connected apps and find their tools with `tool_search`; tools already signed in on this computer, like a logged-in gh, are fair to use, and say so in one line. Never route around a connector: no IMAP client, app password or other way into the same account; if I decline one, that app is out of this build. Ask before sending, deleting or scheduling anything, and set up no recurring job unless I asked. Never use mock or sample data." With a connect list, add: "Include at least one real reading or action through a connected app." Without one, add: "Make this first version finishable with no account I have not connected: web research with the browser visible to me, scripts, a small app, a file-based tracker, a generated page. If the idea needs an account, build the no-account core first and offer the connection next." ## machine diff --git a/tools/setup_choose_tool.py b/tools/setup_choose_tool.py index 1bfaa05ba8..4c7cbc47be 100644 --- a/tools/setup_choose_tool.py +++ b/tools/setup_choose_tool.py @@ -147,6 +147,19 @@ def _follow_up(kind: str, card: str, result: dict, rows: Optional[list], cards: return extra, state +# The picks start_chat hands the task chat from the setup profile, keyed by card kind. +_REMEMBERED = frozenset({"connectors", "plugins", "layout"}) + + +def _remember(kind: str, question: str, result: dict, state: dict) -> dict: + from agent.initiate_setup_prompt import NAME_QUESTION + + key = "name" if kind == "question" and question == NAME_QUESTION else kind if kind in _REMEMBERED else None + if key is None or result["outcome"] != "submitted": + return state + return {**state, "picks": {**state.get("picks", {}), key: result.get("label") or result["picked"]}} + + # App-owned parts of a card, filled here from the recorded facts so the model can neither drop nor edit them. _APP_FILLED: dict[str, Callable[[dict], dict]] = { "tour": lambda cards: {"options": _TOUR_ROWS, "multi_select": False}, @@ -223,6 +236,7 @@ def setup_choose_tool(kind: str = "", question: str = "", options=None, multi_se reply = callback(payload) result = _result(reply, payload["options"]) extra, state = _follow_up(kind, card, result, payload["options"], cards) + state = _remember(kind, text, result, state) if session_id and state != cards: record_cards(session_id, state) return json.dumps({**result, **extra}, ensure_ascii=False) diff --git a/tui_gateway/start_chat.py b/tui_gateway/start_chat.py index 85476e65b4..ecdadf8484 100644 --- a/tui_gateway/start_chat.py +++ b/tui_gateway/start_chat.py @@ -9,11 +9,29 @@ def _rejected(reason: str, retryable: bool = False) -> str: return json.dumps({"status": "rejected", "reason": reason, "retryable": retryable}) +# The setup picks the handoff block names, in its order. +_PICK_LINES = (("name", "Name"), ("connectors", "Apps I picked"), ("plugins", "Plugins I picked"), ("layout", "Layout")) + + +def _setup_learned(cards: dict) -> str: + """The "What setup learned" block for a handoff from the setup profile: the user's card picks and the + interpreted scan lines its ``/initiate-setup`` turn recorded, so the task chat has them whatever the model + wrote. Empty when nothing was recorded.""" + picks = cards.get("picks") or {} + lines = [f"- {label}: {', '.join(map(str, value)) if isinstance(value, list) else value}" + for key, label in _PICK_LINES if (value := picks.get(key))] + lines += [f"- {line}" for line in cards.get("learned") or ()] + if not lines: + return "" + return "\n".join(["What setup learned about me:", *lines, + "Start from this; look at my computer again only when the task needs more."]) + + def start_chat(args: dict, caller_id: str | None = None) -> str: from agent.onboarding import PROFILE_BUILD_FLAG, mark_seen from gateway.session_context import get_session_env from hermes_cli.profiles import SETUP_PROFILE_MARKER - from hermes_cli.setup_profile import mark_completed + from hermes_cli.setup_profile import mark_completed, read_cards from hermes_constants import profile_name_for_home from tui_gateway import server from tui_gateway.transport import bind_transport, reset_transport @@ -34,6 +52,11 @@ def start_chat(args: dict, caller_id: str | None = None) -> str: except server.ProfileUnavailableError as exc: return _rejected(str(exc)) target_home = Path(home or server._hermes_home) + from_setup = (caller_home / SETUP_PROFILE_MARKER).is_file() + if from_setup: + with server._session_profile_runtime_scope({"profile_home": str(caller_home)}, hydrate_secrets=False): + learned = _setup_learned(read_cards(caller.get("session_key") or "")) + message = f"{message}\n\n{learned}" if learned else message token = bind_transport(caller.get("transport")) try: with server._session_profile_runtime_scope({"profile_home": str(home) if home else None}): @@ -49,7 +72,7 @@ def start_chat(args: dict, caller_id: str | None = None) -> str: return _rejected(submitted["error"]["message"], retryable=True) finally: reset_transport(token) - if (caller_home / SETUP_PROFILE_MARKER).is_file() and target_home.resolve() != caller_home.resolve(): + if from_setup and target_home.resolve() != caller_home.resolve(): mark_completed() name = result["info"]["profile_name"] return json.dumps({ diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-initiate-setup.md b/website/docs/user-guide/skills/optional/productivity/productivity-initiate-setup.md index 9f674ee61a..441d9c62d1 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-initiate-setup.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-initiate-setup.md @@ -209,7 +209,7 @@ One short sentence: the work gets its own chat so it has room, and this one stay - `profile`: `primary_profile`. Without it the task opens here, with no terminal or file tools. - `title`: the task's name. -- `message`: the handoff message, written exactly as the fork result's `handoff.message` says, with `handoff.plan` as its plan. +- `message`: the handoff message, written exactly as the fork result's `handoff.message` says, with `handoff.plan` as its plan. The app appends a "What setup learned" block (their picks and the scan) under it; do not write that part. The task chat talks first: it offers a few directions in plain text and builds once they pick. ### Beat 8: after the handoff @@ -237,7 +237,7 @@ When `surface` is not `desktop` or a tool is not in `tools_present`: ## Pitfalls - Repeating the card's question in text. -- A handoff message missing a part: the task chat sees nothing else. +- A handoff message missing a part: the task chat sees only it and the app's "What setup learned" block. ## Verification