personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add support app: portal client, CLI, agent, diagnostics, and convey UI

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) <noreply@anthropic.com>

+2319
+5
apps/support/app.json
··· 1 + { 2 + "icon": "🛟", 3 + "label": "Support", 4 + "facets": false 5 + }
+54
apps/support/background.html
··· 1 + <!-- Support background service: polls for ticket updates, manages badges --> 2 + <script> 3 + (function() { 4 + const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes 5 + let pollTimer = null; 6 + 7 + AppServices.register('support', { 8 + start() { 9 + updateBadge(); 10 + pollTimer = setInterval(updateBadge, POLL_INTERVAL); 11 + 12 + // Listen for proactive suggestions from callosum 13 + if (window.appEvents) { 14 + window.appEvents.listen('support', function(msg) { 15 + if (msg.event === 'proactive_suggestion') { 16 + AppServices.notifications.show({ 17 + app: 'support', 18 + icon: '🛟', 19 + title: 'Support suggestion', 20 + message: msg.message || 'Something may need attention.', 21 + action: '/app/support', 22 + dismissible: true, 23 + autoDismiss: 30000 24 + }); 25 + } 26 + }); 27 + } 28 + }, 29 + 30 + stop() { 31 + if (pollTimer) { 32 + clearInterval(pollTimer); 33 + pollTimer = null; 34 + } 35 + } 36 + }); 37 + 38 + async function updateBadge() { 39 + try { 40 + const resp = await fetch('/app/support/api/badge-count'); 41 + if (!resp.ok) return; 42 + const data = await resp.json(); 43 + const count = data.count || 0; 44 + if (count > 0) { 45 + AppServices.badges.app.set('support', count); 46 + } else { 47 + AppServices.badges.app.clear('support'); 48 + } 49 + } catch (e) { 50 + // Silently ignore — support may be disabled or portal unreachable 51 + } 52 + } 53 + })(); 54 + </script>
+348
apps/support/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for solstone support. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call support ...``. 7 + 8 + Subcommands provide full access to the support portal: registration, KB search, 9 + ticket management, feedback, announcements, and local diagnostics. 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import json 15 + 16 + import typer 17 + 18 + app = typer.Typer(help="Support tools — file tickets, search KB, give feedback.") 19 + 20 + 21 + # --------------------------------------------------------------------------- 22 + # Helpers 23 + # --------------------------------------------------------------------------- 24 + 25 + 26 + def _json_out(data: object) -> None: 27 + """Pretty-print JSON to stdout.""" 28 + typer.echo(json.dumps(data, indent=2, default=str)) 29 + 30 + 31 + def _check_enabled() -> None: 32 + """Exit early if support is disabled in settings.""" 33 + from apps.support.portal import is_enabled 34 + 35 + if not is_enabled(): 36 + typer.echo("Support agent is disabled in settings.", err=True) 37 + raise typer.Exit(1) 38 + 39 + 40 + # --------------------------------------------------------------------------- 41 + # Commands 42 + # --------------------------------------------------------------------------- 43 + 44 + 45 + @app.command("register") 46 + def register() -> None: 47 + """(Re-)register with the support portal.""" 48 + _check_enabled() 49 + from apps.support.portal import get_client 50 + 51 + client = get_client() 52 + result = client.register() 53 + typer.echo(f"Registered as: {result.get('handle', '?')}") 54 + 55 + 56 + @app.command("search") 57 + def search( 58 + query: str = typer.Argument(..., help="Search query for KB articles."), 59 + ) -> None: 60 + """Search knowledge base articles.""" 61 + _check_enabled() 62 + from apps.support.tools import support_search 63 + 64 + articles = support_search(query) 65 + if not articles: 66 + typer.echo("No articles found.") 67 + return 68 + 69 + for a in articles: 70 + typer.echo(f" [{a.get('slug', '?')}] {a.get('title', 'Untitled')}") 71 + typer.echo( 72 + f"\n{len(articles)} article(s) found. Use `sol call support article <slug>` to read." 73 + ) 74 + 75 + 76 + @app.command("article") 77 + def article( 78 + slug: str = typer.Argument(..., help="Article slug."), 79 + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 80 + ) -> None: 81 + """Read a KB article.""" 82 + _check_enabled() 83 + from apps.support.tools import support_article 84 + 85 + try: 86 + data = support_article(slug) 87 + except Exception as exc: 88 + typer.echo(f"Error: {exc}", err=True) 89 + raise typer.Exit(1) from None 90 + 91 + if as_json: 92 + _json_out(data) 93 + else: 94 + typer.echo(f"# {data.get('title', 'Untitled')}\n") 95 + typer.echo(data.get("content", "(no content)")) 96 + 97 + 98 + @app.command("create") 99 + def create( 100 + subject: str = typer.Option(..., "--subject", "-s", help="Ticket subject."), 101 + description: str = typer.Option( 102 + ..., "--description", "-d", help="Ticket description." 103 + ), 104 + product: str = typer.Option("solstone", "--product", "-p", help="Product name."), 105 + severity: str = typer.Option( 106 + "medium", "--severity", help="low, medium, high, critical." 107 + ), 108 + category: str | None = typer.Option( 109 + None, "--category", help="bug, feature, question, account." 110 + ), 111 + skip_kb: bool = typer.Option( 112 + False, "--skip-kb", help="Skip KB search before filing." 113 + ), 114 + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."), 115 + anonymous: bool = typer.Option( 116 + False, "--anonymous", help="Strip installation identifiers." 117 + ), 118 + ) -> None: 119 + """File a support ticket (KB-first flow with consent gate).""" 120 + _check_enabled() 121 + from apps.support.diagnostics import collect_all 122 + from apps.support.tools import support_create, support_search 123 + 124 + # Step 1: KB-first — search before filing 125 + if not skip_kb: 126 + typer.echo("Searching knowledge base...") 127 + articles = support_search(subject) 128 + if articles: 129 + typer.echo(f"\nFound {len(articles)} related article(s):") 130 + for a in articles: 131 + typer.echo(f" [{a.get('slug', '?')}] {a.get('title', '')}") 132 + typer.echo( 133 + "\nThese may answer your question. " 134 + "Use `sol call support article <slug>` to read." 135 + ) 136 + if not yes: 137 + proceed = typer.confirm("Still want to file a ticket?") 138 + if not proceed: 139 + typer.echo("Cancelled.") 140 + return 141 + 142 + # Step 2: Collect diagnostics 143 + diagnostics = collect_all() 144 + 145 + # Step 3: Present draft for review (consent gate) 146 + typer.echo("\n--- Ticket Draft ---") 147 + typer.echo(f"Subject: {subject}") 148 + typer.echo(f"Product: {product}") 149 + typer.echo(f"Severity: {severity}") 150 + if category: 151 + typer.echo(f"Category: {category}") 152 + typer.echo(f"Description: {description}") 153 + typer.echo(f"\nDiagnostic data ({len(json.dumps(diagnostics))} bytes):") 154 + typer.echo(json.dumps(diagnostics, indent=2, default=str)) 155 + typer.echo("--- End Draft ---\n") 156 + 157 + if not yes: 158 + approved = typer.confirm("Submit this ticket?") 159 + if not approved: 160 + typer.echo("Cancelled — nothing was sent.") 161 + return 162 + 163 + # Step 4: Submit 164 + try: 165 + result = support_create( 166 + subject=subject, 167 + description=description, 168 + product=product, 169 + severity=severity, 170 + category=category, 171 + user_context=diagnostics, 172 + auto_context=False, 173 + anonymous=anonymous, 174 + ) 175 + typer.echo(f"Ticket created: #{result.get('id', '?')}") 176 + except Exception as exc: 177 + typer.echo(f"Error submitting ticket: {exc}", err=True) 178 + raise typer.Exit(1) from None 179 + 180 + 181 + @app.command("list") 182 + def list_tickets( 183 + status: str | None = typer.Option(None, "--status", help="Filter by status."), 184 + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 185 + ) -> None: 186 + """List your support tickets.""" 187 + _check_enabled() 188 + from apps.support.tools import support_list 189 + 190 + tickets = support_list(status=status) 191 + if as_json: 192 + _json_out(tickets) 193 + return 194 + 195 + if not tickets: 196 + typer.echo("No tickets found.") 197 + return 198 + 199 + for t in tickets: 200 + status_str = t.get("status", "?") 201 + typer.echo( 202 + f" #{t.get('id', '?'):>4} [{status_str:<12}] {t.get('subject', 'Untitled')}" 203 + ) 204 + typer.echo(f"\n{len(tickets)} ticket(s).") 205 + 206 + 207 + @app.command("show") 208 + def show( 209 + ticket_id: int = typer.Argument(..., help="Ticket ID."), 210 + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 211 + ) -> None: 212 + """View a ticket with its message thread.""" 213 + _check_enabled() 214 + from apps.support.tools import support_check 215 + 216 + try: 217 + data = support_check(ticket_id) 218 + except Exception as exc: 219 + typer.echo(f"Error: {exc}", err=True) 220 + raise typer.Exit(1) from None 221 + 222 + if as_json: 223 + _json_out(data) 224 + return 225 + 226 + typer.echo(f"# Ticket #{data.get('id', '?')}: {data.get('subject', '')}") 227 + typer.echo( 228 + f"Status: {data.get('status', '?')} | Severity: {data.get('severity', '?')}" 229 + ) 230 + typer.echo(f"Created: {data.get('created_at', '?')}") 231 + typer.echo(f"\n{data.get('description', '')}") 232 + 233 + messages = data.get("messages", []) 234 + if messages: 235 + typer.echo(f"\n--- {len(messages)} message(s) ---") 236 + for msg in messages: 237 + handle = msg.get("handle", "?") 238 + typer.echo(f"\n[{handle}] {msg.get('created_at', '')}") 239 + typer.echo(msg.get("content", "")) 240 + 241 + 242 + @app.command("reply") 243 + def reply( 244 + ticket_id: int = typer.Argument(..., help="Ticket ID."), 245 + body: str = typer.Option(..., "--body", "-b", help="Reply content."), 246 + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 247 + ) -> None: 248 + """Reply to a ticket.""" 249 + _check_enabled() 250 + from apps.support.tools import support_reply 251 + 252 + if not yes: 253 + typer.echo(f"Reply to ticket #{ticket_id}:\n{body}\n") 254 + if not typer.confirm("Send this reply?"): 255 + typer.echo("Cancelled.") 256 + return 257 + 258 + try: 259 + support_reply(ticket_id, body) 260 + typer.echo(f"Reply sent to ticket #{ticket_id}.") 261 + except Exception as exc: 262 + typer.echo(f"Error: {exc}", err=True) 263 + raise typer.Exit(1) from None 264 + 265 + 266 + @app.command("feedback") 267 + def feedback( 268 + body: str = typer.Option(..., "--body", "-b", help="Your feedback."), 269 + product: str = typer.Option("solstone", "--product", "-p", help="Product name."), 270 + anonymous: bool = typer.Option(False, "--anonymous", help="Submit anonymously."), 271 + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 272 + ) -> None: 273 + """Submit feedback (lower friction than a full ticket).""" 274 + _check_enabled() 275 + from apps.support.tools import support_feedback 276 + 277 + if not yes: 278 + typer.echo(f"Feedback:\n{body}\n") 279 + anon_note = " (anonymous)" if anonymous else "" 280 + if not typer.confirm(f"Submit this feedback{anon_note}?"): 281 + typer.echo("Cancelled.") 282 + return 283 + 284 + try: 285 + result = support_feedback(body=body, product=product, anonymous=anonymous) 286 + typer.echo(f"Feedback submitted: #{result.get('id', '?')}") 287 + except Exception as exc: 288 + typer.echo(f"Error: {exc}", err=True) 289 + raise typer.Exit(1) from None 290 + 291 + 292 + @app.command("announcements") 293 + def announcements( 294 + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 295 + ) -> None: 296 + """Check for product updates and known issues.""" 297 + _check_enabled() 298 + from apps.support.tools import support_announcements 299 + 300 + items = support_announcements() 301 + if as_json: 302 + _json_out(items) 303 + return 304 + 305 + if not items: 306 + typer.echo("No active announcements.") 307 + return 308 + 309 + for a in items: 310 + icon = {"known-issue": "⚠️", "maintenance": "🔧"}.get(a.get("type", ""), "📢") 311 + typer.echo(f" {icon} {a.get('title', 'Untitled')}") 312 + if a.get("content"): 313 + typer.echo(f" {a['content'][:120]}") 314 + typer.echo(f"\n{len(items)} announcement(s).") 315 + 316 + 317 + @app.command("diagnose") 318 + def diagnose( 319 + as_json: bool = typer.Option(False, "--json", help="Output raw JSON."), 320 + ) -> None: 321 + """Run local diagnostics (no network).""" 322 + from apps.support.tools import support_diagnose 323 + 324 + data = support_diagnose() 325 + if as_json: 326 + _json_out(data) 327 + else: 328 + typer.echo("# Local Diagnostics\n") 329 + typer.echo(f"Version: {data.get('version', 'unknown')}") 330 + plat = data.get("platform", {}) 331 + typer.echo( 332 + f"Platform: {plat.get('system', '?')} {plat.get('release', '')} " 333 + f"({plat.get('machine', '')})" 334 + ) 335 + typer.echo(f"Python: {plat.get('python', '?')}") 336 + 337 + services = data.get("services", {}) 338 + if services: 339 + typer.echo("\nServices:") 340 + for name, status in sorted(services.items()): 341 + icon = "✓" if status == "running" else "✗" 342 + typer.echo(f" {icon} {name}: {status}") 343 + 344 + errors = data.get("recent_errors", []) 345 + if errors: 346 + typer.echo(f"\nRecent errors ({len(errors)}):") 347 + for e in errors[:5]: 348 + typer.echo(f" [{e.get('service', '?')}] {e.get('message', '')[:100]}")
+193
apps/support/diagnostics.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Diagnostic collector for support tickets. 5 + 6 + Gathers system state — version, OS, active services, recent errors, and 7 + configuration (secrets stripped) — for the ``user_context`` field on support 8 + tickets. All collection is local; nothing is transmitted. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import json 14 + import logging 15 + import os 16 + import platform 17 + from pathlib import Path 18 + from typing import Any 19 + 20 + logger = logging.getLogger(__name__) 21 + 22 + # Config keys that must never leave the device. 23 + _SECRET_KEYS = frozenset( 24 + { 25 + "ANTHROPIC_API_KEY", 26 + "OPENAI_API_KEY", 27 + "GOOGLE_API_KEY", 28 + "REVAI_ACCESS_TOKEN", 29 + "PLAUD_ACCESS_TOKEN", 30 + "password", 31 + "secret", 32 + "token", 33 + "key", 34 + } 35 + ) 36 + 37 + 38 + def _is_secret_key(key: str) -> bool: 39 + """Return True if *key* looks like it holds sensitive data.""" 40 + lower = key.lower() 41 + return any(s in lower for s in ("key", "token", "secret", "password")) 42 + 43 + 44 + def _strip_secrets(obj: Any) -> Any: 45 + """Recursively redact values whose keys look secret.""" 46 + if isinstance(obj, dict): 47 + return { 48 + k: "***" if _is_secret_key(k) else _strip_secrets(v) for k, v in obj.items() 49 + } 50 + if isinstance(obj, list): 51 + return [_strip_secrets(v) for v in obj] 52 + return obj 53 + 54 + 55 + # -- Individual collectors --------------------------------------------------- 56 + 57 + 58 + def collect_version() -> str | None: 59 + """Return the installed solstone version string.""" 60 + try: 61 + from importlib.metadata import version 62 + 63 + return version("solstone") 64 + except Exception: 65 + return None 66 + 67 + 68 + def collect_platform() -> dict[str, str]: 69 + """Return OS / platform info.""" 70 + return { 71 + "system": platform.system(), 72 + "release": platform.release(), 73 + "machine": platform.machine(), 74 + "python": platform.python_version(), 75 + } 76 + 77 + 78 + def collect_services() -> dict[str, str]: 79 + """Check which solstone services are running. 80 + 81 + Looks at PID files under ``$JOURNAL_PATH/health/``. 82 + """ 83 + from think.utils import get_journal 84 + 85 + journal = get_journal() 86 + health_dir = Path(journal) / "health" 87 + if not health_dir.is_dir(): 88 + return {} 89 + 90 + statuses: dict[str, str] = {} 91 + for pid_file in health_dir.glob("*.pid"): 92 + service = pid_file.stem 93 + try: 94 + pid = int(pid_file.read_text().strip()) 95 + # Check if process is alive 96 + os.kill(pid, 0) 97 + statuses[service] = "running" 98 + except (ValueError, ProcessLookupError, PermissionError): 99 + statuses[service] = "stopped" 100 + except OSError: 101 + statuses[service] = "unknown" 102 + 103 + return statuses 104 + 105 + 106 + def collect_recent_errors(limit: int = 10) -> list[dict[str, Any]]: 107 + """Return the most recent callosum error events from service logs. 108 + 109 + Scans ``$JOURNAL_PATH/health/*.log`` for lines containing ``ERROR``. 110 + """ 111 + from think.utils import get_journal 112 + 113 + journal = get_journal() 114 + health_dir = Path(journal) / "health" 115 + if not health_dir.is_dir(): 116 + return [] 117 + 118 + errors: list[dict[str, Any]] = [] 119 + for log_file in health_dir.glob("*.log"): 120 + try: 121 + lines = log_file.read_text(errors="replace").splitlines() 122 + for line in reversed(lines): 123 + if "ERROR" in line and len(errors) < limit: 124 + errors.append( 125 + { 126 + "service": log_file.stem, 127 + "message": line.strip()[-500:], # cap length 128 + } 129 + ) 130 + except OSError: 131 + continue 132 + 133 + return errors[:limit] 134 + 135 + 136 + def collect_config() -> dict[str, Any]: 137 + """Return journal config with secrets stripped.""" 138 + from think.utils import get_journal 139 + 140 + journal = get_journal() 141 + config_path = Path(journal) / "config" / "config.json" 142 + if not config_path.is_file(): 143 + return {} 144 + 145 + try: 146 + config = json.loads(config_path.read_text()) 147 + return _strip_secrets(config) 148 + except (json.JSONDecodeError, OSError): 149 + return {} 150 + 151 + 152 + # -- Public API -------------------------------------------------------------- 153 + 154 + 155 + def collect_all() -> dict[str, Any]: 156 + """Gather all diagnostics and return as a JSON-serialisable dict. 157 + 158 + This is the value for the ``user_context`` field on support tickets. 159 + The user sees *exactly* this dict before approving submission. 160 + """ 161 + diagnostics: dict[str, Any] = {} 162 + 163 + try: 164 + diagnostics["version"] = collect_version() 165 + except Exception as exc: 166 + logger.debug("version collection failed: %s", exc) 167 + 168 + try: 169 + diagnostics["platform"] = collect_platform() 170 + except Exception as exc: 171 + logger.debug("platform collection failed: %s", exc) 172 + 173 + try: 174 + diagnostics["services"] = collect_services() 175 + except Exception as exc: 176 + logger.debug("service collection failed: %s", exc) 177 + 178 + try: 179 + diagnostics["recent_errors"] = collect_recent_errors() 180 + except Exception as exc: 181 + logger.debug("error collection failed: %s", exc) 182 + 183 + try: 184 + diagnostics["config"] = collect_config() 185 + except Exception as exc: 186 + logger.debug("config collection failed: %s", exc) 187 + 188 + return diagnostics 189 + 190 + 191 + def collect_all_json() -> str: 192 + """Convenience: return :func:`collect_all` as a formatted JSON string.""" 193 + return json.dumps(collect_all(), indent=2, default=str)
+89
apps/support/events.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Callosum event handlers for proactive support detection. 5 + 6 + Listens for repeated errors on the callosum bus and surfaces suggestions 7 + to the user when ``support.proactive`` is enabled. Never sends data 8 + automatically — only alerts the user locally. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import json 14 + import logging 15 + import time 16 + from collections import defaultdict 17 + from pathlib import Path 18 + 19 + from apps.events import EventContext, on_event 20 + 21 + logger = logging.getLogger(__name__) 22 + 23 + # Track error counts per service in memory (resets on convey restart). 24 + _error_counts: dict[str, list[float]] = defaultdict(list) 25 + _WINDOW_SECONDS = 3600 # 1 hour 26 + _THRESHOLD = 3 # notify after 3 errors in the window 27 + 28 + 29 + def _is_proactive_enabled() -> bool: 30 + """Check if proactive detection is enabled in settings.""" 31 + try: 32 + from think.utils import get_journal 33 + 34 + config_path = Path(get_journal()) / "config" / "config.json" 35 + if config_path.is_file(): 36 + config = json.loads(config_path.read_text()) 37 + support = config.get("support", {}) 38 + return support.get("enabled", True) and support.get("proactive", True) 39 + except Exception: 40 + pass 41 + return True 42 + 43 + 44 + @on_event("*", "error") 45 + def detect_repeated_errors(ctx: EventContext) -> None: 46 + """Track error events and notify when a threshold is reached. 47 + 48 + When the same service emits 3+ errors within an hour and proactive 49 + mode is on, fires a notification suggesting the user investigate. 50 + """ 51 + if not _is_proactive_enabled(): 52 + return 53 + 54 + service = ctx.msg.get("service") or ctx.tract or "unknown" 55 + now = time.time() 56 + 57 + # Append and prune old entries 58 + timestamps = _error_counts[service] 59 + timestamps.append(now) 60 + cutoff = now - _WINDOW_SECONDS 61 + _error_counts[service] = [t for t in timestamps if t > cutoff] 62 + 63 + if len(_error_counts[service]) >= _THRESHOLD: 64 + count = len(_error_counts[service]) 65 + logger.info( 66 + "Proactive support: %d errors from %s in the last hour", 67 + count, 68 + service, 69 + ) 70 + 71 + # Fire a notification via callosum so the background service picks it up 72 + try: 73 + from think.callosum import callosum_send 74 + 75 + callosum_send( 76 + "support", 77 + "proactive_suggestion", 78 + service=service, 79 + count=count, 80 + message=( 81 + f"I noticed {service} has had {count} errors in the last hour. " 82 + "Want me to diagnose this?" 83 + ), 84 + ) 85 + except Exception: 86 + logger.debug("Failed to send proactive notification", exc_info=True) 87 + 88 + # Reset counter to avoid spamming 89 + _error_counts[service] = []
+155
apps/support/muse/sol-support/SKILL.md
··· 1 + --- 2 + name: sol-support 3 + description: > 4 + File support tickets, search the knowledge base, and give feedback via 5 + the sol support CLI. Use this skill when the user needs help with solstone, 6 + wants to report a bug, request a feature, or submit feedback to sol pbc. 7 + TRIGGER: support tickets, bug reports, feature requests, feedback, help 8 + requests, knowledge base search, system diagnostics. 9 + --- 10 + 11 + # sol support 12 + 13 + CLI for filing support tickets, searching the knowledge base, and submitting feedback to sol pbc. 14 + 15 + ## Before You Start 16 + 17 + 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. 18 + 19 + 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. 20 + 21 + 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. 22 + 23 + 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. 24 + 25 + ## Subcommands 26 + 27 + ### Registration 28 + 29 + ```bash 30 + sol call support register 31 + ``` 32 + 33 + 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. 34 + 35 + ### Knowledge Base 36 + 37 + ```bash 38 + # Search articles 39 + sol call support search "transcription errors" 40 + 41 + # Read a specific article 42 + sol call support article getting-started 43 + ``` 44 + 45 + Always search before filing a ticket. Present matching articles to the user. 46 + 47 + ### Filing a Ticket 48 + 49 + ```bash 50 + sol call support create \ 51 + --subject "Transcription fails on long recordings" \ 52 + --description "Recordings over 2 hours consistently fail with timeout errors. Started after updating to v2.1." \ 53 + --severity medium \ 54 + --category bug 55 + ``` 56 + 57 + The `create` command implements a KB-first flow: 58 + 1. Searches KB for related articles 59 + 2. Shows matches (user can read them and cancel if resolved) 60 + 3. Collects diagnostics automatically 61 + 4. Shows the full ticket draft for review 62 + 5. Submits only after user confirms 63 + 64 + **Flags:** 65 + - `--subject` / `-s` — Ticket subject (required) 66 + - `--description` / `-d` — Detailed description (required) 67 + - `--product` / `-p` — Product name (default: solstone) 68 + - `--severity` — low, medium, high, critical (default: medium) 69 + - `--category` — bug, feature, question, account 70 + - `--skip-kb` — Skip KB search (not recommended) 71 + - `--yes` / `-y` — Skip confirmation (only use with explicit user consent) 72 + - `--anonymous` — Strip installation identifiers 73 + 74 + ### Ticket Management 75 + 76 + ```bash 77 + # List open tickets 78 + sol call support list 79 + 80 + # List all tickets (including resolved) 81 + sol call support list --status resolved 82 + 83 + # View a ticket with thread 84 + sol call support show 42 85 + 86 + # Reply to a ticket 87 + sol call support reply 42 --body "Here's the additional info you requested..." 88 + 89 + # JSON output for any command 90 + sol call support list --json 91 + sol call support show 42 --json 92 + ``` 93 + 94 + ### Feedback 95 + 96 + ```bash 97 + sol call support feedback --body "The entity search is great but I wish it could filter by date range" 98 + ``` 99 + 100 + Lower friction than a full ticket. Feedback is submitted as a ticket with category "feedback". Supports `--anonymous` flag. 101 + 102 + ### Announcements 103 + 104 + ```bash 105 + sol call support announcements 106 + ``` 107 + 108 + Check for product updates, known issues, and maintenance notices. 109 + 110 + ### Local Diagnostics 111 + 112 + ```bash 113 + sol call support diagnose 114 + sol call support diagnose --json 115 + ``` 116 + 117 + Runs locally — no network, no data sent. Shows: 118 + - solstone version 119 + - OS/platform info 120 + - Active services and their status 121 + - Recent errors from service logs 122 + - Configuration (secrets stripped) 123 + 124 + ## Good Ticket Descriptions 125 + 126 + A good ticket includes: 127 + - **What happened** — specific behavior observed 128 + - **What was expected** — what should have happened 129 + - **Steps to reproduce** — how to trigger the issue 130 + - **Context** — when it started, how often, any recent changes 131 + 132 + The diagnostic collector auto-populates version, OS, and service status. You don't need to include these in the description. 133 + 134 + ## Examples 135 + 136 + ```bash 137 + # User reports a bug — full flow 138 + sol call support search "calendar sync" # check KB first 139 + sol call support create \ 140 + --subject "Calendar events not syncing" \ 141 + --description "Google Calendar events imported yesterday aren't showing up in the calendar app. Tried re-importing but same result." \ 142 + --category bug \ 143 + --severity medium 144 + 145 + # User wants to give feedback 146 + sol call support feedback \ 147 + --body "Love the entity detection but it sometimes misidentifies project names as people" 148 + 149 + # Check for responses on open tickets 150 + sol call support list 151 + sol call support show 15 152 + 153 + # Quick system health check 154 + sol call support diagnose 155 + ```
+78
apps/support/muse/support.md
··· 1 + { 2 + "type": "cogitate", 3 + "title": "Support", 4 + "description": "Files and monitors support requests with sol pbc — consent-gated, never sends data without explicit user approval", 5 + "color": "#0288d1", 6 + "instructions": {"now": true} 7 + } 8 + 9 + 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. 10 + 11 + ## Critical Privacy Rules 12 + 13 + These are non-negotiable: 14 + 15 + 1. **NEVER send data without explicit user approval.** Always draft first, present for review, then wait for approval before submitting. 16 + 2. **NEVER include journal content by default.** If the user wants to attach a transcript or screenshot, they must explicitly say so. 17 + 3. **Always show the user exactly what will be sent** — every field, every diagnostic value. They can edit, redact, or cancel. 18 + 4. **If support is disabled in settings, only help locally** — diagnostics, help docs, troubleshooting. No outbound communication. 19 + 20 + ## Available Commands 21 + 22 + ### Support 23 + - `sol call support search <query>` — Search KB articles 24 + - `sol call support article <slug>` — Read a KB article 25 + - `sol call support create --subject "..." --description "..." [--severity medium] [--category bug]` — File a ticket (interactive consent flow) 26 + - `sol call support list [--status open]` — List your tickets 27 + - `sol call support show <id>` — View a ticket with thread 28 + - `sol call support reply <id> --body "..." --yes` — Reply to a ticket (only after user approves the reply text) 29 + - `sol call support feedback --body "..." --yes` — Submit feedback (only after user approves) 30 + - `sol call support announcements` — Check for product updates / known issues 31 + - `sol call support diagnose` — Run local diagnostics (no network) 32 + 33 + ### Navigation 34 + - `sol call chat navigate --path /app/support` — Open the support app 35 + - `sol call chat redirect MESSAGE` — Hand off complex requests to the full assistant 36 + 37 + ## How to Handle Support Requests 38 + 39 + ### When the user needs help or reports a problem: 40 + 41 + 1. **Search KB first.** Run `sol call support search` with relevant keywords. If an article answers the question, present it — no ticket needed. 42 + 43 + 2. **Run diagnostics.** Run `sol call support diagnose` to gather system state. 44 + 45 + 3. **Draft a ticket.** Show the user exactly what you'd send: 46 + - Subject, description, severity, category 47 + - All diagnostic data (version, OS, services, recent errors) 48 + - Ask if they want to add or redact anything 49 + 50 + 4. **Wait for approval.** Only submit after the user says yes. Use `--yes` flag only after explicit consent. 51 + 52 + 5. **Confirm submission.** Tell the user the ticket number and that you'll monitor for responses. 53 + 54 + ### When the user wants to give feedback: 55 + 56 + 1. Help them articulate their feedback. 57 + 2. Show them the draft. 58 + 3. Ask if they want to submit anonymously. 59 + 4. Submit only after approval. 60 + 61 + ### When checking on existing tickets: 62 + 63 + 1. Run `sol call support list` to show open tickets. 64 + 2. Use `sol call support show <id>` for details. 65 + 3. If there's a response, present it to the user. 66 + 4. If the user wants to reply, draft the reply, show it, and send after approval. 67 + 68 + ## Tone 69 + 70 + - Be helpful and empathetic, but efficient. Don't over-explain. 71 + - Frame the support agent as the user's advocate — "I'll handle this for you." 72 + - Be transparent about what data you're collecting and sending. 73 + - If something can be resolved locally (diagnostics, help docs), do that first. 74 + 75 + ## When NOT to Engage 76 + 77 + - 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. 78 + - If support is disabled in settings — explain that outbound communication is off and offer local-only help.
+571
apps/support/portal.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Welcome-mat client for support.solpbc.org. 5 + 6 + Implements the full DPoP + self-signed access token auth flow per the 7 + welcome-mat spec and the extro-support SKILL.md interface contract. 8 + 9 + All cryptographic operations use the ``cryptography`` library (already a 10 + solstone dependency). The keypair, access token, and cached TOS are 11 + persisted in the journal's app storage directory. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + import base64 17 + import hashlib 18 + import json 19 + import logging 20 + import time 21 + import uuid 22 + from pathlib import Path 23 + from typing import Any 24 + 25 + import httpx 26 + from cryptography.hazmat.primitives import hashes, serialization 27 + from cryptography.hazmat.primitives.asymmetric import padding, rsa 28 + 29 + logger = logging.getLogger(__name__) 30 + 31 + DEFAULT_PORTAL_URL = "https://support.solpbc.org" 32 + 33 + # --------------------------------------------------------------------------- 34 + # Base64url helpers 35 + # --------------------------------------------------------------------------- 36 + 37 + 38 + def _b64url_encode(data: bytes) -> str: 39 + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") 40 + 41 + 42 + def _b64url_decode(s: str) -> bytes: 43 + s += "=" * (4 - len(s) % 4) 44 + return base64.urlsafe_b64decode(s) 45 + 46 + 47 + # --------------------------------------------------------------------------- 48 + # JWT helpers 49 + # --------------------------------------------------------------------------- 50 + 51 + 52 + def _jwt_encode(header: dict, payload: dict, private_key: rsa.RSAPrivateKey) -> str: 53 + """Create a signed JWT (RS256).""" 54 + h = _b64url_encode(json.dumps(header, separators=(",", ":")).encode()) 55 + p = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) 56 + signing_input = f"{h}.{p}".encode() 57 + sig = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) 58 + return f"{h}.{p}.{_b64url_encode(sig)}" 59 + 60 + 61 + def _sha256_b64url(data: str | bytes) -> str: 62 + if isinstance(data, str): 63 + data = data.encode("utf-8") 64 + return _b64url_encode(hashlib.sha256(data).digest()) 65 + 66 + 67 + # --------------------------------------------------------------------------- 68 + # JWK / Thumbprint 69 + # --------------------------------------------------------------------------- 70 + 71 + 72 + def _public_key_jwk(key: rsa.RSAPublicKey) -> dict: 73 + """Export RSA public key as a JWK dict.""" 74 + numbers = key.public_numbers() 75 + e_bytes = numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big") 76 + n_bytes = numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big") 77 + return { 78 + "kty": "RSA", 79 + "e": _b64url_encode(e_bytes), 80 + "n": _b64url_encode(n_bytes), 81 + } 82 + 83 + 84 + def _jwk_thumbprint(jwk: dict) -> str: 85 + """RFC 7638 JWK thumbprint (SHA-256, base64url).""" 86 + # Canonical JSON: alphabetical keys 87 + canonical = json.dumps( 88 + {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]}, 89 + separators=(",", ":"), 90 + sort_keys=True, 91 + ) 92 + return _sha256_b64url(canonical) 93 + 94 + 95 + # --------------------------------------------------------------------------- 96 + # Portal client 97 + # --------------------------------------------------------------------------- 98 + 99 + 100 + class PortalClient: 101 + """Welcome-mat client for the support portal. 102 + 103 + Parameters 104 + ---------- 105 + portal_url: 106 + Base URL of the portal (no trailing slash). 107 + storage_dir: 108 + Directory for keypair, token cache, and TOS cache. 109 + handle: 110 + Agent handle for registration. Defaults to the machine hostname. 111 + anonymous: 112 + If True, generate a random handle and don't persist the keypair. 113 + """ 114 + 115 + def __init__( 116 + self, 117 + portal_url: str = DEFAULT_PORTAL_URL, 118 + storage_dir: Path | None = None, 119 + handle: str | None = None, 120 + anonymous: bool = False, 121 + ) -> None: 122 + self.portal_url = portal_url.rstrip("/") 123 + self.anonymous = anonymous 124 + self._handle = handle 125 + 126 + if storage_dir is None: 127 + from apps.utils import get_app_storage_path 128 + 129 + storage_dir = get_app_storage_path("support", "portal", ensure_exists=True) 130 + 131 + self.storage_dir = Path(storage_dir) 132 + self.storage_dir.mkdir(parents=True, exist_ok=True) 133 + 134 + self._private_key: rsa.RSAPrivateKey | None = None 135 + self._access_token: str | None = None 136 + self._tos_text: str | None = None 137 + self._jwk: dict | None = None 138 + self._thumbprint: str | None = None 139 + 140 + self._load_state() 141 + 142 + # -- Persistence --------------------------------------------------------- 143 + 144 + @property 145 + def _keypair_path(self) -> Path: 146 + return self.storage_dir / "keypair.pem" 147 + 148 + @property 149 + def _token_path(self) -> Path: 150 + return self.storage_dir / "token.json" 151 + 152 + @property 153 + def _tos_cache_path(self) -> Path: 154 + return self.storage_dir / "tos.txt" 155 + 156 + @property 157 + def handle(self) -> str: 158 + if self._handle: 159 + return self._handle 160 + import socket 161 + 162 + hostname = socket.gethostname().lower().replace("_", "-")[:48] 163 + # Ensure valid handle format 164 + handle = "".join(c for c in hostname if c.isalnum() or c in ".-") 165 + handle = handle.strip(".-") or "solstone" 166 + self._handle = f"solstone-{handle}" 167 + return self._handle 168 + 169 + def _load_state(self) -> None: 170 + """Load persisted keypair and token.""" 171 + if self.anonymous: 172 + return 173 + 174 + if self._keypair_path.is_file(): 175 + pem = self._keypair_path.read_bytes() 176 + self._private_key = serialization.load_pem_private_key(pem, password=None) 177 + pub = self._private_key.public_key() 178 + self._jwk = _public_key_jwk(pub) 179 + self._thumbprint = _jwk_thumbprint(self._jwk) 180 + 181 + if self._token_path.is_file(): 182 + try: 183 + data = json.loads(self._token_path.read_text()) 184 + self._access_token = data.get("access_token") 185 + self._handle = data.get("handle", self._handle) 186 + except (json.JSONDecodeError, OSError): 187 + pass 188 + 189 + if self._tos_cache_path.is_file(): 190 + try: 191 + self._tos_text = self._tos_cache_path.read_text() 192 + except OSError: 193 + pass 194 + 195 + def _save_keypair(self) -> None: 196 + if self.anonymous or self._private_key is None: 197 + return 198 + pem = self._private_key.private_bytes( 199 + encoding=serialization.Encoding.PEM, 200 + format=serialization.PrivateFormat.PKCS8, 201 + encryption_algorithm=serialization.NoEncryption(), 202 + ) 203 + self._keypair_path.write_bytes(pem) 204 + self._keypair_path.chmod(0o600) 205 + 206 + def _save_token(self) -> None: 207 + if self.anonymous: 208 + return 209 + data = {"access_token": self._access_token, "handle": self._handle} 210 + self._token_path.write_text(json.dumps(data)) 211 + 212 + def _save_tos(self, tos_text: str) -> None: 213 + self._tos_text = tos_text 214 + if not self.anonymous: 215 + self._tos_cache_path.write_text(tos_text) 216 + 217 + # -- Key management ------------------------------------------------------ 218 + 219 + def _ensure_keypair(self) -> None: 220 + """Generate RSA-4096 keypair if we don't have one.""" 221 + if self._private_key is not None: 222 + return 223 + 224 + logger.info("Generating RSA-4096 keypair for support portal registration") 225 + self._private_key = rsa.generate_private_key( 226 + public_exponent=65537, 227 + key_size=4096, 228 + ) 229 + pub = self._private_key.public_key() 230 + self._jwk = _public_key_jwk(pub) 231 + self._thumbprint = _jwk_thumbprint(self._jwk) 232 + self._save_keypair() 233 + 234 + # -- DPoP proof creation ------------------------------------------------- 235 + 236 + def _create_dpop_proof( 237 + self, 238 + method: str, 239 + url: str, 240 + access_token: str | None = None, 241 + ) -> str: 242 + """Create a DPoP proof JWT per RFC 9449.""" 243 + assert self._private_key is not None 244 + assert self._jwk is not None 245 + 246 + header = { 247 + "typ": "dpop+jwt", 248 + "alg": "RS256", 249 + "jwk": self._jwk, 250 + } 251 + payload: dict[str, Any] = { 252 + "jti": str(uuid.uuid4()), 253 + "htm": method, 254 + "htu": url.split("?")[0], # strip query/fragment 255 + "iat": int(time.time()), 256 + } 257 + if access_token is not None: 258 + payload["ath"] = _sha256_b64url(access_token) 259 + 260 + return _jwt_encode(header, payload, self._private_key) 261 + 262 + # -- Access token creation ----------------------------------------------- 263 + 264 + def _create_access_token(self, tos_text: str) -> str: 265 + """Create a self-signed wm+jwt access token.""" 266 + assert self._private_key is not None 267 + assert self._thumbprint is not None 268 + 269 + header = {"typ": "wm+jwt", "alg": "RS256"} 270 + payload = { 271 + "jti": str(uuid.uuid4()), 272 + "tos_hash": _sha256_b64url(tos_text), 273 + "aud": self.portal_url, 274 + "cnf": {"jkt": self._thumbprint}, 275 + "iat": int(time.time()), 276 + } 277 + return _jwt_encode(header, payload, self._private_key) 278 + 279 + # -- TOS signing --------------------------------------------------------- 280 + 281 + def _sign_tos(self, tos_text: str) -> str: 282 + """Sign TOS text with RS256 and return base64url signature.""" 283 + assert self._private_key is not None 284 + sig = self._private_key.sign( 285 + tos_text.encode("utf-8"), 286 + padding.PKCS1v15(), 287 + hashes.SHA256(), 288 + ) 289 + return _b64url_encode(sig) 290 + 291 + # -- HTTP helpers -------------------------------------------------------- 292 + 293 + def _http(self) -> httpx.Client: 294 + return httpx.Client(timeout=30.0) 295 + 296 + def _authed_headers(self, method: str, url: str) -> dict[str, str]: 297 + """Return Authorization + DPoP headers for an authenticated request.""" 298 + assert self._access_token is not None 299 + return { 300 + "Authorization": f"DPoP {self._access_token}", 301 + "DPoP": self._create_dpop_proof(method, url, self._access_token), 302 + } 303 + 304 + def _authed_request( 305 + self, 306 + method: str, 307 + path: str, 308 + *, 309 + json_body: dict | None = None, 310 + params: dict | None = None, 311 + retry_on_tos: bool = True, 312 + ) -> httpx.Response: 313 + """Make an authenticated request, handling TOS re-consent.""" 314 + url = f"{self.portal_url}{path}" 315 + headers = self._authed_headers(method, url) 316 + 317 + with self._http() as client: 318 + resp = client.request( 319 + method, url, headers=headers, json=json_body, params=params 320 + ) 321 + 322 + if resp.status_code == 401 and retry_on_tos: 323 + try: 324 + body = resp.json() 325 + except Exception: 326 + body = {} 327 + if body.get("error") == "tos_changed": 328 + logger.info("TOS changed — re-registering") 329 + self.register() 330 + return self._authed_request( 331 + method, path, json_body=json_body, params=params, retry_on_tos=False 332 + ) 333 + 334 + return resp 335 + 336 + # -- Public API ---------------------------------------------------------- 337 + 338 + @property 339 + def is_registered(self) -> bool: 340 + return self._access_token is not None and self._private_key is not None 341 + 342 + @property 343 + def cached_tos(self) -> str | None: 344 + """Return locally cached TOS text, or None if not cached.""" 345 + return self._tos_text 346 + 347 + def fetch_tos(self) -> str: 348 + """Fetch the current TOS from the portal.""" 349 + url = f"{self.portal_url}/tos" 350 + with self._http() as client: 351 + resp = client.get(url, headers={"Accept": "text/plain"}) 352 + resp.raise_for_status() 353 + tos_text = resp.text 354 + self._save_tos(tos_text) 355 + return tos_text 356 + 357 + def register(self) -> dict[str, Any]: 358 + """Run the full welcome-mat registration flow. 359 + 360 + 1. Ensure keypair exists 361 + 2. Fetch TOS 362 + 3. Sign TOS 363 + 4. Create access token 364 + 5. POST /api/signup 365 + """ 366 + self._ensure_keypair() 367 + 368 + tos_text = self.fetch_tos() 369 + tos_signature = self._sign_tos(tos_text) 370 + access_token = self._create_access_token(tos_text) 371 + 372 + url = f"{self.portal_url}/api/signup" 373 + dpop_proof = self._create_dpop_proof("POST", url) 374 + 375 + body = { 376 + "tos_signature": tos_signature, 377 + "access_token": access_token, 378 + "handle": self.handle, 379 + } 380 + 381 + with self._http() as client: 382 + resp = client.post( 383 + url, 384 + headers={"DPoP": dpop_proof, "Content-Type": "application/json"}, 385 + json=body, 386 + ) 387 + 388 + if resp.status_code == 409: 389 + # Handle already taken — append random suffix 390 + import random 391 + import string 392 + 393 + suffix = "".join( 394 + random.choices(string.ascii_lowercase + string.digits, k=4) 395 + ) 396 + self._handle = f"{self.handle}-{suffix}" 397 + return self.register() 398 + 399 + resp.raise_for_status() 400 + data = resp.json() 401 + 402 + self._access_token = data["access_token"] 403 + self._handle = data.get("handle", self._handle) 404 + self._save_token() 405 + 406 + logger.info("Registered with support portal as %s", self._handle) 407 + return data 408 + 409 + def ensure_registered(self) -> None: 410 + """Register if not already registered.""" 411 + if not self.is_registered: 412 + self.register() 413 + 414 + # -- Tickets ------------------------------------------------------------- 415 + 416 + def create_ticket( 417 + self, 418 + *, 419 + product: str = "solstone", 420 + subject: str, 421 + description: str, 422 + severity: str = "medium", 423 + category: str | None = None, 424 + user_email: str | None = None, 425 + user_context: dict | str | None = None, 426 + ) -> dict[str, Any]: 427 + """Create a support ticket.""" 428 + self.ensure_registered() 429 + body: dict[str, Any] = { 430 + "product": product, 431 + "subject": subject, 432 + "description": description, 433 + "severity": severity, 434 + } 435 + if category: 436 + body["category"] = category 437 + if user_email: 438 + body["user_email"] = user_email 439 + if user_context: 440 + body["user_context"] = ( 441 + json.dumps(user_context) 442 + if isinstance(user_context, dict) 443 + else user_context 444 + ) 445 + 446 + resp = self._authed_request("POST", "/api/tickets", json_body=body) 447 + resp.raise_for_status() 448 + return resp.json() 449 + 450 + def list_tickets( 451 + self, 452 + *, 453 + status: str | None = None, 454 + product: str | None = None, 455 + severity: str | None = None, 456 + ) -> list[dict[str, Any]]: 457 + """List tickets (own tickets for user accounts).""" 458 + self.ensure_registered() 459 + params: dict[str, str] = {} 460 + if status: 461 + params["status"] = status 462 + if product: 463 + params["product"] = product 464 + if severity: 465 + params["severity"] = severity 466 + 467 + resp = self._authed_request("GET", "/api/tickets", params=params) 468 + resp.raise_for_status() 469 + return resp.json() 470 + 471 + def get_ticket(self, ticket_id: int) -> dict[str, Any]: 472 + """Get a single ticket with message thread.""" 473 + self.ensure_registered() 474 + resp = self._authed_request("GET", f"/api/tickets/{ticket_id}") 475 + resp.raise_for_status() 476 + return resp.json() 477 + 478 + def reply_to_ticket(self, ticket_id: int, content: str) -> dict[str, Any]: 479 + """Add a message to a ticket.""" 480 + self.ensure_registered() 481 + resp = self._authed_request( 482 + "POST", f"/api/tickets/{ticket_id}/messages", json_body={"content": content} 483 + ) 484 + resp.raise_for_status() 485 + return resp.json() 486 + 487 + # -- Knowledge Base ------------------------------------------------------ 488 + 489 + def search_articles(self, query: str | None = None) -> list[dict[str, Any]]: 490 + """Search published KB articles.""" 491 + self.ensure_registered() 492 + params: dict[str, str] = {} 493 + if query: 494 + params["q"] = query 495 + 496 + resp = self._authed_request("GET", "/api/articles", params=params) 497 + resp.raise_for_status() 498 + return resp.json() 499 + 500 + def get_article(self, slug: str) -> dict[str, Any]: 501 + """Read a single KB article.""" 502 + self.ensure_registered() 503 + resp = self._authed_request("GET", f"/api/articles/{slug}") 504 + resp.raise_for_status() 505 + return resp.json() 506 + 507 + # -- Announcements ------------------------------------------------------- 508 + 509 + def list_announcements(self) -> list[dict[str, Any]]: 510 + """List active announcements.""" 511 + self.ensure_registered() 512 + resp = self._authed_request("GET", "/api/announcements") 513 + resp.raise_for_status() 514 + return resp.json() 515 + 516 + # -- Health -------------------------------------------------------------- 517 + 518 + def health(self) -> dict[str, Any]: 519 + """Check portal health (no auth needed).""" 520 + with self._http() as client: 521 + resp = client.get(f"{self.portal_url}/api/health") 522 + resp.raise_for_status() 523 + return resp.json() 524 + 525 + 526 + # -- Module-level convenience ------------------------------------------------ 527 + 528 + 529 + def get_client( 530 + portal_url: str | None = None, 531 + anonymous: bool = False, 532 + ) -> PortalClient: 533 + """Get a portal client using journal settings for configuration. 534 + 535 + Reads ``support.portal_url`` from journal config if *portal_url* is None. 536 + """ 537 + if portal_url is None: 538 + portal_url = _get_portal_url_from_settings() 539 + return PortalClient(portal_url=portal_url, anonymous=anonymous) 540 + 541 + 542 + def _get_portal_url_from_settings() -> str: 543 + """Read portal URL from journal config, falling back to default.""" 544 + try: 545 + from think.utils import get_journal 546 + 547 + config_path = Path(get_journal()) / "config" / "config.json" 548 + if config_path.is_file(): 549 + config = json.loads(config_path.read_text()) 550 + support = config.get("support", {}) 551 + url = support.get("portal_url") 552 + if url: 553 + return url 554 + except Exception: 555 + pass 556 + return DEFAULT_PORTAL_URL 557 + 558 + 559 + def is_enabled() -> bool: 560 + """Check if the support agent is enabled in settings.""" 561 + try: 562 + from think.utils import get_journal 563 + 564 + config_path = Path(get_journal()) / "config" / "config.json" 565 + if config_path.is_file(): 566 + config = json.loads(config_path.read_text()) 567 + support = config.get("support", {}) 568 + return support.get("enabled", True) 569 + except Exception: 570 + pass 571 + return True
+218
apps/support/routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Flask routes for the support app. 5 + 6 + Provides API endpoints consumed by workspace.html and the background service. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import logging 12 + from typing import Any 13 + 14 + from flask import Blueprint, jsonify, request 15 + 16 + from convey.utils import error_response 17 + 18 + logger = logging.getLogger(__name__) 19 + 20 + support_bp = Blueprint( 21 + "app:support", 22 + __name__, 23 + url_prefix="/app/support", 24 + ) 25 + 26 + 27 + def _get_client(): 28 + """Lazy-import portal client.""" 29 + from apps.support.portal import get_client 30 + 31 + return get_client() 32 + 33 + 34 + def _enabled() -> bool: 35 + from apps.support.portal import is_enabled 36 + 37 + return is_enabled() 38 + 39 + 40 + # -- Tickets ----------------------------------------------------------------- 41 + 42 + 43 + @support_bp.route("/api/tickets", methods=["GET"]) 44 + def list_tickets() -> Any: 45 + """List user's tickets.""" 46 + if not _enabled(): 47 + return error_response("Support is disabled", 403) 48 + 49 + try: 50 + status = request.args.get("status") 51 + client = _get_client() 52 + tickets = client.list_tickets(status=status) 53 + return jsonify(tickets) 54 + except Exception as exc: 55 + logger.exception("Failed to list tickets") 56 + return error_response(str(exc)) 57 + 58 + 59 + @support_bp.route("/api/tickets/<int:ticket_id>", methods=["GET"]) 60 + def get_ticket(ticket_id: int) -> Any: 61 + """Get a single ticket with thread.""" 62 + if not _enabled(): 63 + return error_response("Support is disabled", 403) 64 + 65 + try: 66 + client = _get_client() 67 + ticket = client.get_ticket(ticket_id) 68 + return jsonify(ticket) 69 + except Exception as exc: 70 + logger.exception("Failed to get ticket %d", ticket_id) 71 + return error_response(str(exc)) 72 + 73 + 74 + @support_bp.route("/api/tickets", methods=["POST"]) 75 + def create_ticket() -> Any: 76 + """Create a support ticket.""" 77 + if not _enabled(): 78 + return error_response("Support is disabled", 403) 79 + 80 + payload = request.get_json(force=True) 81 + subject = payload.get("subject") 82 + description = payload.get("description") 83 + 84 + if not subject or not description: 85 + return error_response("subject and description are required") 86 + 87 + try: 88 + from apps.support.tools import support_create 89 + 90 + result = support_create( 91 + subject=subject, 92 + description=description, 93 + product=payload.get("product", "solstone"), 94 + severity=payload.get("severity", "medium"), 95 + category=payload.get("category"), 96 + user_context=payload.get("user_context"), 97 + auto_context=payload.get("auto_context", True), 98 + anonymous=payload.get("anonymous", False), 99 + ) 100 + return jsonify(result), 201 101 + except Exception as exc: 102 + logger.exception("Failed to create ticket") 103 + return error_response(str(exc)) 104 + 105 + 106 + @support_bp.route("/api/tickets/<int:ticket_id>/reply", methods=["POST"]) 107 + def reply_to_ticket(ticket_id: int) -> Any: 108 + """Reply to a ticket.""" 109 + if not _enabled(): 110 + return error_response("Support is disabled", 403) 111 + 112 + payload = request.get_json(force=True) 113 + content = payload.get("content", "") 114 + if not content: 115 + return error_response("content is required") 116 + 117 + try: 118 + client = _get_client() 119 + result = client.reply_to_ticket(ticket_id, content) 120 + return jsonify(result), 201 121 + except Exception as exc: 122 + logger.exception("Failed to reply to ticket %d", ticket_id) 123 + return error_response(str(exc)) 124 + 125 + 126 + # -- Feedback ---------------------------------------------------------------- 127 + 128 + 129 + @support_bp.route("/api/feedback", methods=["POST"]) 130 + def submit_feedback() -> Any: 131 + """Submit feedback.""" 132 + if not _enabled(): 133 + return error_response("Support is disabled", 403) 134 + 135 + payload = request.get_json(force=True) 136 + body = payload.get("body", "") 137 + if not body: 138 + return error_response("body is required") 139 + 140 + try: 141 + from apps.support.tools import support_feedback 142 + 143 + result = support_feedback( 144 + body=body, 145 + product=payload.get("product", "solstone"), 146 + anonymous=payload.get("anonymous", False), 147 + ) 148 + return jsonify(result), 201 149 + except Exception as exc: 150 + logger.exception("Failed to submit feedback") 151 + return error_response(str(exc)) 152 + 153 + 154 + # -- KB & Announcements ------------------------------------------------------ 155 + 156 + 157 + @support_bp.route("/api/articles", methods=["GET"]) 158 + def search_articles() -> Any: 159 + """Search KB articles.""" 160 + if not _enabled(): 161 + return error_response("Support is disabled", 403) 162 + 163 + try: 164 + query = request.args.get("q") 165 + client = _get_client() 166 + articles = client.search_articles(query=query) 167 + return jsonify(articles) 168 + except Exception as exc: 169 + logger.exception("Failed to search articles") 170 + return error_response(str(exc)) 171 + 172 + 173 + @support_bp.route("/api/announcements", methods=["GET"]) 174 + def list_announcements() -> Any: 175 + """List active announcements.""" 176 + if not _enabled(): 177 + return error_response("Support is disabled", 403) 178 + 179 + try: 180 + client = _get_client() 181 + items = client.list_announcements() 182 + return jsonify(items) 183 + except Exception as exc: 184 + logger.exception("Failed to list announcements") 185 + return error_response(str(exc)) 186 + 187 + 188 + # -- Diagnostics ------------------------------------------------------------- 189 + 190 + 191 + @support_bp.route("/api/diagnostics", methods=["GET"]) 192 + def diagnostics() -> Any: 193 + """Run local diagnostics.""" 194 + from apps.support.diagnostics import collect_all 195 + 196 + return jsonify(collect_all()) 197 + 198 + 199 + # -- Badge ------------------------------------------------------------------- 200 + 201 + 202 + @support_bp.route("/api/badge-count", methods=["GET"]) 203 + def badge_count() -> Any: 204 + """Return count of tickets with new responses (for app badge).""" 205 + if not _enabled(): 206 + return jsonify({"count": 0}) 207 + 208 + try: 209 + client = _get_client() 210 + tickets = client.list_tickets(status="open") 211 + # Count tickets that have been updated since creation 212 + # (a simple heuristic for "has new response") 213 + count = sum( 214 + 1 for t in tickets if t.get("updated_at", "") > t.get("created_at", "") 215 + ) 216 + return jsonify({"count": count}) 217 + except Exception: 218 + return jsonify({"count": 0})
+150
apps/support/tools.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Support tool functions for agent workflows. 5 + 6 + Each function provides a discrete capability that both the muse agent 7 + (via ``sol call support``) and the convey routes can use. All outbound 8 + operations are **consent-gated** — they return a draft for review rather 9 + than submitting directly. 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import logging 15 + from typing import Any 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + def support_diagnose() -> dict[str, Any]: 21 + """Run local diagnostics — no network. 22 + 23 + Returns a dict of system state suitable for the ``user_context`` field. 24 + """ 25 + from apps.support.diagnostics import collect_all 26 + 27 + return collect_all() 28 + 29 + 30 + def support_search(query: str, portal_url: str | None = None) -> list[dict[str, Any]]: 31 + """Search the knowledge base for articles matching *query*.""" 32 + from apps.support.portal import get_client 33 + 34 + client = get_client(portal_url=portal_url) 35 + return client.search_articles(query=query) 36 + 37 + 38 + def support_article(slug: str, portal_url: str | None = None) -> dict[str, Any]: 39 + """Read a single KB article by slug.""" 40 + from apps.support.portal import get_client 41 + 42 + client = get_client(portal_url=portal_url) 43 + return client.get_article(slug) 44 + 45 + 46 + def support_create( 47 + *, 48 + subject: str, 49 + description: str, 50 + product: str = "solstone", 51 + severity: str = "medium", 52 + category: str | None = None, 53 + user_email: str | None = None, 54 + user_context: dict | None = None, 55 + auto_context: bool = True, 56 + portal_url: str | None = None, 57 + anonymous: bool = False, 58 + ) -> dict[str, Any]: 59 + """Create a support ticket. 60 + 61 + If *auto_context* is True (default), diagnostic data is collected 62 + and merged into *user_context*. 63 + """ 64 + from apps.support.portal import get_client 65 + 66 + if auto_context: 67 + from apps.support.diagnostics import collect_all 68 + 69 + diag = collect_all() 70 + if user_context: 71 + diag.update(user_context) 72 + user_context = diag 73 + 74 + client = get_client(portal_url=portal_url, anonymous=anonymous) 75 + return client.create_ticket( 76 + product=product, 77 + subject=subject, 78 + description=description, 79 + severity=severity, 80 + category=category, 81 + user_email=user_email, 82 + user_context=user_context, 83 + ) 84 + 85 + 86 + def support_feedback( 87 + *, 88 + body: str, 89 + product: str = "solstone", 90 + portal_url: str | None = None, 91 + anonymous: bool = False, 92 + ) -> dict[str, Any]: 93 + """Submit feedback (lower-friction path). 94 + 95 + Feedback is a ticket with ``category="feedback"`` and low severity. 96 + """ 97 + return support_create( 98 + subject="User feedback", 99 + description=body, 100 + product=product, 101 + severity="low", 102 + category="feedback", 103 + portal_url=portal_url, 104 + anonymous=anonymous, 105 + ) 106 + 107 + 108 + def support_list( 109 + *, 110 + status: str | None = None, 111 + portal_url: str | None = None, 112 + ) -> list[dict[str, Any]]: 113 + """List the user's tickets.""" 114 + from apps.support.portal import get_client 115 + 116 + client = get_client(portal_url=portal_url) 117 + return client.list_tickets(status=status) 118 + 119 + 120 + def support_check( 121 + ticket_id: int, 122 + portal_url: str | None = None, 123 + ) -> dict[str, Any]: 124 + """Check status of a specific ticket (with message thread).""" 125 + from apps.support.portal import get_client 126 + 127 + client = get_client(portal_url=portal_url) 128 + return client.get_ticket(ticket_id) 129 + 130 + 131 + def support_reply( 132 + ticket_id: int, 133 + content: str, 134 + portal_url: str | None = None, 135 + ) -> dict[str, Any]: 136 + """Reply to a ticket.""" 137 + from apps.support.portal import get_client 138 + 139 + client = get_client(portal_url=portal_url) 140 + return client.reply_to_ticket(ticket_id, content) 141 + 142 + 143 + def support_announcements( 144 + portal_url: str | None = None, 145 + ) -> list[dict[str, Any]]: 146 + """List active announcements.""" 147 + from apps.support.portal import get_client 148 + 149 + client = get_client(portal_url=portal_url) 150 + return client.list_announcements()
+448
apps/support/workspace.html
··· 1 + <style> 2 + .support-nav { 3 + display: flex; 4 + gap: 0.25rem; 5 + margin-bottom: 1.5rem; 6 + border-bottom: 1px solid var(--border, #e0e0e0); 7 + padding-bottom: 0.5rem; 8 + } 9 + .support-nav button { 10 + background: none; 11 + border: none; 12 + padding: 0.5rem 1rem; 13 + cursor: pointer; 14 + font-size: 0.9rem; 15 + color: var(--muted, #888); 16 + border-bottom: 2px solid transparent; 17 + transition: all 0.15s; 18 + } 19 + .support-nav button.active { 20 + color: var(--text, #222); 21 + border-bottom-color: var(--facet-color, #3b82f6); 22 + font-weight: 600; 23 + } 24 + .support-section { display: none; } 25 + .support-section.active { display: block; } 26 + 27 + /* Tickets */ 28 + .support-ticket { 29 + border: 1px solid var(--border, #e0e0e0); 30 + border-radius: 8px; 31 + padding: 1rem; 32 + margin-bottom: 0.75rem; 33 + cursor: pointer; 34 + transition: background 0.1s; 35 + } 36 + .support-ticket:hover { background: var(--card-bg, #fafafa); } 37 + .support-ticket-header { 38 + display: flex; 39 + justify-content: space-between; 40 + align-items: center; 41 + margin-bottom: 0.25rem; 42 + } 43 + .support-ticket-id { font-size: 0.8rem; color: var(--muted, #888); } 44 + .support-ticket-subject { font-weight: 600; } 45 + .support-ticket-meta { font-size: 0.8rem; color: var(--muted, #888); margin-top: 0.25rem; } 46 + .support-status { 47 + font-size: 0.75rem; 48 + padding: 0.15rem 0.5rem; 49 + border-radius: 4px; 50 + font-weight: 600; 51 + text-transform: uppercase; 52 + } 53 + .support-status-open { background: #e3f2fd; color: #1565c0; } 54 + .support-status-in-progress { background: #fff3e0; color: #e65100; } 55 + .support-status-waiting { background: #f3e5f5; color: #7b1fa2; } 56 + .support-status-resolved { background: #e8f5e9; color: #2e7d32; } 57 + 58 + /* Ticket detail */ 59 + .support-detail { display: none; } 60 + .support-detail.active { display: block; } 61 + .support-detail-back { 62 + background: none; 63 + border: none; 64 + cursor: pointer; 65 + font-size: 0.9rem; 66 + color: var(--muted, #888); 67 + padding: 0; 68 + margin-bottom: 1rem; 69 + } 70 + .support-detail-back:hover { color: var(--text, #222); } 71 + .support-message { 72 + border-left: 3px solid var(--border, #e0e0e0); 73 + padding: 0.75rem 1rem; 74 + margin-bottom: 0.75rem; 75 + } 76 + .support-message-meta { font-size: 0.8rem; color: var(--muted, #888); margin-bottom: 0.25rem; } 77 + .support-reply-form { margin-top: 1rem; } 78 + .support-reply-form textarea { 79 + width: 100%; 80 + min-height: 80px; 81 + padding: 0.75rem; 82 + border: 1px solid var(--border, #e0e0e0); 83 + border-radius: 6px; 84 + font: inherit; 85 + resize: vertical; 86 + box-sizing: border-box; 87 + } 88 + .support-reply-form textarea:focus { 89 + outline: none; 90 + border-color: var(--facet-color, #3b82f6); 91 + } 92 + 93 + /* Feedback */ 94 + .support-feedback-form textarea { 95 + width: 100%; 96 + min-height: 100px; 97 + padding: 0.75rem; 98 + border: 1px solid var(--border, #e0e0e0); 99 + border-radius: 6px; 100 + font: inherit; 101 + resize: vertical; 102 + margin-bottom: 0.5rem; 103 + box-sizing: border-box; 104 + } 105 + .support-feedback-form textarea:focus { 106 + outline: none; 107 + border-color: var(--facet-color, #3b82f6); 108 + } 109 + .support-feedback-options { 110 + display: flex; 111 + gap: 1rem; 112 + align-items: center; 113 + margin-bottom: 0.5rem; 114 + font-size: 0.85rem; 115 + } 116 + 117 + /* Help */ 118 + .support-help-card { 119 + border: 1px solid var(--border, #e0e0e0); 120 + border-radius: 8px; 121 + padding: 1rem; 122 + margin-bottom: 0.75rem; 123 + } 124 + .support-help-card h3 { margin: 0 0 0.5rem 0; font-size: 1rem; } 125 + .support-help-card p { margin: 0; font-size: 0.9rem; color: var(--muted, #888); } 126 + 127 + /* Buttons */ 128 + .support-btn { 129 + background: var(--facet-color, #3b82f6); 130 + color: #fff; 131 + border: none; 132 + padding: 0.5rem 1.25rem; 133 + border-radius: 6px; 134 + cursor: pointer; 135 + font-size: 0.9rem; 136 + font-weight: 500; 137 + } 138 + .support-btn:hover { opacity: 0.9; } 139 + .support-btn:disabled { opacity: 0.5; cursor: not-allowed; } 140 + .support-btn-secondary { 141 + background: transparent; 142 + color: var(--text, #222); 143 + border: 1px solid var(--border, #e0e0e0); 144 + } 145 + 146 + .support-empty { 147 + text-align: center; 148 + padding: 3rem 1rem; 149 + color: var(--muted, #888); 150 + } 151 + .support-disabled { 152 + text-align: center; 153 + padding: 3rem 1rem; 154 + color: var(--muted, #888); 155 + } 156 + 157 + .support-status-msg { 158 + font-size: 0.85rem; 159 + margin-top: 0.5rem; 160 + min-height: 1.2em; 161 + } 162 + .support-status-msg.success { color: #2e7d32; } 163 + .support-status-msg.error { color: #c62828; } 164 + 165 + /* Announcements banner */ 166 + .support-announcements { 167 + background: #fff8e1; 168 + border: 1px solid #ffe082; 169 + border-radius: 8px; 170 + padding: 0.75rem 1rem; 171 + margin-bottom: 1.5rem; 172 + font-size: 0.9rem; 173 + } 174 + .support-announcements h4 { margin: 0 0 0.25rem 0; font-size: 0.9rem; } 175 + </style> 176 + 177 + <div class="workspace-content" id="support-root"> 178 + <div id="support-disabled" class="support-disabled" style="display:none;"> 179 + <p>Support agent is disabled. Enable it in Settings to file tickets and give feedback.</p> 180 + <p style="font-size:0.85rem;">Local help and diagnostics are still available via <code>sol call support diagnose</code>.</p> 181 + </div> 182 + 183 + <div id="support-main"> 184 + <!-- Announcements banner (hidden if none) --> 185 + <div id="support-announcements-banner" class="support-announcements" style="display:none;"></div> 186 + 187 + <!-- Navigation tabs --> 188 + <nav class="support-nav"> 189 + <button class="active" data-section="tickets">Active Tickets</button> 190 + <button data-section="feedback">Feedback</button> 191 + <button data-section="help">Help &amp; Guidance</button> 192 + </nav> 193 + 194 + <!-- Section: Active Tickets --> 195 + <div id="section-tickets" class="support-section active"> 196 + <div id="tickets-list"></div> 197 + <div id="ticket-detail" class="support-detail"></div> 198 + </div> 199 + 200 + <!-- Section: Feedback --> 201 + <div id="section-feedback" class="support-section"> 202 + <p style="margin-bottom:1rem;">Share your impressions, ideas, or anything on your mind. Your feedback shapes the product.</p> 203 + <div class="support-feedback-form"> 204 + <textarea id="feedback-text" placeholder="What's on your mind?"></textarea> 205 + <div class="support-feedback-options"> 206 + <label><input type="checkbox" id="feedback-anonymous"> Submit anonymously</label> 207 + </div> 208 + <button class="support-btn" id="feedback-submit">Send Feedback</button> 209 + <div id="feedback-status" class="support-status-msg"></div> 210 + </div> 211 + </div> 212 + 213 + <!-- Section: Help & Guidance --> 214 + <div id="section-help" class="support-section"> 215 + <div class="support-help-card"> 216 + <h3>🛟 Getting Help</h3> 217 + <p>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.</p> 218 + </div> 219 + <div class="support-help-card"> 220 + <h3>🔍 Search the Knowledge Base</h3> 221 + <p>Run <code>sol call support search "your question"</code> to find answers in our knowledge base before filing a ticket.</p> 222 + </div> 223 + <div class="support-help-card"> 224 + <h3>🩺 Run Diagnostics</h3> 225 + <p>Run <code>sol call support diagnose</code> to check your system health locally — no data is sent anywhere.</p> 226 + </div> 227 + <div class="support-help-card"> 228 + <h3>📢 Announcements</h3> 229 + <p>Run <code>sol call support announcements</code> to check for product updates and known issues.</p> 230 + </div> 231 + <div class="support-help-card"> 232 + <h3>🔒 Privacy</h3> 233 + <p>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.</p> 234 + </div> 235 + </div> 236 + </div> 237 + </div> 238 + 239 + <script> 240 + (function() { 241 + // Tab navigation 242 + document.querySelectorAll('.support-nav button').forEach(btn => { 243 + btn.addEventListener('click', function() { 244 + document.querySelectorAll('.support-nav button').forEach(b => b.classList.remove('active')); 245 + document.querySelectorAll('.support-section').forEach(s => s.classList.remove('active')); 246 + this.classList.add('active'); 247 + document.getElementById('section-' + this.dataset.section).classList.add('active'); 248 + // Reset ticket detail when switching to tickets 249 + if (this.dataset.section === 'tickets') { 250 + document.getElementById('ticket-detail').classList.remove('active'); 251 + document.getElementById('tickets-list').style.display = ''; 252 + } 253 + }); 254 + }); 255 + 256 + // Load tickets 257 + async function loadTickets() { 258 + const list = document.getElementById('tickets-list'); 259 + try { 260 + const resp = await fetch('/app/support/api/tickets'); 261 + if (!resp.ok) { 262 + if (resp.status === 403) { 263 + document.getElementById('support-main').style.display = 'none'; 264 + document.getElementById('support-disabled').style.display = ''; 265 + return; 266 + } 267 + throw new Error('Failed to load tickets'); 268 + } 269 + const tickets = await resp.json(); 270 + 271 + if (!tickets.length) { 272 + list.innerHTML = '<div class="support-empty"><p>No tickets yet.</p><p style="font-size:0.85rem;">File one by saying "I need help" in the chat bar, or use <code>sol call support create</code>.</p></div>'; 273 + return; 274 + } 275 + 276 + list.innerHTML = tickets.map(t => { 277 + const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, ''); 278 + return `<div class="support-ticket" data-id="${t.id}"> 279 + <div class="support-ticket-header"> 280 + <span class="support-ticket-subject">${esc(t.subject || 'Untitled')}</span> 281 + <span class="support-status ${statusClass}">${esc(t.status || 'open')}</span> 282 + </div> 283 + <div class="support-ticket-meta"> 284 + <span class="support-ticket-id">#${t.id}</span> &middot; 285 + ${esc(t.product || '')} &middot; 286 + ${timeAgo(t.created_at)} 287 + </div> 288 + </div>`; 289 + }).join(''); 290 + 291 + // Click to open detail 292 + list.querySelectorAll('.support-ticket').forEach(el => { 293 + el.addEventListener('click', () => openTicket(parseInt(el.dataset.id))); 294 + }); 295 + } catch (e) { 296 + list.innerHTML = '<div class="support-empty"><p>Unable to load tickets.</p></div>'; 297 + } 298 + } 299 + 300 + // Open ticket detail 301 + async function openTicket(id) { 302 + const detail = document.getElementById('ticket-detail'); 303 + const list = document.getElementById('tickets-list'); 304 + list.style.display = 'none'; 305 + detail.classList.add('active'); 306 + detail.innerHTML = '<p>Loading...</p>'; 307 + 308 + try { 309 + const resp = await fetch('/app/support/api/tickets/' + id); 310 + const t = await resp.json(); 311 + const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, ''); 312 + 313 + let html = ` 314 + <button class="support-detail-back" id="back-to-list">&larr; Back to tickets</button> 315 + <h2>${esc(t.subject || 'Untitled')} <span class="support-status ${statusClass}">${esc(t.status || 'open')}</span></h2> 316 + <div class="support-ticket-meta">#${t.id} &middot; ${esc(t.product || '')} &middot; ${esc(t.severity || '')} &middot; ${timeAgo(t.created_at)}</div> 317 + <div class="support-message" style="margin-top:1rem;"> 318 + <p>${esc(t.description || '')}</p> 319 + </div>`; 320 + 321 + const msgs = t.messages || []; 322 + if (msgs.length) { 323 + html += '<h3 style="margin-top:1.5rem;">Thread</h3>'; 324 + msgs.forEach(m => { 325 + html += `<div class="support-message"> 326 + <div class="support-message-meta">${esc(m.handle || 'unknown')} &middot; ${timeAgo(m.created_at)}</div> 327 + <p>${esc(m.content || '')}</p> 328 + </div>`; 329 + }); 330 + } 331 + 332 + if (t.status !== 'resolved') { 333 + html += `<div class="support-reply-form"> 334 + <textarea id="reply-text" placeholder="Write a reply..."></textarea> 335 + <button class="support-btn" id="reply-submit" style="margin-top:0.5rem;">Send Reply</button> 336 + <div id="reply-status" class="support-status-msg"></div> 337 + </div>`; 338 + } 339 + 340 + detail.innerHTML = html; 341 + 342 + document.getElementById('back-to-list').addEventListener('click', () => { 343 + detail.classList.remove('active'); 344 + detail.innerHTML = ''; 345 + list.style.display = ''; 346 + }); 347 + 348 + const replyBtn = document.getElementById('reply-submit'); 349 + if (replyBtn) { 350 + replyBtn.addEventListener('click', async () => { 351 + const text = document.getElementById('reply-text').value.trim(); 352 + if (!text) return; 353 + replyBtn.disabled = true; 354 + const status = document.getElementById('reply-status'); 355 + try { 356 + const r = await fetch('/app/support/api/tickets/' + id + '/reply', { 357 + method: 'POST', 358 + headers: {'Content-Type': 'application/json'}, 359 + body: JSON.stringify({content: text}) 360 + }); 361 + if (r.ok) { 362 + status.textContent = 'Reply sent.'; 363 + status.className = 'support-status-msg success'; 364 + document.getElementById('reply-text').value = ''; 365 + // Refresh to show new message 366 + setTimeout(() => openTicket(id), 500); 367 + } else { 368 + throw new Error('Failed'); 369 + } 370 + } catch (e) { 371 + status.textContent = 'Failed to send reply.'; 372 + status.className = 'support-status-msg error'; 373 + } 374 + replyBtn.disabled = false; 375 + }); 376 + } 377 + } catch (e) { 378 + detail.innerHTML = '<p>Failed to load ticket.</p>'; 379 + } 380 + } 381 + 382 + // Feedback submission 383 + document.getElementById('feedback-submit').addEventListener('click', async function() { 384 + const text = document.getElementById('feedback-text').value.trim(); 385 + if (!text) return; 386 + const anon = document.getElementById('feedback-anonymous').checked; 387 + const status = document.getElementById('feedback-status'); 388 + this.disabled = true; 389 + 390 + try { 391 + const resp = await fetch('/app/support/api/feedback', { 392 + method: 'POST', 393 + headers: {'Content-Type': 'application/json'}, 394 + body: JSON.stringify({body: text, anonymous: anon}) 395 + }); 396 + if (resp.ok) { 397 + status.textContent = 'Thanks for your feedback!'; 398 + status.className = 'support-status-msg success'; 399 + document.getElementById('feedback-text').value = ''; 400 + } else { 401 + throw new Error('Failed'); 402 + } 403 + } catch (e) { 404 + status.textContent = 'Failed to submit feedback.'; 405 + status.className = 'support-status-msg error'; 406 + } 407 + this.disabled = false; 408 + }); 409 + 410 + // Load announcements 411 + async function loadAnnouncements() { 412 + try { 413 + const resp = await fetch('/app/support/api/announcements'); 414 + if (!resp.ok) return; 415 + const items = await resp.json(); 416 + if (!items.length) return; 417 + const banner = document.getElementById('support-announcements-banner'); 418 + banner.style.display = ''; 419 + const icons = {'known-issue': '⚠️', 'maintenance': '🔧', 'info': '📢'}; 420 + banner.innerHTML = items.map(a => 421 + `<div><h4>${icons[a.type] || '📢'} ${esc(a.title || '')}</h4><p style="margin:0;font-size:0.85rem;">${esc((a.content || '').slice(0, 200))}</p></div>` 422 + ).join(''); 423 + } catch (e) { /* ignore */ } 424 + } 425 + 426 + // Helpers 427 + function esc(s) { 428 + const el = document.createElement('span'); 429 + el.textContent = s; 430 + return el.innerHTML; 431 + } 432 + 433 + function timeAgo(dateStr) { 434 + if (!dateStr) return ''; 435 + const now = Date.now(); 436 + const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z')).getTime(); 437 + const s = Math.floor((now - then) / 1000); 438 + if (s < 60) return 'just now'; 439 + if (s < 3600) return Math.floor(s / 60) + 'm ago'; 440 + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 441 + return Math.floor(s / 86400) + 'd ago'; 442 + } 443 + 444 + // Init 445 + loadTickets(); 446 + loadAnnouncements(); 447 + })(); 448 + </script>
+6
muse/onboarding.md
··· 110 110 6. Offer imports (see Import Offer above). 111 111 7. Run `sol call awareness onboarding --complete`. 112 112 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. 113 + 114 + ### Support Agent Introduction 115 + 116 + After completing onboarding (step 8), introduce the support agent: 117 + 118 + > 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.
+4
muse/triage.md
··· 45 45 - `sol call awareness onboarding` — Read onboarding state (path, status, observation count). 46 46 - `sol call awareness log-read [DAY] [--kind KIND] [--limit N]` — Read awareness log entries. Use `--kind observation` to read observation findings. 47 47 48 + ### Support 49 + - `sol call chat redirect MESSAGE --muse support:support` — Hand off to the support agent for bug reports, issues, or feedback. 50 + 48 51 ### Redirect to Chat 49 52 - `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. 50 53 ··· 60 63 3. Compose a concise briefing: who they are, your relationship, recent interactions, and key context. 61 64 Proactively offer briefings when context shows an upcoming meeting: "You have a meeting with [person] in [time]. Want me to brief you?" 62 65 - 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`. 66 + - **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..." 63 67 - Do not attempt to use any commands not listed above. 64 68 - 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. 65 69