diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py
index f4f4ee99..13037c6f 100644
--- a/backend/apps/help/bundle.py
+++ b/backend/apps/help/bundle.py
@@ -1,8 +1,8 @@
-"""Diagnostic bundle for bug reports: one folder a user can drag into a GitHub issue.
+"""Routes for the Help panel: the product-knowledge feed, and the diagnostic bundle for bug reports.
-Everything is assembled LOCALLY and only revealed in the file manager; nothing uploads
-anywhere by itself. Contents are deliberately allowlisted (identity, versions, feature
-booleans, provider KINDS, counts, log tail) so no secret or API key can ever ride along.
+The bundle is assembled LOCALLY and only revealed in the file manager; nothing uploads anywhere by
+itself. Contents are deliberately allowlisted (identity, versions, feature booleans, provider KINDS,
+counts, log tail) so no secret or API key can ever ride along.
"""
import base64
@@ -18,6 +18,7 @@ from typing import AsyncIterator, List
from pydantic import BaseModel, ConfigDict, Field
from typeguard import typechecked
+from backend.apps.help.knowledge import HelpKnowledgeResponse, build_knowledge_response
from backend.config.Apps import SubApp
from backend.config.paths import DATA_ROOT, SESSIONS_DIR
@@ -138,6 +139,12 @@ def p_build_report(req: BundleRequest) -> str:
return "\n".join(lines)
+@help_app.router.get("/knowledge")
+@typechecked
+async def get_help_knowledge() -> HelpKnowledgeResponse:
+ return build_knowledge_response()
+
+
@help_app.router.post("/bundle")
@typechecked
async def build_bundle(body: BundleRequest) -> dict:
diff --git a/backend/apps/help/help_topics.py b/backend/apps/help/help_topics.py
new file mode 100644
index 00000000..76866f2d
--- /dev/null
+++ b/backend/apps/help/help_topics.py
@@ -0,0 +1,269 @@
+"""Curated product knowledge for the in-app help chat.
+
+Every fact here is written from the CODE, not from the docs site, and ships inside the same
+build as the surfaces it describes, so help can never describe a version the user isn't running.
+Each topic is self-contained on purpose: the help panel searches these offline with no model at
+all, and the chat gets them as a cached system-prompt block.
+
+When you move or rename a surface, update the topic in the SAME change. A help topic that lies is
+worse than no help topic.
+"""
+
+from typing import List
+
+from pydantic import BaseModel, ConfigDict
+
+
+class HelpTopic(BaseModel):
+ model_config = ConfigDict(validate_assignment=True)
+
+ id: str
+ title: str
+ # Where the thing physically is on screen. Empty when the topic is a concept, not a surface.
+ where: str
+ body: str
+ keywords: List[str]
+
+
+HELP_TOPICS: List[HelpTopic] = [
+ HelpTopic(
+ id="canvas",
+ title="The dashboard canvas",
+ where="The whole main window.",
+ body=(
+ "Each dashboard is an infinite canvas holding cards: agent chats, browsers, apps you "
+ "built, and workflows. Scroll or pinch to zoom, hold Space and drag to pan, drag a card "
+ "by its header to move it, and drag on empty canvas to marquee-select several."
+ ),
+ keywords=["canvas", "dashboard", "zoom", "pan", "move", "cards", "select"],
+ ),
+ HelpTopic(
+ id="dock",
+ title="The dock",
+ where="A dark vertical rail down the left edge of the window.",
+ body=(
+ "Every open card gets a colored tile at the top of the dock; click one to jump to that "
+ "card, right-click it for its menu. Below the divider sit four actions in this order: "
+ "New browser, Workflows, then Settings and Applications."
+ ),
+ keywords=["dock", "rail", "sidebar", "left", "tiles", "icons"],
+ ),
+ HelpTopic(
+ id="applications",
+ title="Applications, your app library",
+ where="The grid icon at the very bottom of the left dock.",
+ body=(
+ "Opens a window listing every app you have built or imported, with thumbnails. Click one "
+ "to drop it onto the current dashboard as a live card. A search box appears once you have "
+ "more than eight apps. It lists YOUR apps, not the programs installed on your computer."
+ ),
+ keywords=["applications", "apps", "library", "grid", "built", "outputs"],
+ ),
+ HelpTopic(
+ id="new-chat",
+ title="Starting a new chat",
+ where="The 'Ask me anything...' pill at the bottom center of the canvas.",
+ body=(
+ "Type in the pill and press Enter to spawn an agent card. The pill is hidden while a "
+ "dashboard is completely empty, so the keyboard shortcut is the reliable way in; it works "
+ "from anywhere. The new card lands beside whatever is selected, otherwise in view."
+ ),
+ keywords=["new chat", "agent", "spawn", "pill", "ask", "start", "composer"],
+ ),
+ HelpTopic(
+ id="window-controls",
+ title="Card window controls and tiling",
+ where="The three traffic-light dots in the top-left corner of any card.",
+ body=(
+ "Red closes, yellow minimizes, green toggles full screen. HOVER the green dot instead of "
+ "clicking it and a macOS-style tiling menu opens with two groups: Fill and Halves (fill, "
+ "left, right, top, bottom) and Quarters. Clicking green again restores a tiled card."
+ ),
+ keywords=["traffic lights", "fullscreen", "tile", "halves", "quarters", "minimize", "close", "green"],
+ ),
+ HelpTopic(
+ id="minimized",
+ title="Minimized cards",
+ where="A stack of small thumbnails on the right edge of the canvas.",
+ body=(
+ "Minimizing a card with the yellow dot parks it in the right-edge stack instead of closing "
+ "it. Click a thumbnail to put the card back on the canvas exactly where it was."
+ ),
+ keywords=["minimize", "minimized", "restore", "stack", "thumbnail", "yellow"],
+ ),
+ HelpTopic(
+ id="spaces",
+ title="Dashboards, which behave like Spaces",
+ where="Rest the cursor on the very top edge of the window to reveal the spaces bar.",
+ body=(
+ "Dashboards are separate canvases, like macOS Spaces. The top-edge bar switches between "
+ "them, + adds one, and right-clicking a space renames or removes it. There is also a "
+ "keyboard shortcut for the previous and next dashboard."
+ ),
+ keywords=["spaces", "dashboards", "switch", "top edge", "workspace", "add"],
+ ),
+ HelpTopic(
+ id="history",
+ title="Chat history",
+ where="The island at the top center of the dashboard.",
+ body=(
+ "History lists past chats across ALL dashboards, not just the current one. Picking a chat "
+ "reopens it as a card. It is on the top island, not in the dock."
+ ),
+ keywords=["history", "past chats", "previous", "reopen", "recent", "island"],
+ ),
+ HelpTopic(
+ id="search",
+ title="Search everything",
+ where="The global search palette, opened with the search shortcut.",
+ body=(
+ "One palette searches chats, apps, and commands across every dashboard. There is a "
+ "separate find that searches only the cards on the current canvas, and inside a browser "
+ "card that same find searches the web page instead."
+ ),
+ keywords=["search", "find", "palette", "command", "lookup", "cmd k"],
+ ),
+ HelpTopic(
+ id="workflows",
+ title="Workflows and scheduled tasks",
+ where="The repeating-calendar icon in the left dock, above Settings.",
+ body=(
+ "A workflow is a sequence of agent steps that runs on a schedule, for example every "
+ "weekday at 9am. Open the Workflows window from the dock to create one, set its steps and "
+ "schedule, run it immediately with Run now, or pause it. You can also just ask an agent in "
+ "chat to schedule something and it will build the workflow for you."
+ ),
+ keywords=["workflow", "schedule", "scheduled", "task", "cron", "recurring", "automation", "daily"],
+ ),
+ HelpTopic(
+ id="apps",
+ title="Building apps",
+ where="Ask any agent chat, or the + menu on the canvas.",
+ body=(
+ "Ask an agent for a tool, dashboard, or game and it calls CreateApp, which seeds a "
+ "workspace and puts a live preview card on the dashboard, then writes the code. To change "
+ "an app, select its card and tell the agent what to change; the preview reloads itself."
+ ),
+ keywords=["app", "build", "create", "app builder", "preview", "vite", "code"],
+ ),
+ HelpTopic(
+ id="publish",
+ title="Publishing an app to the web",
+ where="The share or publish control on a built app's card.",
+ body=(
+ "An app can be published to a public {slug}.openswarm.host URL. Publishing first scans the "
+ "code for anything sensitive and shows the findings before it uploads. Published apps can "
+ "be unpublished again from the same place."
+ ),
+ keywords=["publish", "share", "host", "openswarm.host", "deploy", "public", "link"],
+ ),
+ HelpTopic(
+ id="browser",
+ title="Browser cards",
+ where="The globe icon in the left dock.",
+ body=(
+ "A browser card is a real browser on the canvas, with tabs, its own zoom, find-in-page, and "
+ "a persistent login session that survives quitting the app. Agents can drive these cards "
+ "for you, and you stay signed in to the sites you use."
+ ),
+ keywords=["browser", "web", "tabs", "globe", "login", "website", "internet"],
+ ),
+ HelpTopic(
+ id="browser-agent",
+ title="Agents that use the browser",
+ where="Happens automatically inside a chat.",
+ body=(
+ "When a task needs a real website, the agent delegates to a browser sub-agent, which opens "
+ "a browser card and works in it while you watch. It is the last resort: agents prefer "
+ "connected tools and plain web search first, because those are faster and more reliable."
+ ),
+ keywords=["browser agent", "sub-agent", "automation", "click", "website", "delegate"],
+ ),
+ HelpTopic(
+ id="tools",
+ title="Tools and MCP integrations",
+ where="Settings, then Tools.",
+ body=(
+ "Integrations (Gmail, Notion, Slack and so on) are MCP servers. They are deliberately NOT "
+ "active by default: an agent has to find one with MCPSearch and then activate it with "
+ "MCPActivate, which asks for your approval first. Nothing gets tool access silently."
+ ),
+ keywords=["tools", "mcp", "integration", "connect", "gmail", "notion", "slack", "approval", "activate"],
+ ),
+ HelpTopic(
+ id="approvals",
+ title="Approvals",
+ where="In the chat card, as a prompt from the agent.",
+ body=(
+ "Before an agent does something that needs your say-so it stops and asks in the chat. You "
+ "approve or deny, and you can tell it to remember the choice so it stops asking for that "
+ "same action."
+ ),
+ keywords=["approval", "permission", "approve", "deny", "hitl", "ask", "confirm"],
+ ),
+ HelpTopic(
+ id="skills",
+ title="Skills",
+ where="Settings, then Skills.",
+ body=(
+ "A skill is a reusable set of instructions that teaches agents how to do a specific task. "
+ "You can write one, install one from the registry, or import one, and agents pull in a "
+ "skill on demand when it is relevant instead of carrying all of them every turn."
+ ),
+ keywords=["skill", "skills", "instructions", "registry", "teach", "import"],
+ ),
+ HelpTopic(
+ id="dictation",
+ title="Voice dictation",
+ where="The mic button on the composer, or the dictation shortcut.",
+ body=(
+ "Dictation transcribes speech locally on your machine and drops the words wherever your "
+ "cursor is. The first use downloads a voice model, so it is slower once and fast after. "
+ "Hold-to-talk versus click-to-toggle is a setting under General."
+ ),
+ keywords=["dictation", "voice", "mic", "speak", "transcribe", "talk", "whisper"],
+ ),
+ HelpTopic(
+ id="models",
+ title="Connecting a model",
+ where="Settings, then Models.",
+ body=(
+ "You can connect a Claude, ChatGPT, or Gemini subscription, paste an API key, or use "
+ "OpenSwarm's own paid plan. Brand-new installs get a small free trial that runs on a "
+ "shared pool. If a connection dies, this is the page with the Reconnect button."
+ ),
+ keywords=["model", "models", "connect", "api key", "subscription", "claude", "gpt", "gemini", "provider", "reconnect", "free trial"],
+ ),
+ HelpTopic(
+ id="settings",
+ title="Settings",
+ where="The gear icon in the left dock; it opens as a card on the canvas.",
+ body=(
+ "Sections are Account; then App: General, Appearance, Privacy, Advanced; then "
+ "Capabilities: Models, Skills, Tools, Commands, Usage. Theme, accent color, and text size "
+ "live under Appearance. Agent defaults and shortcuts live under General."
+ ),
+ keywords=["settings", "preferences", "gear", "config", "theme", "account", "appearance", "privacy", "advanced", "usage"],
+ ),
+ HelpTopic(
+ id="swarm-file",
+ title="Sharing with .swarm files",
+ where="Share on the thing you want to export; drag a .swarm file onto the app to import.",
+ body=(
+ "Apps, skills, and workflows export to a single .swarm file you can send someone. Importing "
+ "one adds it to your library. The export is scanned so credentials never travel inside it."
+ ),
+ keywords=["swarm", "export", "import", "share", "file", "backup", "send"],
+ ),
+ HelpTopic(
+ id="report-bug",
+ title="Reporting a bug",
+ where="The Help pill, top right, then Report a bug.",
+ body=(
+ "It writes a diagnostics folder locally (versions, platform, recent log tail, with secrets "
+ "stripped), reveals that folder in your file manager, and opens a prefilled GitHub issue to "
+ "drag the files into. Nothing uploads on its own."
+ ),
+ keywords=["bug", "report", "issue", "broken", "diagnostics", "github", "feedback", "crash"],
+ ),
+]
diff --git a/backend/apps/help/knowledge.py b/backend/apps/help/knowledge.py
new file mode 100644
index 00000000..78db0d59
--- /dev/null
+++ b/backend/apps/help/knowledge.py
@@ -0,0 +1,187 @@
+"""Assembles what the in-app help chat knows: shipped product facts plus live facts about THIS
+install (version, platform, the user's actual shortcuts, whether a model is even connected).
+
+Shipped facts can't drift from the build. Live facts are the part a shipped file can never get
+right, and they are exactly where a hardcoded help prompt goes stale first.
+
+The result is handed to the chat as its system prompt, which lands in the provider's cached
+prefix (see RunOptions), so the knowledge is paid for once per session rather than per turn.
+"""
+
+import platform
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict
+from typeguard import typechecked
+
+from backend.apps.help.help_topics import HELP_TOPICS, HelpTopic
+from backend.apps.help.known_issues import KNOWN_ISSUES, HelpKnownIssue
+from backend.apps.help.prompt_rules import GROUNDING_RULES, ROLE
+
+IS_MAC = platform.system() == "Darwin"
+
+# Off mac these handlers fire on Ctrl (the code reads metaKey||ctrlKey), so "Meta" must READ as Ctrl.
+P_MAC_GLYPH = {"meta": "⌘", "ctrl": "⌃", "alt": "⌥", "shift": "⇧"}
+P_OTHER_NAME = {"meta": "Ctrl", "ctrl": "Ctrl", "alt": "Alt", "shift": "Shift"}
+
+# Same "Meta+l" parts format the settings fields use, so one renderer covers stock and configured alike.
+P_FIXED_SHORTCUTS: List[tuple] = [
+ ("Meta+k", "Search everything, across all dashboards"),
+ ("Meta+f", "Find cards on this canvas; inside a browser card it finds text on the page"),
+ ("Meta+Shift+t", "Reopen the card you just closed"),
+ ("Meta+a", "Select every card"),
+ ("Meta+c", "Copy the selected cards"),
+ ("Meta+v", "Paste the copied cards"),
+ ("Delete", "Delete the selected cards"),
+ ("Enter", "Expand or collapse the selected chat"),
+ ("Meta+Alt+Left", "Previous dashboard"),
+ ("Meta+Alt+Right", "Next dashboard"),
+ ("Meta+=", "Zoom in; inside a browser card it zooms the page"),
+ ("Meta+-", "Zoom out; inside a browser card it zooms the page"),
+ ("Meta+0", "Reset zoom"),
+ ("Ctrl+Tab", "Next tab in the focused browser card"),
+ ("Space", "Hold and drag to pan the canvas"),
+ ("Arrows", "Move between cards"),
+ ("Escape", "Close the open panel or menu"),
+]
+
+
+class HelpShortcut(BaseModel):
+ model_config = ConfigDict(validate_assignment=True)
+
+ keys: str
+ action: str
+
+
+class HelpKnowledgeResponse(BaseModel):
+ model_config = ConfigDict(validate_assignment=True)
+
+ system_prompt: str
+ topics: List[HelpTopic]
+ known_issues: List[HelpKnownIssue]
+ shortcuts: List[HelpShortcut]
+ app_version: str
+
+
+@typechecked
+def render_combo(combo: str) -> str:
+ """'Meta+Shift+d' -> '⇧⌘D' on mac, 'Ctrl+Shift+D' elsewhere."""
+ parts = [p for p in combo.split("+") if p]
+ out: List[str] = []
+ for part in parts:
+ low = part.lower()
+ if IS_MAC:
+ out.append(P_MAC_GLYPH.get(low, part.upper() if len(part) == 1 else part))
+ else:
+ out.append(P_OTHER_NAME.get(low, part.upper() if len(part) == 1 else part))
+ return ("" if IS_MAC else "+").join(out)
+
+
+@typechecked
+def dictation_default_combo() -> str:
+ return "Meta+Shift+d" if IS_MAC else "Ctrl+Shift+d"
+
+
+@typechecked
+def build_shortcuts(new_agent_combo: str, dictation_combo: Optional[str]) -> List[HelpShortcut]:
+ live = [
+ HelpShortcut(keys=render_combo(new_agent_combo or "Meta+l"), action="Open the new-chat composer"),
+ HelpShortcut(keys=render_combo(dictation_combo or dictation_default_combo()), action="Start or stop voice dictation"),
+ ]
+ return live + [HelpShortcut(keys=render_combo(c), action=a) for c, a in P_FIXED_SHORTCUTS]
+
+
+@typechecked
+def p_provider_state() -> str:
+ """One honest line about whether this install can actually run a model."""
+ from backend.apps.settings.store import load_settings
+
+ s = load_settings()
+ mode = getattr(s, "connection_mode", "own_key")
+ if mode == "free-trial":
+ return "on the free trial (a shared capacity pool)"
+ if mode == "openswarm-pro":
+ return "on an OpenSwarm paid plan"
+ keyed = any(
+ getattr(s, f, None)
+ for f in ("anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key")
+ )
+ subbed = any(
+ getattr(s, f, None)
+ for f in ("claude_subscription_token", "openai_subscription_token", "gemini_subscription_token")
+ )
+ if subbed:
+ return "on a connected provider subscription"
+ if keyed:
+ return "on their own API key"
+ return "with NO model connected yet, so most agent work will fail until they connect one in Settings > Models"
+
+
+@typechecked
+def p_topics_block() -> str:
+ lines: List[str] = []
+ for t in HELP_TOPICS:
+ where = f" WHERE: {t.where}" if t.where else ""
+ lines.append(f"- [{t.id}] {t.title}.{where} {t.body}")
+ return "\n".join(lines)
+
+
+@typechecked
+def p_issues_block() -> str:
+ lines: List[str] = []
+ for i in KNOWN_ISSUES:
+ fix = f" Workaround: {i.workaround}" if i.workaround else ""
+ lines.append(f"- [{i.id}] ({i.status}) {i.title}. {i.detail}{fix}")
+ return "\n".join(lines)
+
+
+@typechecked
+def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str:
+ shortcut_lines = "\n".join(f"- {s.keys}: {s.action}" for s in shortcuts)
+ os_name = "macOS" if IS_MAC else platform.system()
+ return "\n".join(
+ [
+ ROLE,
+ "",
+ "",
+ f"OpenSwarm version {app_version} on {os_name}. This user is {p_provider_state()}.",
+ "These are live facts about the machine you are talking to; trust them over anything you recall.",
+ "",
+ "",
+ "",
+ "Verified against this exact build. This is your ground truth for where things are.",
+ p_topics_block(),
+ "",
+ "",
+ "",
+ "This user's real shortcuts, already written for their platform. Quote them exactly.",
+ shortcut_lines,
+ "",
+ "",
+ "",
+ "The complete list of issues shipped with this build. You cannot see live bug reports.",
+ p_issues_block(),
+ "",
+ "",
+ GROUNDING_RULES,
+ ]
+ )
+
+
+@typechecked
+def build_knowledge_response() -> HelpKnowledgeResponse:
+ from backend.apps.service.version import APP_VERSION
+ from backend.apps.settings.store import load_settings
+
+ s = load_settings()
+ shortcuts = build_shortcuts(
+ getattr(s, "new_agent_shortcut", "Meta+l") or "Meta+l",
+ getattr(s, "dictation_shortcut", None),
+ )
+ return HelpKnowledgeResponse(
+ system_prompt=build_system_prompt(shortcuts, APP_VERSION),
+ topics=HELP_TOPICS,
+ known_issues=KNOWN_ISSUES,
+ shortcuts=shortcuts,
+ app_version=APP_VERSION,
+ )
diff --git a/backend/apps/help/known_issues.py b/backend/apps/help/known_issues.py
new file mode 100644
index 00000000..aa52f6b4
--- /dev/null
+++ b/backend/apps/help/known_issues.py
@@ -0,0 +1,61 @@
+"""The curated known-issues list that ships with this build.
+
+Deliberately NOT the live GitHub issue queue: that queue is engineering-facing (path guards, token
+budgets) and phrased in nothing like the words a user would use for their symptom, so matching a
+user's complaint against it produces false confidence. It is also network-dependent and publicly
+writable, which is a prompt-injection surface for no gain.
+
+So: a short list of real, user-visible symptoms, each verified. The help chat is told this list is
+complete and that it has no live view of the tracker, so it can never invent a bug status.
+"""
+
+from typing import List, Literal, Optional
+
+from pydantic import BaseModel, ConfigDict
+
+
+class HelpKnownIssue(BaseModel):
+ model_config = ConfigDict(validate_assignment=True)
+
+ id: str
+ title: str
+ status: Literal["known", "mitigated", "fixed"]
+ detail: str
+ workaround: Optional[str] = None
+
+
+KNOWN_ISSUES: List[HelpKnownIssue] = [
+ HelpKnownIssue(
+ id="free-trial-capacity",
+ title="Free-trial runs fail with a capacity or busy message",
+ status="mitigated",
+ detail=(
+ "The free trial runs on one shared pool of capacity, so under load a run can come back "
+ "saying it is out of capacity. The app now waits and retries automatically instead of "
+ "erroring straight away, but a sustained busy period still ends in that message."
+ ),
+ workaround="Connecting your own subscription or API key under Settings, then Models, avoids the shared pool entirely.",
+ ),
+ HelpKnownIssue(
+ id="windows-cli-quarantine",
+ title="Windows: 'Claude Code not found' after installing",
+ status="mitigated",
+ detail=(
+ "Some Windows antivirus products quarantine the command-line binary that ships inside the "
+ "app, which makes every run fail with a not-found error. Newer builds ship that binary "
+ "code-signed, and the app now detects the case and shows repair steps instead of a raw error."
+ ),
+ workaround="Restore the file from your antivirus quarantine, or reinstall OpenSwarm. Your chats are kept either way.",
+ ),
+ HelpKnownIssue(
+ id="dashboard-switch-logout",
+ title="Switching dashboards can sign you out of a site in a browser card",
+ status="known",
+ detail=(
+ "Some sites keep their login in per-tab storage that only lives as long as the page is "
+ "mounted. Panning away from a card and back preserves it, because that state is captured "
+ "and restored, but switching to another dashboard and back can still lose it."
+ ),
+ workaround="Keep browser cards you are signed into on the dashboard you are working in, or sign in again after switching.",
+ ),
+]
diff --git a/backend/apps/help/prompt_rules.py b/backend/apps/help/prompt_rules.py
new file mode 100644
index 00000000..9608a24e
--- /dev/null
+++ b/backend/apps/help/prompt_rules.py
@@ -0,0 +1,61 @@
+"""The behavior contract for the help chat, kept apart from the facts it reasons over.
+
+The single property that matters here is refusal: a help assistant that invents a menu item is
+worse than one that says it doesn't know, because the user burns real time hunting for a button
+that was never built. Everything below exists to make "I don't know" the cheap answer.
+"""
+
+ROLE = (
+ "You are OpenSwarm's help assistant, a support chat built into the app itself.\n"
+ "You help people use OpenSwarm: where things are, how to do them, and what went wrong.\n"
+ "You are talking to someone with the app open in front of them right now."
+)
+
+GROUNDING_RULES = "\n".join(
+ [
+ "",
+ "GROUNDING. The blocks above are your only source of truth about OpenSwarm. They were written",
+ "from this build's code, so they beat anything you remember about this or any similar app.",
+ "- Answer from those blocks. Name the surface you are drawing on, in plain prose, so the user",
+ " can go look at it: 'the dock, on the left edge' rather than an unsourced instruction.",
+ "- Quote shortcuts EXACTLY as they appear in . Never guess a key combination, and",
+ " never convert between platforms yourself; the list is already correct for this machine.",
+ "- Never invent a button, menu item, tab, setting, or page name. If you cannot name the exact",
+ " surface from the blocks above, you do not know where it is, and you must say so.",
+ "- The [bracketed ids] are internal labels for your own lookup. Never print one; the user has",
+ " never seen them and they read as a glitch.",
+ "",
+ "SAYING YOU DON'T KNOW. This is a correct, expected answer, not a failure.",
+ "- If something is not covered above, say plainly that you don't know or that OpenSwarm does",
+ " not appear to have it, in one sentence, with no hedging and no invented alternative.",
+ "- Then point at something real: the docs (Help pill, then Docs and shortcuts), the Discord",
+ " (Help pill, then Talk to the team), or Report a bug for something broken.",
+ "- If the user asks for a feature that is genuinely absent, say it is absent and offer to help",
+ " them file it as a feature request. Do not describe a workflow that does not exist.",
+ "- Never dress a guess as an answer. A wrong click path costs more than an honest 'not sure'.",
+ "",
+ "BUGS AND KNOWN ISSUES. Be exact about what you can and cannot see.",
+ "- is the complete list that shipped with this build. You have no live view of",
+ " the bug tracker, so you cannot confirm or deny anything outside that list.",
+ "- If it matches a listed issue, say so and give the status and workaround verbatim.",
+ "- If it does not match, say you cannot tell whether it is a known bug, then walk them to the",
+ " Help pill and Report a bug, which packages diagnostics automatically.",
+ "- Never invent a bug status, a fix version, or an ETA.",
+ "",
+ "TROUBLESHOOTING. Diagnose from before theorizing. If no model is connected,",
+ "that is the answer to most 'it failed' questions. Ask for the exact error text when you need",
+ "it rather than guessing which of several causes applies.",
+ "",
+ "SCOPE AND STYLE.",
+ "- Stay a help chat. Never start unrelated agent work from here; if they want real work done,",
+ " tell them to start a normal chat from the canvas composer.",
+ "- You may read their settings to answer a question about their setup. Ask before changing any.",
+ "- The docs site can lag the installed version. If docs and the blocks above disagree, the",
+ " blocks win, and say so.",
+ "- Be brief. Give the click path or the key, not an essay. Two or three sentences is usually the",
+ " whole answer. No preamble, no restating the question.",
+ "- Never mention your own tools, or narrate one failing. If a tool won't cooperate, just answer",
+ " in plain text as if you had never reached for it.",
+ "",
+ ]
+)
diff --git a/backend/tests/test_help_knowledge.py b/backend/tests/test_help_knowledge.py
new file mode 100644
index 00000000..ee8ad24f
--- /dev/null
+++ b/backend/tests/test_help_knowledge.py
@@ -0,0 +1,110 @@
+"""The help chat's knowledge feed. The invariants here are all about honesty: the facts must be
+present, the shortcuts must be the user's real ones, and the prompt must forbid guessing.
+
+A help assistant that invents a menu item is the failure mode this whole feature exists to prevent,
+so the grounding rules are pinned by test rather than trusted to survive a future prompt edit.
+"""
+
+from fastapi.testclient import TestClient
+
+from backend.apps.help.help_topics import HELP_TOPICS
+from backend.apps.help.knowledge import (
+ build_knowledge_response,
+ build_shortcuts,
+ build_system_prompt,
+ render_combo,
+)
+from backend.apps.help.known_issues import KNOWN_ISSUES
+from backend.main import app
+
+
+def test_knowledge_endpoint_serves_topics_and_issues():
+ import backend.auth as auth_mod
+
+ with TestClient(app, headers={"Authorization": f"Bearer {auth_mod.TOKEN}"}) as client:
+ res = client.get("/api/help/knowledge")
+ assert res.status_code == 200
+ body = res.json()
+ assert len(body["topics"]) == len(HELP_TOPICS)
+ assert len(body["known_issues"]) == len(KNOWN_ISSUES)
+ assert body["system_prompt"]
+ assert body["app_version"]
+
+
+def test_every_topic_is_self_contained():
+ """Kapa's rule: a chunk retrieved alone still has to make sense alone."""
+ for topic in HELP_TOPICS:
+ assert topic.id and topic.title and topic.body
+ assert topic.keywords, f"{topic.id} has no keywords, so local search can never find it"
+ assert len(topic.body) > 40, f"{topic.id} body is too thin to answer anything"
+
+
+def test_topic_ids_are_unique():
+ ids = [t.id for t in HELP_TOPICS]
+ assert len(ids) == len(set(ids))
+
+
+def test_known_issues_never_carry_a_fix_date_or_eta():
+ """Bug status is the easiest thing to lie about, so the data itself must not invite it."""
+ for issue in KNOWN_ISSUES:
+ assert issue.status in ("known", "mitigated", "fixed")
+ assert issue.detail
+ for banned in ("ETA", "next release", "soon", "will be fixed"):
+ assert banned.lower() not in issue.detail.lower()
+
+
+def test_render_combo_matches_the_platform():
+ from backend.apps.help.knowledge import IS_MAC
+
+ if IS_MAC:
+ assert render_combo("Meta+l") == "⌘L"
+ assert render_combo("Meta+Shift+d") == "⌘⇧D"
+ else:
+ assert render_combo("Meta+l") == "Ctrl+L"
+ assert render_combo("Meta+Shift+d") == "Ctrl+Shift+D"
+
+
+def test_shortcuts_use_the_users_configured_combo_not_the_default():
+ shortcuts = build_shortcuts("Meta+Shift+j", "Alt+m")
+ new_chat = next(s for s in shortcuts if s.action == "Open the new-chat composer")
+ dictation = next(s for s in shortcuts if "dictation" in s.action)
+ assert new_chat.keys == render_combo("Meta+Shift+j")
+ assert dictation.keys == render_combo("Alt+m")
+
+
+def test_dictation_falls_back_to_the_platform_default_when_unset():
+ from backend.apps.help.knowledge import dictation_default_combo
+
+ shortcuts = build_shortcuts("Meta+l", None)
+ dictation = next(s for s in shortcuts if "dictation" in s.action)
+ assert dictation.keys == render_combo(dictation_default_combo())
+
+
+def test_prompt_forbids_guessing_and_permits_not_knowing():
+ prompt = build_knowledge_response().system_prompt
+ assert "Never invent a button" in prompt
+ assert "Never invent a bug status" in prompt
+ assert "you don't know" in prompt.lower()
+ # The refusal has to be framed as acceptable, or the model treats it as a last resort.
+ assert "not a failure" in prompt
+
+
+def test_prompt_states_it_cannot_see_live_bug_reports():
+ prompt = build_knowledge_response().system_prompt
+ assert "cannot see live bug reports" in prompt
+ assert "no live view of" in prompt
+
+
+def test_prompt_carries_every_topic_and_issue():
+ prompt = build_system_prompt(build_shortcuts("Meta+l", None), "9.9.9")
+ for topic in HELP_TOPICS:
+ assert f"[{topic.id}]" in prompt
+ for issue in KNOWN_ISSUES:
+ assert f"[{issue.id}]" in prompt
+ assert "9.9.9" in prompt
+
+
+def test_prompt_stays_within_a_sane_token_budget():
+ """It rides the cached prefix, but an unbounded knowledge blob still costs a cache write."""
+ prompt = build_knowledge_response().system_prompt
+ assert len(prompt) < 24_000, "help knowledge is growing past its budget; tighten the topics"
diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx
new file mode 100644
index 00000000..20c6d384
--- /dev/null
+++ b/frontend/src/app/pages/Dashboard/desktop/HelpAskBox.tsx
@@ -0,0 +1,61 @@
+import React, { useMemo } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
+import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
+import { searchHelp, type HelpKnowledge } from './helpSearch';
+
+interface HelpAskBoxProps {
+ value: string;
+ knowledge: HelpKnowledge | null;
+ onChange: (v: string) => void;
+ onAsk: () => void;
+}
+
+/**
+ * Search first, chat second (the Raycast/Linear shape): the top questions are answered from facts
+ * that shipped with the build, instantly, with no model call and no network. That also makes this
+ * the one part of Help that still works when the user's provider is the thing that's broken.
+ */
+const HelpAskBox: React.FC = ({ value, knowledge, onChange, onAsk }) => {
+ const matches = useMemo(() => searchHelp(knowledge, value), [knowledge, value]);
+
+ return (
+
+
+
+ ) => onChange(e.target.value)}
+ onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') onAsk(); e.stopPropagation(); }}
+ placeholder="Ask OpenSwarm anything..."
+ sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', '&::placeholder': { color: 'rgba(255,255,255,0.4)' } }}
+ />
+ {value.trim() && (
+
+
+
+ )}
+
+ {matches.length > 0 && (
+
+
+ From the docs
+
+ {matches.map((m) => (
+
+ {m.title}
+ {m.detail}
+
+ ))}
+
+ Press Enter to ask the help chat instead.
+
+
+ )}
+
+ );
+};
+
+export default HelpAskBox;
diff --git a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx
index 62aa072f..afcb5600 100644
--- a/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/HelpPanel.tsx
@@ -1,9 +1,8 @@
-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { AnimatePresence, motion } from 'framer-motion';
-import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import BugReportOutlinedIcon from '@mui/icons-material/BugReportOutlined';
import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined';
import MenuBookOutlinedIcon from '@mui/icons-material/MenuBookOutlined';
@@ -12,32 +11,15 @@ import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
import ArrowOutwardRoundedIcon from '@mui/icons-material/ArrowOutwardRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
-import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
+import HelpAskBox from './HelpAskBox';
+import { FALLBACK_HELP_PROMPT, loadHelpKnowledge } from './helpKnowledge';
+import type { HelpKnowledge } from './helpSearch';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice';
import { addBrowserCard } from '@/shared/state/dashboardLayoutSlice';
import { getLastDashboardId } from '@/shared/lastDashboardId';
import { API_BASE } from '@/shared/config';
-// The Ask chat is a DEDICATED help chat, not a general agent: it knows the app's surfaces and stays
-// in support mode instead of launching into open-ended work.
-const HELP_SYSTEM_PROMPT = [
- "You are OpenSwarm's help assistant, a dedicated support chat inside the app.",
- 'Answer questions about using OpenSwarm clearly and briefly, with the exact clicks or keys.',
- 'What you know about the app:',
- '- Dashboards work like macOS Spaces: resting the cursor on the very top edge of the window reveals the spaces bar to switch dashboards or add one with +.',
- '- The canvas holds cards: agent chats, browsers, built apps, and workflows. Cards have mac-style traffic lights; the green dot goes full screen, hovering it offers halves, quarters, and thirds.',
- '- The dark dock on the left creates chats, browsers, and workflows, and opens History, Settings, and Apps.',
- '- Cmd+K searches everything. Dictation: hold the mic in the Help pill (or the mic key) to talk; the words land where the cursor is.',
- '- Settings (gear in the dock): Account, General (agent defaults), Appearance (theme, accent colors, text size), Privacy, Advanced, plus Models (connect Claude, ChatGPT, or Gemini subscriptions or API keys), Skills, Tools, Commands, and Usage.',
- '- Workflows run agents on a schedule; open them from the dock calendar icon.',
- 'Behavior rules:',
- '- Stay a help chat. Never start unrelated agent work or long tasks from here.',
- '- You may read settings with the settings tools to answer questions about their setup; ask before changing anything.',
- '- If something sounds like a bug, point them to Help then Report a bug, which packages diagnostics automatically.',
- '- If they want real work done, tell them to start a regular chat from the dock or by typing on the canvas.',
-].join('\n');
-
const REPO_ISSUES_URL = 'https://github.com/openswarm-ai/openswarm/issues/new';
const DOCS_URL = 'https://docs.openswarm.com';
const DISCORD_URL = 'https://discord.com/channels/1486442924391796896/1486442927554170892';
@@ -78,17 +60,25 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const [reportText, setReportText] = useState('');
const [files, setFiles] = useState([]);
const [sending, setSending] = useState(false);
+ const [knowledge, setKnowledge] = useState(null);
const fileInputRef = useRef(null);
+ // Fetched on open so the instant answers are ready before the user finishes typing; cached across opens.
+ useEffect(() => {
+ let live = true;
+ void loadHelpKnowledge().then((k) => { if (live) setKnowledge(k); });
+ return () => { live = false; };
+ }, []);
+
const startChat = useCallback((prompt: string): void => {
const p = prompt.trim();
if (!p) return;
const dashboardId = getLastDashboardId() ?? undefined;
- const config: AgentConfig = { name: 'Help', model, mode: 'agent', dashboard_id: dashboardId, system_prompt: HELP_SYSTEM_PROMPT };
+ const config: AgentConfig = { name: 'Help', model, mode: 'agent', dashboard_id: dashboardId, system_prompt: knowledge?.system_prompt || FALLBACK_HELP_PROMPT };
const draftId = dispatch(createDraftSession({ mode: 'agent', model, dashboardId: dashboardId ?? '', setActive: true })).payload.draftId;
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt: p, mode: 'agent', model, expand: true }));
onClose();
- }, [dispatch, model, onClose]);
+ }, [dispatch, model, onClose, knowledge]);
const submitReport = useCallback(async (kind: 'bug' | 'idea'): Promise => {
if (sending) return;
@@ -167,22 +157,7 @@ const HelpPanel: React.FC<{ onClose: () => void }> = ({ onClose }) => {
How can we help?
-
-
- ) => setAsk(e.target.value)}
- onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') startChat(ask); e.stopPropagation(); }}
- placeholder="Ask OpenSwarm anything..."
- sx={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', '&::placeholder': { color: 'rgba(255,255,255,0.4)' } }}
- />
- {ask.trim() && (
- startChat(ask)} sx={{ display: 'flex', border: 'none', background: 'transparent', color: 'rgba(255,255,255,0.8)', cursor: 'pointer', p: 0 }}>
-
-
- )}
-
+ startChat(ask)} />
{ setReportText(''); setFiles([]); setPane('bug'); }} sx={rowSx}>
diff --git a/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts b/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts
new file mode 100644
index 00000000..e43295aa
--- /dev/null
+++ b/frontend/src/app/pages/Dashboard/desktop/helpKnowledge.ts
@@ -0,0 +1,43 @@
+/**
+ * Product knowledge for the Help panel. The backend assembles it from facts that ship with the
+ * build plus live facts about this install, so it can't drift the way a hardcoded prompt does.
+ *
+ * Two consumers, one fetch: the Ask box searches topics locally (instant, no model, works with no
+ * provider connected) and the help chat gets the assembled system prompt.
+ */
+import { API_BASE } from '@/shared/config';
+import type { HelpKnowledge } from './helpSearch';
+
+// If the backend can't answer, the chat still has to know it is a help chat and still has to refuse
+// to guess. Deliberately thin: the real facts live in the build, and claiming them from memory here
+// is exactly the staleness this feature exists to kill.
+export const FALLBACK_HELP_PROMPT = [
+ "You are OpenSwarm's help assistant, a support chat inside the app.",
+ 'Your product knowledge feed is unavailable right now, so you are working without verified facts',
+ 'about this build. Do not guess where things are, do not invent buttons, menus, or shortcuts, and',
+ 'do not claim anything about known bugs.',
+ 'Say plainly what you cannot verify, then point the user at the Help pill (top right) for Docs and',
+ 'shortcuts, Talk to the team, or Report a bug. Keep answers to a couple of sentences.',
+].join('\n');
+
+let cached: HelpKnowledge | null = null;
+let inFlight: Promise | null = null;
+
+export async function loadHelpKnowledge(): Promise {
+ if (cached) return cached;
+ if (inFlight) return inFlight;
+ inFlight = (async () => {
+ try {
+ const res = await fetch(`${API_BASE}/help/knowledge`);
+ if (!res.ok) return null;
+ cached = (await res.json()) as HelpKnowledge;
+ return cached;
+ } catch {
+ return null;
+ } finally {
+ inFlight = null;
+ }
+ })();
+ return inFlight;
+}
+
diff --git a/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts b/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts
new file mode 100644
index 00000000..ca5c9a8c
--- /dev/null
+++ b/frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts
@@ -0,0 +1,86 @@
+/**
+ * Run: node --test frontend/src/app/pages/Dashboard/desktop/helpSearch.test.ts
+ *
+ * The offline answer path. Every case below is a ranking bug caught live in the real panel: a
+ * help answer that is confidently the WRONG surface is worse than no answer, so they stay pinned.
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { searchHelp, type HelpKnowledge } from './helpSearch.ts';
+
+const KNOWLEDGE: HelpKnowledge = {
+ system_prompt: 'x',
+ app_version: '1.6.0',
+ known_issues: [],
+ shortcuts: [
+ { keys: '⌘L', action: 'Open the new-chat composer' },
+ { keys: '⌘⇧D', action: 'Start or stop voice dictation' },
+ { keys: '⌘K', action: 'Search everything, across all dashboards' },
+ { keys: '⌘F', action: 'Find cards on this canvas; inside a browser card it finds text on the page' },
+ { keys: '⌘⇧T', action: 'Reopen the card you just closed' },
+ ],
+ topics: [
+ {
+ id: 'applications',
+ title: 'Applications, your app library',
+ where: 'The grid icon at the very bottom of the left dock.',
+ body: 'Lists every app you have built or imported.',
+ keywords: ['applications', 'apps', 'library', 'grid'],
+ },
+ {
+ id: 'apps',
+ title: 'Building apps',
+ where: 'Ask any agent chat.',
+ body: 'Ask an agent for a tool and it calls CreateApp.',
+ keywords: ['app', 'build', 'create'],
+ },
+ {
+ id: 'workflows',
+ title: 'Workflows and scheduled tasks',
+ where: 'The repeating-calendar icon in the left dock.',
+ body: 'Runs agent steps on a schedule, for example every weekday at 9am.',
+ keywords: ['workflow', 'schedule', 'task', 'recurring'],
+ },
+ {
+ id: 'settings',
+ title: 'Settings',
+ where: 'The gear icon in the left dock.',
+ body: 'Theme, accent color, and text size live under Appearance.',
+ keywords: ['settings', 'theme', 'appearance'],
+ },
+ ],
+};
+
+const top = (q: string): string => searchHelp(KNOWLEDGE, q)[0]?.title ?? '(none)';
+
+test('"where are my apps" leads with the app library, not the app builder', () => {
+ assert.equal(top('where are my apps'), 'Applications, your app library');
+});
+
+test('a shortcut question leads with the right key', () => {
+ assert.equal(top('reopen a closed card'), '⌘⇧T');
+});
+
+test('stopwords never pull in a shortcut: "the" must not match "Open the new-chat composer"', () => {
+ assert.equal(top('change the theme'), 'Settings');
+ assert.ok(!searchHelp(KNOWLEDGE, 'change the theme').some((m) => m.title === '⌘L'));
+});
+
+test('partial words never match: "every" must not hit "Search everything"', () => {
+ assert.equal(top('schedule a task every weekday'), 'Workflows and scheduled tasks');
+ assert.ok(!searchHelp(KNOWLEDGE, 'schedule a task every weekday').some((m) => m.title === '⌘K'));
+});
+
+test('plurals still match their singular keyword', () => {
+ assert.equal(top('cards'), '⌘F');
+});
+
+test('no knowledge, an empty query, or pure stopwords return nothing rather than noise', () => {
+ assert.deepEqual(searchHelp(null, 'apps'), []);
+ assert.deepEqual(searchHelp(KNOWLEDGE, ''), []);
+ assert.deepEqual(searchHelp(KNOWLEDGE, 'how do i'), []);
+});
+
+test('a question we have no answer for returns nothing, never a bad guess', () => {
+ assert.deepEqual(searchHelp(KNOWLEDGE, 'kubernetes ingress'), []);
+});
diff --git a/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts b/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts
new file mode 100644
index 00000000..523770d7
--- /dev/null
+++ b/frontend/src/app/pages/Dashboard/desktop/helpSearch.ts
@@ -0,0 +1,94 @@
+/**
+ * Ranking for the Help panel's offline answers: pure functions over the knowledge payload, no
+ * imports, so the app, the tests, and any future surface all score a query identically.
+ */
+
+export interface HelpTopic {
+ id: string;
+ title: string;
+ where: string;
+ body: string;
+ keywords: string[];
+}
+
+export interface HelpKnownIssue {
+ id: string;
+ title: string;
+ status: 'known' | 'mitigated' | 'fixed';
+ detail: string;
+ workaround?: string | null;
+}
+
+export interface HelpShortcut {
+ keys: string;
+ action: string;
+}
+
+export interface HelpKnowledge {
+ system_prompt: string;
+ topics: HelpTopic[];
+ known_issues: HelpKnownIssue[];
+ shortcuts: HelpShortcut[];
+ app_version: string;
+}
+
+export interface HelpMatch {
+ id: string;
+ title: string;
+ detail: string;
+}
+
+// Words that match everything and so mean nothing; without them "change the theme" hits every entry.
+const STOPWORDS = new Set([
+ 'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'for', 'to', 'is', 'are', 'was', 'be', 'it',
+ 'my', 'me', 'i', 'you', 'your', 'do', 'does', 'did', 'how', 'what', 'where', 'when', 'why',
+ 'can', 'get', 'got', 'with', 'from', 'that', 'this', 'any', 'all', 'use', 'using',
+]);
+
+function stem(word: string): string {
+ return word.length > 3 && word.endsWith('s') ? word.slice(0, -1) : word;
+}
+
+// Whole-word matching on stems, so "card" still finds "cards" but "every" stops matching "everything".
+function wordSet(text: string): Set {
+ const out = new Set();
+ for (const w of text.toLowerCase().split(/[^a-z0-9]+/)) {
+ if (w) out.add(stem(w));
+ }
+ return out;
+}
+
+function hits(words: Set, terms: string[]): number {
+ let n = 0;
+ for (const t of terms) if (words.has(t)) n += 1;
+ return n;
+}
+
+/** Instant local answers: no model, no network, still works when no provider is connected. */
+export function searchHelp(knowledge: HelpKnowledge | null, query: string, limit = 3): HelpMatch[] {
+ if (!knowledge || query.trim().length < 2) return [];
+ const terms = [...wordSet(query)].filter((t) => t.length > 1 && !STOPWORDS.has(t));
+ if (terms.length === 0) return [];
+
+ const scored: Array<{ score: number; match: HelpMatch }> = [];
+ for (const topic of knowledge.topics) {
+ const score =
+ hits(wordSet(topic.title), terms) * 6 +
+ hits(wordSet(topic.keywords.join(' ')), terms) * 4 +
+ hits(wordSet(`${topic.body} ${topic.where}`), terms);
+ if (score > 0) {
+ scored.push({
+ score,
+ match: { id: topic.id, title: topic.title, detail: topic.where ? `${topic.where} ${topic.body}` : topic.body },
+ });
+ }
+ }
+ // Shortcuts rank in the same list rather than jumping the queue, so a key only leads when the
+ // question is actually about that key.
+ for (const s of knowledge.shortcuts) {
+ const score = hits(wordSet(s.action), terms) * 5;
+ if (score > 0) scored.push({ score, match: { id: `shortcut-${s.keys}`, title: s.keys, detail: s.action } });
+ }
+
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.match);
+}