From 1b122c4c4e7bd4ffe27eb6dfec2250070cdb2b38 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 14 Mar 2026 18:43:50 -0600 Subject: [PATCH] Add support app: portal client, CLI, agent, diagnostics, and convey UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full solstone support experience per the approved CPO spec (cpo/specs/in-flight/solstone-support-agent.md): Portal client (portal.py): - Welcome-mat client with RSA-4096 keypair gen/persistence - DPoP proof creation per RFC 9449 - Self-signed access token (wm+jwt) with tos_hash, aud, cnf.jkt - TOS fetching, signing, and local caching - Auto re-consent on TOS changes (401 tos_changed) - Full API: signup, tickets, messages, articles, announcements CLI (call.py — sol call support): - register, search, article, create, list, show, reply, feedback, announcements, diagnose - KB-first flow on create: search KB → present matches → confirm - Auto-populated user_context via diagnostic collector - Consent gate: draft → review → approve → submit Support app (convey): - workspace.html with three sections: tickets, feedback, help - background.html polling for ticket updates + badge - routes.py API endpoints - events.py proactive error detection via callosum Support agent (muse/support.md): - Cogitate agent with consent-gated outbound - Journal content never included by default Diagnostic collector (diagnostics.py): - Version, OS, services, recent errors, config (secrets stripped) Triage + onboarding integration: - Triage hands off support scenarios with handoff to support agent - Onboarding introduces support agent after setup completes SKILL.md for coding agents (sol-support): - Documents all sol call support subcommands - Instructs agents to read local TOS and search KB first Privacy constraints (non-negotiable): - Nothing leaves without explicit user approval - User reviews everything before submission - Journal content never included by default - Anonymous mode strips installation identifiers - Fully toggleable (disabled = local help only) - portal_url configurable for self-hosters Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/support/app.json | 5 + apps/support/background.html | 54 +++ apps/support/call.py | 348 +++++++++++++++ apps/support/diagnostics.py | 193 +++++++++ apps/support/events.py | 89 ++++ apps/support/muse/sol-support/SKILL.md | 155 +++++++ apps/support/muse/support.md | 78 ++++ apps/support/portal.py | 571 +++++++++++++++++++++++++ apps/support/routes.py | 218 ++++++++++ apps/support/tools.py | 150 +++++++ apps/support/workspace.html | 448 +++++++++++++++++++ muse/onboarding.md | 6 + muse/triage.md | 4 + 13 files changed, 2319 insertions(+) create mode 100644 apps/support/app.json create mode 100644 apps/support/background.html create mode 100644 apps/support/call.py create mode 100644 apps/support/diagnostics.py create mode 100644 apps/support/events.py create mode 100644 apps/support/muse/sol-support/SKILL.md create mode 100644 apps/support/muse/support.md create mode 100644 apps/support/portal.py create mode 100644 apps/support/routes.py create mode 100644 apps/support/tools.py create mode 100644 apps/support/workspace.html diff --git a/apps/support/app.json b/apps/support/app.json new file mode 100644 index 000000000..bba8b05f7 --- /dev/null +++ b/apps/support/app.json @@ -0,0 +1,5 @@ +{ + "icon": "🛟", + "label": "Support", + "facets": false +} diff --git a/apps/support/background.html b/apps/support/background.html new file mode 100644 index 000000000..a9955b604 --- /dev/null +++ b/apps/support/background.html @@ -0,0 +1,54 @@ + + diff --git a/apps/support/call.py b/apps/support/call.py new file mode 100644 index 000000000..fb30432fc --- /dev/null +++ b/apps/support/call.py @@ -0,0 +1,348 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""CLI commands for solstone support. + +Auto-discovered by ``think.call`` and mounted as ``sol call support ...``. + +Subcommands provide full access to the support portal: registration, KB search, +ticket management, feedback, announcements, and local diagnostics. +""" + +from __future__ import annotations + +import json + +import typer + +app = typer.Typer(help="Support tools — file tickets, search KB, give feedback.") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _json_out(data: object) -> None: + """Pretty-print JSON to stdout.""" + typer.echo(json.dumps(data, indent=2, default=str)) + + +def _check_enabled() -> None: + """Exit early if support is disabled in settings.""" + from apps.support.portal import is_enabled + + if not is_enabled(): + typer.echo("Support agent is disabled in settings.", err=True) + raise typer.Exit(1) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@app.command("register") +def register() -> None: + """(Re-)register with the support portal.""" + _check_enabled() + from apps.support.portal import get_client + + client = get_client() + result = client.register() + typer.echo(f"Registered as: {result.get('handle', '?')}") + + +@app.command("search") +def search( + query: str = typer.Argument(..., help="Search query for KB articles."), +) -> None: + """Search knowledge base articles.""" + _check_enabled() + from apps.support.tools import support_search + + articles = support_search(query) + if not articles: + typer.echo("No articles found.") + return + + for a in articles: + typer.echo(f" [{a.get('slug', '?')}] {a.get('title', 'Untitled')}") + typer.echo( + f"\n{len(articles)} article(s) found. Use `sol call support article ` to read." + ) + + +@app.command("article") +def article( + slug: str = typer.Argument(..., help="Article slug."), + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Read a KB article.""" + _check_enabled() + from apps.support.tools import support_article + + try: + data = support_article(slug) + except Exception as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + if as_json: + _json_out(data) + else: + typer.echo(f"# {data.get('title', 'Untitled')}\n") + typer.echo(data.get("content", "(no content)")) + + +@app.command("create") +def create( + subject: str = typer.Option(..., "--subject", "-s", help="Ticket subject."), + description: str = typer.Option( + ..., "--description", "-d", help="Ticket description." + ), + product: str = typer.Option("solstone", "--product", "-p", help="Product name."), + severity: str = typer.Option( + "medium", "--severity", help="low, medium, high, critical." + ), + category: str | None = typer.Option( + None, "--category", help="bug, feature, question, account." + ), + skip_kb: bool = typer.Option( + False, "--skip-kb", help="Skip KB search before filing." + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), + anonymous: bool = typer.Option( + False, "--anonymous", help="Strip installation identifiers." + ), +) -> None: + """File a support ticket (KB-first flow with consent gate).""" + _check_enabled() + from apps.support.diagnostics import collect_all + from apps.support.tools import support_create, support_search + + # Step 1: KB-first — search before filing + if not skip_kb: + typer.echo("Searching knowledge base...") + articles = support_search(subject) + if articles: + typer.echo(f"\nFound {len(articles)} related article(s):") + for a in articles: + typer.echo(f" [{a.get('slug', '?')}] {a.get('title', '')}") + typer.echo( + "\nThese may answer your question. " + "Use `sol call support article ` to read." + ) + if not yes: + proceed = typer.confirm("Still want to file a ticket?") + if not proceed: + typer.echo("Cancelled.") + return + + # Step 2: Collect diagnostics + diagnostics = collect_all() + + # Step 3: Present draft for review (consent gate) + typer.echo("\n--- Ticket Draft ---") + typer.echo(f"Subject: {subject}") + typer.echo(f"Product: {product}") + typer.echo(f"Severity: {severity}") + if category: + typer.echo(f"Category: {category}") + typer.echo(f"Description: {description}") + typer.echo(f"\nDiagnostic data ({len(json.dumps(diagnostics))} bytes):") + typer.echo(json.dumps(diagnostics, indent=2, default=str)) + typer.echo("--- End Draft ---\n") + + if not yes: + approved = typer.confirm("Submit this ticket?") + if not approved: + typer.echo("Cancelled — nothing was sent.") + return + + # Step 4: Submit + try: + result = support_create( + subject=subject, + description=description, + product=product, + severity=severity, + category=category, + user_context=diagnostics, + auto_context=False, + anonymous=anonymous, + ) + typer.echo(f"Ticket created: #{result.get('id', '?')}") + except Exception as exc: + typer.echo(f"Error submitting ticket: {exc}", err=True) + raise typer.Exit(1) from None + + +@app.command("list") +def list_tickets( + status: str | None = typer.Option(None, "--status", help="Filter by status."), + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List your support tickets.""" + _check_enabled() + from apps.support.tools import support_list + + tickets = support_list(status=status) + if as_json: + _json_out(tickets) + return + + if not tickets: + typer.echo("No tickets found.") + return + + for t in tickets: + status_str = t.get("status", "?") + typer.echo( + f" #{t.get('id', '?'):>4} [{status_str:<12}] {t.get('subject', 'Untitled')}" + ) + typer.echo(f"\n{len(tickets)} ticket(s).") + + +@app.command("show") +def show( + ticket_id: int = typer.Argument(..., help="Ticket ID."), + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """View a ticket with its message thread.""" + _check_enabled() + from apps.support.tools import support_check + + try: + data = support_check(ticket_id) + except Exception as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + if as_json: + _json_out(data) + return + + typer.echo(f"# Ticket #{data.get('id', '?')}: {data.get('subject', '')}") + typer.echo( + f"Status: {data.get('status', '?')} | Severity: {data.get('severity', '?')}" + ) + typer.echo(f"Created: {data.get('created_at', '?')}") + typer.echo(f"\n{data.get('description', '')}") + + messages = data.get("messages", []) + if messages: + typer.echo(f"\n--- {len(messages)} message(s) ---") + for msg in messages: + handle = msg.get("handle", "?") + typer.echo(f"\n[{handle}] {msg.get('created_at', '')}") + typer.echo(msg.get("content", "")) + + +@app.command("reply") +def reply( + ticket_id: int = typer.Argument(..., help="Ticket ID."), + body: str = typer.Option(..., "--body", "-b", help="Reply content."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), +) -> None: + """Reply to a ticket.""" + _check_enabled() + from apps.support.tools import support_reply + + if not yes: + typer.echo(f"Reply to ticket #{ticket_id}:\n{body}\n") + if not typer.confirm("Send this reply?"): + typer.echo("Cancelled.") + return + + try: + support_reply(ticket_id, body) + typer.echo(f"Reply sent to ticket #{ticket_id}.") + except Exception as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + +@app.command("feedback") +def feedback( + body: str = typer.Option(..., "--body", "-b", help="Your feedback."), + product: str = typer.Option("solstone", "--product", "-p", help="Product name."), + anonymous: bool = typer.Option(False, "--anonymous", help="Submit anonymously."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), +) -> None: + """Submit feedback (lower friction than a full ticket).""" + _check_enabled() + from apps.support.tools import support_feedback + + if not yes: + typer.echo(f"Feedback:\n{body}\n") + anon_note = " (anonymous)" if anonymous else "" + if not typer.confirm(f"Submit this feedback{anon_note}?"): + typer.echo("Cancelled.") + return + + try: + result = support_feedback(body=body, product=product, anonymous=anonymous) + typer.echo(f"Feedback submitted: #{result.get('id', '?')}") + except Exception as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + +@app.command("announcements") +def announcements( + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Check for product updates and known issues.""" + _check_enabled() + from apps.support.tools import support_announcements + + items = support_announcements() + if as_json: + _json_out(items) + return + + if not items: + typer.echo("No active announcements.") + return + + for a in items: + icon = {"known-issue": "⚠️", "maintenance": "🔧"}.get(a.get("type", ""), "📢") + typer.echo(f" {icon} {a.get('title', 'Untitled')}") + if a.get("content"): + typer.echo(f" {a['content'][:120]}") + typer.echo(f"\n{len(items)} announcement(s).") + + +@app.command("diagnose") +def diagnose( + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Run local diagnostics (no network).""" + from apps.support.tools import support_diagnose + + data = support_diagnose() + if as_json: + _json_out(data) + else: + typer.echo("# Local Diagnostics\n") + typer.echo(f"Version: {data.get('version', 'unknown')}") + plat = data.get("platform", {}) + typer.echo( + f"Platform: {plat.get('system', '?')} {plat.get('release', '')} " + f"({plat.get('machine', '')})" + ) + typer.echo(f"Python: {plat.get('python', '?')}") + + services = data.get("services", {}) + if services: + typer.echo("\nServices:") + for name, status in sorted(services.items()): + icon = "✓" if status == "running" else "✗" + typer.echo(f" {icon} {name}: {status}") + + errors = data.get("recent_errors", []) + if errors: + typer.echo(f"\nRecent errors ({len(errors)}):") + for e in errors[:5]: + typer.echo(f" [{e.get('service', '?')}] {e.get('message', '')[:100]}") diff --git a/apps/support/diagnostics.py b/apps/support/diagnostics.py new file mode 100644 index 000000000..4e32b939a --- /dev/null +++ b/apps/support/diagnostics.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Diagnostic collector for support tickets. + +Gathers system state — version, OS, active services, recent errors, and +configuration (secrets stripped) — for the ``user_context`` field on support +tickets. All collection is local; nothing is transmitted. +""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Config keys that must never leave the device. +_SECRET_KEYS = frozenset( + { + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "REVAI_ACCESS_TOKEN", + "PLAUD_ACCESS_TOKEN", + "password", + "secret", + "token", + "key", + } +) + + +def _is_secret_key(key: str) -> bool: + """Return True if *key* looks like it holds sensitive data.""" + lower = key.lower() + return any(s in lower for s in ("key", "token", "secret", "password")) + + +def _strip_secrets(obj: Any) -> Any: + """Recursively redact values whose keys look secret.""" + if isinstance(obj, dict): + return { + k: "***" if _is_secret_key(k) else _strip_secrets(v) for k, v in obj.items() + } + if isinstance(obj, list): + return [_strip_secrets(v) for v in obj] + return obj + + +# -- Individual collectors --------------------------------------------------- + + +def collect_version() -> str | None: + """Return the installed solstone version string.""" + try: + from importlib.metadata import version + + return version("solstone") + except Exception: + return None + + +def collect_platform() -> dict[str, str]: + """Return OS / platform info.""" + return { + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + "python": platform.python_version(), + } + + +def collect_services() -> dict[str, str]: + """Check which solstone services are running. + + Looks at PID files under ``$JOURNAL_PATH/health/``. + """ + from think.utils import get_journal + + journal = get_journal() + health_dir = Path(journal) / "health" + if not health_dir.is_dir(): + return {} + + statuses: dict[str, str] = {} + for pid_file in health_dir.glob("*.pid"): + service = pid_file.stem + try: + pid = int(pid_file.read_text().strip()) + # Check if process is alive + os.kill(pid, 0) + statuses[service] = "running" + except (ValueError, ProcessLookupError, PermissionError): + statuses[service] = "stopped" + except OSError: + statuses[service] = "unknown" + + return statuses + + +def collect_recent_errors(limit: int = 10) -> list[dict[str, Any]]: + """Return the most recent callosum error events from service logs. + + Scans ``$JOURNAL_PATH/health/*.log`` for lines containing ``ERROR``. + """ + from think.utils import get_journal + + journal = get_journal() + health_dir = Path(journal) / "health" + if not health_dir.is_dir(): + return [] + + errors: list[dict[str, Any]] = [] + for log_file in health_dir.glob("*.log"): + try: + lines = log_file.read_text(errors="replace").splitlines() + for line in reversed(lines): + if "ERROR" in line and len(errors) < limit: + errors.append( + { + "service": log_file.stem, + "message": line.strip()[-500:], # cap length + } + ) + except OSError: + continue + + return errors[:limit] + + +def collect_config() -> dict[str, Any]: + """Return journal config with secrets stripped.""" + from think.utils import get_journal + + journal = get_journal() + config_path = Path(journal) / "config" / "config.json" + if not config_path.is_file(): + return {} + + try: + config = json.loads(config_path.read_text()) + return _strip_secrets(config) + except (json.JSONDecodeError, OSError): + return {} + + +# -- Public API -------------------------------------------------------------- + + +def collect_all() -> dict[str, Any]: + """Gather all diagnostics and return as a JSON-serialisable dict. + + This is the value for the ``user_context`` field on support tickets. + The user sees *exactly* this dict before approving submission. + """ + diagnostics: dict[str, Any] = {} + + try: + diagnostics["version"] = collect_version() + except Exception as exc: + logger.debug("version collection failed: %s", exc) + + try: + diagnostics["platform"] = collect_platform() + except Exception as exc: + logger.debug("platform collection failed: %s", exc) + + try: + diagnostics["services"] = collect_services() + except Exception as exc: + logger.debug("service collection failed: %s", exc) + + try: + diagnostics["recent_errors"] = collect_recent_errors() + except Exception as exc: + logger.debug("error collection failed: %s", exc) + + try: + diagnostics["config"] = collect_config() + except Exception as exc: + logger.debug("config collection failed: %s", exc) + + return diagnostics + + +def collect_all_json() -> str: + """Convenience: return :func:`collect_all` as a formatted JSON string.""" + return json.dumps(collect_all(), indent=2, default=str) diff --git a/apps/support/events.py b/apps/support/events.py new file mode 100644 index 000000000..9cbcc55ec --- /dev/null +++ b/apps/support/events.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Callosum event handlers for proactive support detection. + +Listens for repeated errors on the callosum bus and surfaces suggestions +to the user when ``support.proactive`` is enabled. Never sends data +automatically — only alerts the user locally. +""" + +from __future__ import annotations + +import json +import logging +import time +from collections import defaultdict +from pathlib import Path + +from apps.events import EventContext, on_event + +logger = logging.getLogger(__name__) + +# Track error counts per service in memory (resets on convey restart). +_error_counts: dict[str, list[float]] = defaultdict(list) +_WINDOW_SECONDS = 3600 # 1 hour +_THRESHOLD = 3 # notify after 3 errors in the window + + +def _is_proactive_enabled() -> bool: + """Check if proactive detection is enabled in settings.""" + try: + from think.utils import get_journal + + config_path = Path(get_journal()) / "config" / "config.json" + if config_path.is_file(): + config = json.loads(config_path.read_text()) + support = config.get("support", {}) + return support.get("enabled", True) and support.get("proactive", True) + except Exception: + pass + return True + + +@on_event("*", "error") +def detect_repeated_errors(ctx: EventContext) -> None: + """Track error events and notify when a threshold is reached. + + When the same service emits 3+ errors within an hour and proactive + mode is on, fires a notification suggesting the user investigate. + """ + if not _is_proactive_enabled(): + return + + service = ctx.msg.get("service") or ctx.tract or "unknown" + now = time.time() + + # Append and prune old entries + timestamps = _error_counts[service] + timestamps.append(now) + cutoff = now - _WINDOW_SECONDS + _error_counts[service] = [t for t in timestamps if t > cutoff] + + if len(_error_counts[service]) >= _THRESHOLD: + count = len(_error_counts[service]) + logger.info( + "Proactive support: %d errors from %s in the last hour", + count, + service, + ) + + # Fire a notification via callosum so the background service picks it up + try: + from think.callosum import callosum_send + + callosum_send( + "support", + "proactive_suggestion", + service=service, + count=count, + message=( + f"I noticed {service} has had {count} errors in the last hour. " + "Want me to diagnose this?" + ), + ) + except Exception: + logger.debug("Failed to send proactive notification", exc_info=True) + + # Reset counter to avoid spamming + _error_counts[service] = [] diff --git a/apps/support/muse/sol-support/SKILL.md b/apps/support/muse/sol-support/SKILL.md new file mode 100644 index 000000000..d92d4c57f --- /dev/null +++ b/apps/support/muse/sol-support/SKILL.md @@ -0,0 +1,155 @@ +--- +name: sol-support +description: > + File support tickets, search the knowledge base, and give feedback via + the sol support CLI. Use this skill when the user needs help with solstone, + wants to report a bug, request a feature, or submit feedback to sol pbc. + TRIGGER: support tickets, bug reports, feature requests, feedback, help + requests, knowledge base search, system diagnostics. +--- + +# sol support + +CLI for filing support tickets, searching the knowledge base, and submitting feedback to sol pbc. + +## Before You Start + +1. **Read the TOS first.** A local copy is cached at the portal storage directory after first registration. Check `apps/support/portal/tos.txt` in the journal's app storage. If it doesn't exist, run `sol call support register` to fetch and cache it. + +2. **Always search the KB before filing a ticket.** Run `sol call support search "your question"` first. Many common issues are already documented. Only file a ticket if the KB doesn't answer the question. + +3. **Diagnostics are auto-populated.** When creating a ticket, `sol call support create` automatically collects system info (version, OS, services, recent errors). You don't need to gather this manually. + +4. **User consent is required for all outbound operations.** Never use `--yes` without explicit user approval. Always show the user what will be sent and get their OK first. + +## Subcommands + +### Registration + +```bash +sol call support register +``` + +Register (or re-register) with the support portal. Generates an RSA-4096 keypair on first use, signs the TOS, and creates an account. Run this if you get auth errors. + +### Knowledge Base + +```bash +# Search articles +sol call support search "transcription errors" + +# Read a specific article +sol call support article getting-started +``` + +Always search before filing a ticket. Present matching articles to the user. + +### Filing a Ticket + +```bash +sol call support create \ + --subject "Transcription fails on long recordings" \ + --description "Recordings over 2 hours consistently fail with timeout errors. Started after updating to v2.1." \ + --severity medium \ + --category bug +``` + +The `create` command implements a KB-first flow: +1. Searches KB for related articles +2. Shows matches (user can read them and cancel if resolved) +3. Collects diagnostics automatically +4. Shows the full ticket draft for review +5. Submits only after user confirms + +**Flags:** +- `--subject` / `-s` — Ticket subject (required) +- `--description` / `-d` — Detailed description (required) +- `--product` / `-p` — Product name (default: solstone) +- `--severity` — low, medium, high, critical (default: medium) +- `--category` — bug, feature, question, account +- `--skip-kb` — Skip KB search (not recommended) +- `--yes` / `-y` — Skip confirmation (only use with explicit user consent) +- `--anonymous` — Strip installation identifiers + +### Ticket Management + +```bash +# List open tickets +sol call support list + +# List all tickets (including resolved) +sol call support list --status resolved + +# View a ticket with thread +sol call support show 42 + +# Reply to a ticket +sol call support reply 42 --body "Here's the additional info you requested..." + +# JSON output for any command +sol call support list --json +sol call support show 42 --json +``` + +### Feedback + +```bash +sol call support feedback --body "The entity search is great but I wish it could filter by date range" +``` + +Lower friction than a full ticket. Feedback is submitted as a ticket with category "feedback". Supports `--anonymous` flag. + +### Announcements + +```bash +sol call support announcements +``` + +Check for product updates, known issues, and maintenance notices. + +### Local Diagnostics + +```bash +sol call support diagnose +sol call support diagnose --json +``` + +Runs locally — no network, no data sent. Shows: +- solstone version +- OS/platform info +- Active services and their status +- Recent errors from service logs +- Configuration (secrets stripped) + +## Good Ticket Descriptions + +A good ticket includes: +- **What happened** — specific behavior observed +- **What was expected** — what should have happened +- **Steps to reproduce** — how to trigger the issue +- **Context** — when it started, how often, any recent changes + +The diagnostic collector auto-populates version, OS, and service status. You don't need to include these in the description. + +## Examples + +```bash +# User reports a bug — full flow +sol call support search "calendar sync" # check KB first +sol call support create \ + --subject "Calendar events not syncing" \ + --description "Google Calendar events imported yesterday aren't showing up in the calendar app. Tried re-importing but same result." \ + --category bug \ + --severity medium + +# User wants to give feedback +sol call support feedback \ + --body "Love the entity detection but it sometimes misidentifies project names as people" + +# Check for responses on open tickets +sol call support list +sol call support show 15 + +# Quick system health check +sol call support diagnose +``` diff --git a/apps/support/muse/support.md b/apps/support/muse/support.md new file mode 100644 index 000000000..f95b03455 --- /dev/null +++ b/apps/support/muse/support.md @@ -0,0 +1,78 @@ +{ + "type": "cogitate", + "title": "Support", + "description": "Files and monitors support requests with sol pbc — consent-gated, never sends data without explicit user approval", + "color": "#0288d1", + "instructions": {"now": true} +} + +You are solstone's support agent. You help $name get support from sol pbc — filing tickets, checking responses, submitting feedback, and running local diagnostics. You are $preferred's advocate: you work for the user, not for sol pbc. + +## Critical Privacy Rules + +These are non-negotiable: + +1. **NEVER send data without explicit user approval.** Always draft first, present for review, then wait for approval before submitting. +2. **NEVER include journal content by default.** If the user wants to attach a transcript or screenshot, they must explicitly say so. +3. **Always show the user exactly what will be sent** — every field, every diagnostic value. They can edit, redact, or cancel. +4. **If support is disabled in settings, only help locally** — diagnostics, help docs, troubleshooting. No outbound communication. + +## Available Commands + +### Support +- `sol call support search ` — Search KB articles +- `sol call support article ` — Read a KB article +- `sol call support create --subject "..." --description "..." [--severity medium] [--category bug]` — File a ticket (interactive consent flow) +- `sol call support list [--status open]` — List your tickets +- `sol call support show ` — View a ticket with thread +- `sol call support reply --body "..." --yes` — Reply to a ticket (only after user approves the reply text) +- `sol call support feedback --body "..." --yes` — Submit feedback (only after user approves) +- `sol call support announcements` — Check for product updates / known issues +- `sol call support diagnose` — Run local diagnostics (no network) + +### Navigation +- `sol call chat navigate --path /app/support` — Open the support app +- `sol call chat redirect MESSAGE` — Hand off complex requests to the full assistant + +## How to Handle Support Requests + +### When the user needs help or reports a problem: + +1. **Search KB first.** Run `sol call support search` with relevant keywords. If an article answers the question, present it — no ticket needed. + +2. **Run diagnostics.** Run `sol call support diagnose` to gather system state. + +3. **Draft a ticket.** Show the user exactly what you'd send: + - Subject, description, severity, category + - All diagnostic data (version, OS, services, recent errors) + - Ask if they want to add or redact anything + +4. **Wait for approval.** Only submit after the user says yes. Use `--yes` flag only after explicit consent. + +5. **Confirm submission.** Tell the user the ticket number and that you'll monitor for responses. + +### When the user wants to give feedback: + +1. Help them articulate their feedback. +2. Show them the draft. +3. Ask if they want to submit anonymously. +4. Submit only after approval. + +### When checking on existing tickets: + +1. Run `sol call support list` to show open tickets. +2. Use `sol call support show ` for details. +3. If there's a response, present it to the user. +4. If the user wants to reply, draft the reply, show it, and send after approval. + +## Tone + +- Be helpful and empathetic, but efficient. Don't over-explain. +- Frame the support agent as the user's advocate — "I'll handle this for you." +- Be transparent about what data you're collecting and sending. +- If something can be resolved locally (diagnostics, help docs), do that first. + +## When NOT to Engage + +- If the user is asking "how do I use this feature?" — that's a help/documentation question, not support. Point them to help resources or redirect to the full assistant. +- If support is disabled in settings — explain that outbound communication is off and offer local-only help. diff --git a/apps/support/portal.py b/apps/support/portal.py new file mode 100644 index 000000000..9d5b09de9 --- /dev/null +++ b/apps/support/portal.py @@ -0,0 +1,571 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Welcome-mat client for support.solpbc.org. + +Implements the full DPoP + self-signed access token auth flow per the +welcome-mat spec and the extro-support SKILL.md interface contract. + +All cryptographic operations use the ``cryptography`` library (already a +solstone dependency). The keypair, access token, and cached TOS are +persisted in the journal's app storage directory. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import time +import uuid +from pathlib import Path +from typing import Any + +import httpx +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +logger = logging.getLogger(__name__) + +DEFAULT_PORTAL_URL = "https://support.solpbc.org" + +# --------------------------------------------------------------------------- +# Base64url helpers +# --------------------------------------------------------------------------- + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + s += "=" * (4 - len(s) % 4) + return base64.urlsafe_b64decode(s) + + +# --------------------------------------------------------------------------- +# JWT helpers +# --------------------------------------------------------------------------- + + +def _jwt_encode(header: dict, payload: dict, private_key: rsa.RSAPrivateKey) -> str: + """Create a signed JWT (RS256).""" + h = _b64url_encode(json.dumps(header, separators=(",", ":")).encode()) + p = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + signing_input = f"{h}.{p}".encode() + sig = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + return f"{h}.{p}.{_b64url_encode(sig)}" + + +def _sha256_b64url(data: str | bytes) -> str: + if isinstance(data, str): + data = data.encode("utf-8") + return _b64url_encode(hashlib.sha256(data).digest()) + + +# --------------------------------------------------------------------------- +# JWK / Thumbprint +# --------------------------------------------------------------------------- + + +def _public_key_jwk(key: rsa.RSAPublicKey) -> dict: + """Export RSA public key as a JWK dict.""" + numbers = key.public_numbers() + e_bytes = numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") + n_bytes = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") + return { + "kty": "RSA", + "e": _b64url_encode(e_bytes), + "n": _b64url_encode(n_bytes), + } + + +def _jwk_thumbprint(jwk: dict) -> str: + """RFC 7638 JWK thumbprint (SHA-256, base64url).""" + # Canonical JSON: alphabetical keys + canonical = json.dumps( + {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]}, + separators=(",", ":"), + sort_keys=True, + ) + return _sha256_b64url(canonical) + + +# --------------------------------------------------------------------------- +# Portal client +# --------------------------------------------------------------------------- + + +class PortalClient: + """Welcome-mat client for the support portal. + + Parameters + ---------- + portal_url: + Base URL of the portal (no trailing slash). + storage_dir: + Directory for keypair, token cache, and TOS cache. + handle: + Agent handle for registration. Defaults to the machine hostname. + anonymous: + If True, generate a random handle and don't persist the keypair. + """ + + def __init__( + self, + portal_url: str = DEFAULT_PORTAL_URL, + storage_dir: Path | None = None, + handle: str | None = None, + anonymous: bool = False, + ) -> None: + self.portal_url = portal_url.rstrip("/") + self.anonymous = anonymous + self._handle = handle + + if storage_dir is None: + from apps.utils import get_app_storage_path + + storage_dir = get_app_storage_path("support", "portal", ensure_exists=True) + + self.storage_dir = Path(storage_dir) + self.storage_dir.mkdir(parents=True, exist_ok=True) + + self._private_key: rsa.RSAPrivateKey | None = None + self._access_token: str | None = None + self._tos_text: str | None = None + self._jwk: dict | None = None + self._thumbprint: str | None = None + + self._load_state() + + # -- Persistence --------------------------------------------------------- + + @property + def _keypair_path(self) -> Path: + return self.storage_dir / "keypair.pem" + + @property + def _token_path(self) -> Path: + return self.storage_dir / "token.json" + + @property + def _tos_cache_path(self) -> Path: + return self.storage_dir / "tos.txt" + + @property + def handle(self) -> str: + if self._handle: + return self._handle + import socket + + hostname = socket.gethostname().lower().replace("_", "-")[:48] + # Ensure valid handle format + handle = "".join(c for c in hostname if c.isalnum() or c in ".-") + handle = handle.strip(".-") or "solstone" + self._handle = f"solstone-{handle}" + return self._handle + + def _load_state(self) -> None: + """Load persisted keypair and token.""" + if self.anonymous: + return + + if self._keypair_path.is_file(): + pem = self._keypair_path.read_bytes() + self._private_key = serialization.load_pem_private_key(pem, password=None) + pub = self._private_key.public_key() + self._jwk = _public_key_jwk(pub) + self._thumbprint = _jwk_thumbprint(self._jwk) + + if self._token_path.is_file(): + try: + data = json.loads(self._token_path.read_text()) + self._access_token = data.get("access_token") + self._handle = data.get("handle", self._handle) + except (json.JSONDecodeError, OSError): + pass + + if self._tos_cache_path.is_file(): + try: + self._tos_text = self._tos_cache_path.read_text() + except OSError: + pass + + def _save_keypair(self) -> None: + if self.anonymous or self._private_key is None: + return + pem = self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + self._keypair_path.write_bytes(pem) + self._keypair_path.chmod(0o600) + + def _save_token(self) -> None: + if self.anonymous: + return + data = {"access_token": self._access_token, "handle": self._handle} + self._token_path.write_text(json.dumps(data)) + + def _save_tos(self, tos_text: str) -> None: + self._tos_text = tos_text + if not self.anonymous: + self._tos_cache_path.write_text(tos_text) + + # -- Key management ------------------------------------------------------ + + def _ensure_keypair(self) -> None: + """Generate RSA-4096 keypair if we don't have one.""" + if self._private_key is not None: + return + + logger.info("Generating RSA-4096 keypair for support portal registration") + self._private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + pub = self._private_key.public_key() + self._jwk = _public_key_jwk(pub) + self._thumbprint = _jwk_thumbprint(self._jwk) + self._save_keypair() + + # -- DPoP proof creation ------------------------------------------------- + + def _create_dpop_proof( + self, + method: str, + url: str, + access_token: str | None = None, + ) -> str: + """Create a DPoP proof JWT per RFC 9449.""" + assert self._private_key is not None + assert self._jwk is not None + + header = { + "typ": "dpop+jwt", + "alg": "RS256", + "jwk": self._jwk, + } + payload: dict[str, Any] = { + "jti": str(uuid.uuid4()), + "htm": method, + "htu": url.split("?")[0], # strip query/fragment + "iat": int(time.time()), + } + if access_token is not None: + payload["ath"] = _sha256_b64url(access_token) + + return _jwt_encode(header, payload, self._private_key) + + # -- Access token creation ----------------------------------------------- + + def _create_access_token(self, tos_text: str) -> str: + """Create a self-signed wm+jwt access token.""" + assert self._private_key is not None + assert self._thumbprint is not None + + header = {"typ": "wm+jwt", "alg": "RS256"} + payload = { + "jti": str(uuid.uuid4()), + "tos_hash": _sha256_b64url(tos_text), + "aud": self.portal_url, + "cnf": {"jkt": self._thumbprint}, + "iat": int(time.time()), + } + return _jwt_encode(header, payload, self._private_key) + + # -- TOS signing --------------------------------------------------------- + + def _sign_tos(self, tos_text: str) -> str: + """Sign TOS text with RS256 and return base64url signature.""" + assert self._private_key is not None + sig = self._private_key.sign( + tos_text.encode("utf-8"), + padding.PKCS1v15(), + hashes.SHA256(), + ) + return _b64url_encode(sig) + + # -- HTTP helpers -------------------------------------------------------- + + def _http(self) -> httpx.Client: + return httpx.Client(timeout=30.0) + + def _authed_headers(self, method: str, url: str) -> dict[str, str]: + """Return Authorization + DPoP headers for an authenticated request.""" + assert self._access_token is not None + return { + "Authorization": f"DPoP {self._access_token}", + "DPoP": self._create_dpop_proof(method, url, self._access_token), + } + + def _authed_request( + self, + method: str, + path: str, + *, + json_body: dict | None = None, + params: dict | None = None, + retry_on_tos: bool = True, + ) -> httpx.Response: + """Make an authenticated request, handling TOS re-consent.""" + url = f"{self.portal_url}{path}" + headers = self._authed_headers(method, url) + + with self._http() as client: + resp = client.request( + method, url, headers=headers, json=json_body, params=params + ) + + if resp.status_code == 401 and retry_on_tos: + try: + body = resp.json() + except Exception: + body = {} + if body.get("error") == "tos_changed": + logger.info("TOS changed — re-registering") + self.register() + return self._authed_request( + method, path, json_body=json_body, params=params, retry_on_tos=False + ) + + return resp + + # -- Public API ---------------------------------------------------------- + + @property + def is_registered(self) -> bool: + return self._access_token is not None and self._private_key is not None + + @property + def cached_tos(self) -> str | None: + """Return locally cached TOS text, or None if not cached.""" + return self._tos_text + + def fetch_tos(self) -> str: + """Fetch the current TOS from the portal.""" + url = f"{self.portal_url}/tos" + with self._http() as client: + resp = client.get(url, headers={"Accept": "text/plain"}) + resp.raise_for_status() + tos_text = resp.text + self._save_tos(tos_text) + return tos_text + + def register(self) -> dict[str, Any]: + """Run the full welcome-mat registration flow. + + 1. Ensure keypair exists + 2. Fetch TOS + 3. Sign TOS + 4. Create access token + 5. POST /api/signup + """ + self._ensure_keypair() + + tos_text = self.fetch_tos() + tos_signature = self._sign_tos(tos_text) + access_token = self._create_access_token(tos_text) + + url = f"{self.portal_url}/api/signup" + dpop_proof = self._create_dpop_proof("POST", url) + + body = { + "tos_signature": tos_signature, + "access_token": access_token, + "handle": self.handle, + } + + with self._http() as client: + resp = client.post( + url, + headers={"DPoP": dpop_proof, "Content-Type": "application/json"}, + json=body, + ) + + if resp.status_code == 409: + # Handle already taken — append random suffix + import random + import string + + suffix = "".join( + random.choices(string.ascii_lowercase + string.digits, k=4) + ) + self._handle = f"{self.handle}-{suffix}" + return self.register() + + resp.raise_for_status() + data = resp.json() + + self._access_token = data["access_token"] + self._handle = data.get("handle", self._handle) + self._save_token() + + logger.info("Registered with support portal as %s", self._handle) + return data + + def ensure_registered(self) -> None: + """Register if not already registered.""" + if not self.is_registered: + self.register() + + # -- Tickets ------------------------------------------------------------- + + def create_ticket( + self, + *, + product: str = "solstone", + subject: str, + description: str, + severity: str = "medium", + category: str | None = None, + user_email: str | None = None, + user_context: dict | str | None = None, + ) -> dict[str, Any]: + """Create a support ticket.""" + self.ensure_registered() + body: dict[str, Any] = { + "product": product, + "subject": subject, + "description": description, + "severity": severity, + } + if category: + body["category"] = category + if user_email: + body["user_email"] = user_email + if user_context: + body["user_context"] = ( + json.dumps(user_context) + if isinstance(user_context, dict) + else user_context + ) + + resp = self._authed_request("POST", "/api/tickets", json_body=body) + resp.raise_for_status() + return resp.json() + + def list_tickets( + self, + *, + status: str | None = None, + product: str | None = None, + severity: str | None = None, + ) -> list[dict[str, Any]]: + """List tickets (own tickets for user accounts).""" + self.ensure_registered() + params: dict[str, str] = {} + if status: + params["status"] = status + if product: + params["product"] = product + if severity: + params["severity"] = severity + + resp = self._authed_request("GET", "/api/tickets", params=params) + resp.raise_for_status() + return resp.json() + + def get_ticket(self, ticket_id: int) -> dict[str, Any]: + """Get a single ticket with message thread.""" + self.ensure_registered() + resp = self._authed_request("GET", f"/api/tickets/{ticket_id}") + resp.raise_for_status() + return resp.json() + + def reply_to_ticket(self, ticket_id: int, content: str) -> dict[str, Any]: + """Add a message to a ticket.""" + self.ensure_registered() + resp = self._authed_request( + "POST", f"/api/tickets/{ticket_id}/messages", json_body={"content": content} + ) + resp.raise_for_status() + return resp.json() + + # -- Knowledge Base ------------------------------------------------------ + + def search_articles(self, query: str | None = None) -> list[dict[str, Any]]: + """Search published KB articles.""" + self.ensure_registered() + params: dict[str, str] = {} + if query: + params["q"] = query + + resp = self._authed_request("GET", "/api/articles", params=params) + resp.raise_for_status() + return resp.json() + + def get_article(self, slug: str) -> dict[str, Any]: + """Read a single KB article.""" + self.ensure_registered() + resp = self._authed_request("GET", f"/api/articles/{slug}") + resp.raise_for_status() + return resp.json() + + # -- Announcements ------------------------------------------------------- + + def list_announcements(self) -> list[dict[str, Any]]: + """List active announcements.""" + self.ensure_registered() + resp = self._authed_request("GET", "/api/announcements") + resp.raise_for_status() + return resp.json() + + # -- Health -------------------------------------------------------------- + + def health(self) -> dict[str, Any]: + """Check portal health (no auth needed).""" + with self._http() as client: + resp = client.get(f"{self.portal_url}/api/health") + resp.raise_for_status() + return resp.json() + + +# -- Module-level convenience ------------------------------------------------ + + +def get_client( + portal_url: str | None = None, + anonymous: bool = False, +) -> PortalClient: + """Get a portal client using journal settings for configuration. + + Reads ``support.portal_url`` from journal config if *portal_url* is None. + """ + if portal_url is None: + portal_url = _get_portal_url_from_settings() + return PortalClient(portal_url=portal_url, anonymous=anonymous) + + +def _get_portal_url_from_settings() -> str: + """Read portal URL from journal config, falling back to default.""" + try: + from think.utils import get_journal + + config_path = Path(get_journal()) / "config" / "config.json" + if config_path.is_file(): + config = json.loads(config_path.read_text()) + support = config.get("support", {}) + url = support.get("portal_url") + if url: + return url + except Exception: + pass + return DEFAULT_PORTAL_URL + + +def is_enabled() -> bool: + """Check if the support agent is enabled in settings.""" + try: + from think.utils import get_journal + + config_path = Path(get_journal()) / "config" / "config.json" + if config_path.is_file(): + config = json.loads(config_path.read_text()) + support = config.get("support", {}) + return support.get("enabled", True) + except Exception: + pass + return True diff --git a/apps/support/routes.py b/apps/support/routes.py new file mode 100644 index 000000000..26926834b --- /dev/null +++ b/apps/support/routes.py @@ -0,0 +1,218 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Flask routes for the support app. + +Provides API endpoints consumed by workspace.html and the background service. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from flask import Blueprint, jsonify, request + +from convey.utils import error_response + +logger = logging.getLogger(__name__) + +support_bp = Blueprint( + "app:support", + __name__, + url_prefix="/app/support", +) + + +def _get_client(): + """Lazy-import portal client.""" + from apps.support.portal import get_client + + return get_client() + + +def _enabled() -> bool: + from apps.support.portal import is_enabled + + return is_enabled() + + +# -- Tickets ----------------------------------------------------------------- + + +@support_bp.route("/api/tickets", methods=["GET"]) +def list_tickets() -> Any: + """List user's tickets.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + try: + status = request.args.get("status") + client = _get_client() + tickets = client.list_tickets(status=status) + return jsonify(tickets) + except Exception as exc: + logger.exception("Failed to list tickets") + return error_response(str(exc)) + + +@support_bp.route("/api/tickets/", methods=["GET"]) +def get_ticket(ticket_id: int) -> Any: + """Get a single ticket with thread.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + try: + client = _get_client() + ticket = client.get_ticket(ticket_id) + return jsonify(ticket) + except Exception as exc: + logger.exception("Failed to get ticket %d", ticket_id) + return error_response(str(exc)) + + +@support_bp.route("/api/tickets", methods=["POST"]) +def create_ticket() -> Any: + """Create a support ticket.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + payload = request.get_json(force=True) + subject = payload.get("subject") + description = payload.get("description") + + if not subject or not description: + return error_response("subject and description are required") + + try: + from apps.support.tools import support_create + + result = support_create( + subject=subject, + description=description, + product=payload.get("product", "solstone"), + severity=payload.get("severity", "medium"), + category=payload.get("category"), + user_context=payload.get("user_context"), + auto_context=payload.get("auto_context", True), + anonymous=payload.get("anonymous", False), + ) + return jsonify(result), 201 + except Exception as exc: + logger.exception("Failed to create ticket") + return error_response(str(exc)) + + +@support_bp.route("/api/tickets//reply", methods=["POST"]) +def reply_to_ticket(ticket_id: int) -> Any: + """Reply to a ticket.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + payload = request.get_json(force=True) + content = payload.get("content", "") + if not content: + return error_response("content is required") + + try: + client = _get_client() + result = client.reply_to_ticket(ticket_id, content) + return jsonify(result), 201 + except Exception as exc: + logger.exception("Failed to reply to ticket %d", ticket_id) + return error_response(str(exc)) + + +# -- Feedback ---------------------------------------------------------------- + + +@support_bp.route("/api/feedback", methods=["POST"]) +def submit_feedback() -> Any: + """Submit feedback.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + payload = request.get_json(force=True) + body = payload.get("body", "") + if not body: + return error_response("body is required") + + try: + from apps.support.tools import support_feedback + + result = support_feedback( + body=body, + product=payload.get("product", "solstone"), + anonymous=payload.get("anonymous", False), + ) + return jsonify(result), 201 + except Exception as exc: + logger.exception("Failed to submit feedback") + return error_response(str(exc)) + + +# -- KB & Announcements ------------------------------------------------------ + + +@support_bp.route("/api/articles", methods=["GET"]) +def search_articles() -> Any: + """Search KB articles.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + try: + query = request.args.get("q") + client = _get_client() + articles = client.search_articles(query=query) + return jsonify(articles) + except Exception as exc: + logger.exception("Failed to search articles") + return error_response(str(exc)) + + +@support_bp.route("/api/announcements", methods=["GET"]) +def list_announcements() -> Any: + """List active announcements.""" + if not _enabled(): + return error_response("Support is disabled", 403) + + try: + client = _get_client() + items = client.list_announcements() + return jsonify(items) + except Exception as exc: + logger.exception("Failed to list announcements") + return error_response(str(exc)) + + +# -- Diagnostics ------------------------------------------------------------- + + +@support_bp.route("/api/diagnostics", methods=["GET"]) +def diagnostics() -> Any: + """Run local diagnostics.""" + from apps.support.diagnostics import collect_all + + return jsonify(collect_all()) + + +# -- Badge ------------------------------------------------------------------- + + +@support_bp.route("/api/badge-count", methods=["GET"]) +def badge_count() -> Any: + """Return count of tickets with new responses (for app badge).""" + if not _enabled(): + return jsonify({"count": 0}) + + try: + client = _get_client() + tickets = client.list_tickets(status="open") + # Count tickets that have been updated since creation + # (a simple heuristic for "has new response") + count = sum( + 1 for t in tickets if t.get("updated_at", "") > t.get("created_at", "") + ) + return jsonify({"count": count}) + except Exception: + return jsonify({"count": 0}) diff --git a/apps/support/tools.py b/apps/support/tools.py new file mode 100644 index 000000000..ae90df1a0 --- /dev/null +++ b/apps/support/tools.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Support tool functions for agent workflows. + +Each function provides a discrete capability that both the muse agent +(via ``sol call support``) and the convey routes can use. All outbound +operations are **consent-gated** — they return a draft for review rather +than submitting directly. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def support_diagnose() -> dict[str, Any]: + """Run local diagnostics — no network. + + Returns a dict of system state suitable for the ``user_context`` field. + """ + from apps.support.diagnostics import collect_all + + return collect_all() + + +def support_search(query: str, portal_url: str | None = None) -> list[dict[str, Any]]: + """Search the knowledge base for articles matching *query*.""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.search_articles(query=query) + + +def support_article(slug: str, portal_url: str | None = None) -> dict[str, Any]: + """Read a single KB article by slug.""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.get_article(slug) + + +def support_create( + *, + subject: str, + description: str, + product: str = "solstone", + severity: str = "medium", + category: str | None = None, + user_email: str | None = None, + user_context: dict | None = None, + auto_context: bool = True, + portal_url: str | None = None, + anonymous: bool = False, +) -> dict[str, Any]: + """Create a support ticket. + + If *auto_context* is True (default), diagnostic data is collected + and merged into *user_context*. + """ + from apps.support.portal import get_client + + if auto_context: + from apps.support.diagnostics import collect_all + + diag = collect_all() + if user_context: + diag.update(user_context) + user_context = diag + + client = get_client(portal_url=portal_url, anonymous=anonymous) + return client.create_ticket( + product=product, + subject=subject, + description=description, + severity=severity, + category=category, + user_email=user_email, + user_context=user_context, + ) + + +def support_feedback( + *, + body: str, + product: str = "solstone", + portal_url: str | None = None, + anonymous: bool = False, +) -> dict[str, Any]: + """Submit feedback (lower-friction path). + + Feedback is a ticket with ``category="feedback"`` and low severity. + """ + return support_create( + subject="User feedback", + description=body, + product=product, + severity="low", + category="feedback", + portal_url=portal_url, + anonymous=anonymous, + ) + + +def support_list( + *, + status: str | None = None, + portal_url: str | None = None, +) -> list[dict[str, Any]]: + """List the user's tickets.""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.list_tickets(status=status) + + +def support_check( + ticket_id: int, + portal_url: str | None = None, +) -> dict[str, Any]: + """Check status of a specific ticket (with message thread).""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.get_ticket(ticket_id) + + +def support_reply( + ticket_id: int, + content: str, + portal_url: str | None = None, +) -> dict[str, Any]: + """Reply to a ticket.""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.reply_to_ticket(ticket_id, content) + + +def support_announcements( + portal_url: str | None = None, +) -> list[dict[str, Any]]: + """List active announcements.""" + from apps.support.portal import get_client + + client = get_client(portal_url=portal_url) + return client.list_announcements() diff --git a/apps/support/workspace.html b/apps/support/workspace.html new file mode 100644 index 000000000..a00e1b1c2 --- /dev/null +++ b/apps/support/workspace.html @@ -0,0 +1,448 @@ + + +
+ + +
+ + + + + + + +
+
+
+
+ + +
+

Share your impressions, ideas, or anything on your mind. Your feedback shapes the product.

+
+ +
+ +
+ +
+
+
+ + +
+
+

🛟 Getting Help

+

Just say "I need help" or "something's not working" in the chat bar. Your sol will handle everything — searching for answers, running diagnostics, and filing a ticket if needed.

+
+
+

🔍 Search the Knowledge Base

+

Run sol call support search "your question" to find answers in our knowledge base before filing a ticket.

+
+
+

🩺 Run Diagnostics

+

Run sol call support diagnose to check your system health locally — no data is sent anywhere.

+
+
+

📢 Announcements

+

Run sol call support announcements to check for product updates and known issues.

+
+
+

🔒 Privacy

+

Nothing leaves your device without your explicit approval. You review every ticket before it's sent and can edit or redact anything. Journal content is never included unless you attach it yourself.

+
+
+
+
+ + diff --git a/muse/onboarding.md b/muse/onboarding.md index c0d223002..75cc940c6 100644 --- a/muse/onboarding.md +++ b/muse/onboarding.md @@ -110,3 +110,9 @@ Example onboarding flow: 6. Offer imports (see Import Offer above). 7. Run `sol call awareness onboarding --complete`. 8. Summarize what was created — name the specific facets and entities you just set up. Then suggest a concrete first thing to try: pick one of the entities you just attached and say something like "Try asking me 'tell me about [entity name]' to see how I can help." Keep it warm and grounded in what was just created together. + +### Support Agent Introduction + +After completing onboarding (step 8), introduce the support agent: + +> One more thing — if you ever need help, run into an issue, or want to share feedback, just tell me in the chat bar. I'll handle everything with sol pbc for you — filing tickets, tracking responses, the works. You can also open the Support app anytime. Nothing ever gets sent without your review first. diff --git a/muse/triage.md b/muse/triage.md index be4020036..863023a84 100644 --- a/muse/triage.md +++ b/muse/triage.md @@ -45,6 +45,9 @@ You are given context about the user's current app, URL path, and facet. Use thi - `sol call awareness onboarding` — Read onboarding state (path, status, observation count). - `sol call awareness log-read [DAY] [--kind KIND] [--limit N]` — Read awareness log entries. Use `--kind observation` to read observation findings. +### Support +- `sol call chat redirect MESSAGE --muse support:support` — Hand off to the support agent for bug reports, issues, or feedback. + ### Redirect to Chat - `sol call chat redirect MESSAGE --app APP --path PATH --facet FACET` — Create a chat thread with the full assistant and navigate the browser there. Use the user's original message as MESSAGE. Pass the current app, path, and facet from context. @@ -60,6 +63,7 @@ You are given context about the user's current app, URL path, and facet. Use thi 3. Compose a concise briefing: who they are, your relationship, recent interactions, and key context. Proactively offer briefings when context shows an upcoming meeting: "You have a meeting with [person] in [time]. Want me to brief you?" - For complex entity exploration (e.g., "show me my whole network", deep relationship analysis, multi-entity comparisons), redirect to the full chat assistant using `sol call chat redirect`. +- **Support handoff**: When the user reports a problem ("this isn't working", "I found a bug", "something's broken"), wants to file a ticket, or wants to give feedback about the product, hand off to the support agent: `sol call chat redirect "USER'S MESSAGE" --muse support:support`. After redirecting, respond: "I'm connecting you with the support agent..." - Do not attempt to use any commands not listed above. - SOL_DAY and SOL_FACET environment variables are already set — tools will use them as defaults when --day/--facet are omitted. So you can often omit these flags. -- 2.51.2