personal memory agent
0
fork

Configure Feed

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

Add file attachment support to the support flow

Implements attachment capability across all layers of solstone's support
system: portal client method, CLI subcommand, convey workspace UI with
drag-and-drop, Flask route, and updated muse agent instructions.

Attachments are a follow-up action (post-creation) and go through the
same consent gate as all other outbound data — users review file names
and sizes before upload. Respects portal limits (10 MB/file, 5/upload).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+475 -17
+68
apps/support/call.py
··· 12 12 from __future__ import annotations 13 13 14 14 import json 15 + from pathlib import Path 15 16 16 17 import typer 17 18 ··· 237 238 handle = msg.get("handle", "?") 238 239 typer.echo(f"\n[{handle}] {msg.get('created_at', '')}") 239 240 typer.echo(msg.get("content", "")) 241 + attachments = msg.get("attachments", []) 242 + if attachments: 243 + for att in attachments: 244 + size = att.get("size_bytes", 0) 245 + if size >= 1024 * 1024: 246 + size_str = f"{size / 1024 / 1024:.1f} MB" 247 + elif size >= 1024: 248 + size_str = f"{size / 1024:.0f} KB" 249 + else: 250 + size_str = f"{size} bytes" 251 + typer.echo( 252 + f" 📎 {att.get('filename', '?')} ({size_str})" 253 + ) 240 254 241 255 242 256 @app.command("reply") ··· 261 275 except Exception as exc: 262 276 typer.echo(f"Error: {exc}", err=True) 263 277 raise typer.Exit(1) from None 278 + 279 + 280 + @app.command("attach") 281 + def attach( 282 + ticket_id: int = typer.Argument(..., help="Ticket ID to attach files to."), 283 + files: list[Path] = typer.Argument(..., help="File(s) to attach."), 284 + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."), 285 + ) -> None: 286 + """Attach file(s) to a ticket.""" 287 + _check_enabled() 288 + from apps.support.portal import PortalClient 289 + from apps.support.tools import support_attach 290 + 291 + # Validate files up front 292 + for f in files: 293 + if not f.is_file(): 294 + typer.echo(f"Error: file not found: {f}", err=True) 295 + raise typer.Exit(1) 296 + 297 + if len(files) > PortalClient.MAX_ATTACHMENTS_PER_MESSAGE: 298 + typer.echo( 299 + f"Error: max {PortalClient.MAX_ATTACHMENTS_PER_MESSAGE} files per upload.", 300 + err=True, 301 + ) 302 + raise typer.Exit(1) 303 + 304 + # Consent gate — show what will be uploaded 305 + typer.echo(f"\n--- Attachment Review (ticket #{ticket_id}) ---") 306 + for f in files: 307 + size = f.stat().st_size 308 + if size >= 1024 * 1024: 309 + size_str = f"{size / 1024 / 1024:.1f} MB" 310 + elif size >= 1024: 311 + size_str = f"{size / 1024:.0f} KB" 312 + else: 313 + size_str = f"{size} bytes" 314 + typer.echo(f" {f.name} ({size_str})") 315 + typer.echo("--- End Review ---\n") 316 + 317 + if not yes: 318 + approved = typer.confirm("Upload these files?") 319 + if not approved: 320 + typer.echo("Cancelled — nothing was sent.") 321 + return 322 + 323 + for f in files: 324 + try: 325 + result = support_attach(ticket_id, str(f)) 326 + typer.echo(f"Attached: {f.name} (id: {result.get('id', '?')})") 327 + except ValueError as exc: 328 + typer.echo(f"Skipped {f.name}: {exc}", err=True) 329 + except Exception as exc: 330 + typer.echo(f"Error uploading {f.name}: {exc}", err=True) 331 + raise typer.Exit(1) from None 264 332 265 333 266 334 @app.command("feedback")
+25 -1
apps/support/muse/sol-support/SKILL.md
··· 80 80 # List all tickets (including resolved) 81 81 sol call support list --status resolved 82 82 83 - # View a ticket with thread 83 + # View a ticket with thread (includes attachment metadata) 84 84 sol call support show 42 85 85 86 86 # Reply to a ticket ··· 90 90 sol call support list --json 91 91 sol call support show 42 --json 92 92 ``` 93 + 94 + ### Attachments 95 + 96 + ```bash 97 + # Attach a screenshot to ticket #42 98 + sol call support attach 42 ~/screenshot.png 99 + 100 + # Attach multiple files 101 + sol call support attach 42 screenshot.png error-log.txt 102 + 103 + # Skip confirmation (only after explicit user consent) 104 + sol call support attach 42 screenshot.png --yes 105 + ``` 106 + 107 + Upload files to an existing ticket. The consent gate shows each file (name and size) before upload. Attachments are a follow-up action — create the ticket first, then attach files. 108 + 109 + **Limits:** max 10 MB per file, max 5 files per upload. 110 + 111 + **Supported formats:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.pdf`, `.txt`, `.csv`, `.html`, `.md`, `.xml`, `.json` 112 + 113 + When a user reports a visual bug (UI glitch, rendering issue), proactively suggest attaching a screenshot. 93 114 94 115 ### Feedback 95 116 ··· 141 162 --description "Google Calendar events imported yesterday aren't showing up in the calendar app. Tried re-importing but same result." \ 142 163 --category bug \ 143 164 --severity medium 165 + 166 + # Attach a screenshot to the ticket 167 + sol call support attach 15 ~/screenshot.png 144 168 145 169 # User wants to give feedback 146 170 sol call support feedback \
+3
apps/support/muse/support.md
··· 26 26 - `sol call support list [--status open]` — List your tickets 27 27 - `sol call support show <id>` — View a ticket with thread 28 28 - `sol call support reply <id> --body "..." --yes` — Reply to a ticket (only after user approves the reply text) 29 + - `sol call support attach <id> <file> [<file>...]` — Attach files to a ticket (consent gate shows files before upload) 29 30 - `sol call support feedback --body "..." --yes` — Submit feedback (only after user approves) 30 31 - `sol call support announcements` — Check for product updates / known issues 31 32 - `sol call support diagnose` — Run local diagnostics (no network) ··· 50 51 4. **Wait for approval.** Only submit after the user says yes. Use `--yes` flag only after explicit consent. 51 52 52 53 5. **Confirm submission.** Tell the user the ticket number and that you'll monitor for responses. 54 + 55 + 6. **For visual bugs, offer to attach a screenshot.** If the user describes a UI glitch, rendering issue, or anything visual, proactively ask: "Would you like to attach a screenshot? That would help the support team see exactly what you're seeing." If they provide a file path, use `sol call support attach <ticket_id> <file>` — the consent gate will show them the file before upload. 53 56 54 57 ### When the user wants to give feedback: 55 58
+98
apps/support/portal.py
··· 493 493 self._raise_for_status(resp) 494 494 return resp.json() 495 495 496 + # -- Attachments --------------------------------------------------------- 497 + 498 + MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB 499 + MAX_ATTACHMENTS_PER_MESSAGE = 5 500 + 501 + ALLOWED_CONTENT_TYPES = { 502 + ".png": "image/png", 503 + ".jpg": "image/jpeg", 504 + ".jpeg": "image/jpeg", 505 + ".gif": "image/gif", 506 + ".webp": "image/webp", 507 + ".svg": "image/svg+xml", 508 + ".pdf": "application/pdf", 509 + ".txt": "text/plain", 510 + ".csv": "text/csv", 511 + ".html": "text/html", 512 + ".md": "text/markdown", 513 + ".xml": "text/xml", 514 + ".json": "application/json", 515 + } 516 + 517 + def attach_file( 518 + self, 519 + ticket_id: int, 520 + file_path: Path, 521 + *, 522 + filename: str | None = None, 523 + content_type: str | None = None, 524 + ) -> dict[str, Any]: 525 + """Upload a file attachment to a ticket. 526 + 527 + Parameters 528 + ---------- 529 + ticket_id: 530 + The ticket to attach the file to. 531 + file_path: 532 + Path to the local file. 533 + filename: 534 + Override filename sent to the portal (defaults to file_path.name). 535 + content_type: 536 + Override MIME type (auto-detected from extension if omitted). 537 + 538 + Raises 539 + ------ 540 + ValueError 541 + If the file is too large or has an unsupported type. 542 + FileNotFoundError 543 + If the file does not exist. 544 + """ 545 + self.ensure_registered() 546 + file_path = Path(file_path) 547 + 548 + if not file_path.is_file(): 549 + raise FileNotFoundError(f"File not found: {file_path}") 550 + 551 + size = file_path.stat().st_size 552 + if size > self.MAX_ATTACHMENT_SIZE: 553 + raise ValueError( 554 + f"File too large: {size / 1024 / 1024:.1f} MB " 555 + f"(max {self.MAX_ATTACHMENT_SIZE / 1024 / 1024:.0f} MB)" 556 + ) 557 + 558 + if content_type is None: 559 + suffix = file_path.suffix.lower() 560 + content_type = self.ALLOWED_CONTENT_TYPES.get(suffix) 561 + if content_type is None: 562 + raise ValueError( 563 + f"Unsupported file type: {suffix}. " 564 + f"Allowed: {', '.join(sorted(self.ALLOWED_CONTENT_TYPES))}" 565 + ) 566 + 567 + fname = filename or file_path.name 568 + url = f"{self.portal_url}/api/tickets/{ticket_id}/attachments" 569 + headers = self._authed_headers("POST", url) 570 + 571 + with self._http() as client: 572 + with open(file_path, "rb") as f: 573 + resp = client.post( 574 + url, 575 + headers=headers, 576 + files={"file": (fname, f, content_type)}, 577 + ) 578 + 579 + if resp.status_code == 401: 580 + try: 581 + body = resp.json() 582 + except Exception: 583 + body = {} 584 + if body.get("error") == "tos_changed": 585 + logger.info("TOS changed — re-registering") 586 + self.register() 587 + return self.attach_file( 588 + ticket_id, file_path, filename=fname, content_type=content_type 589 + ) 590 + 591 + self._raise_for_status(resp) 592 + return resp.json() 593 + 496 594 # -- Knowledge Base ------------------------------------------------------ 497 595 498 596 def search_articles(self, query: str | None = None) -> list[dict[str, Any]]:
+54
apps/support/routes.py
··· 123 123 return error_response(str(exc)) 124 124 125 125 126 + # -- Attachments ------------------------------------------------------------- 127 + 128 + 129 + @support_bp.route("/api/tickets/<int:ticket_id>/attachments", methods=["POST"]) 130 + def upload_attachment(ticket_id: int) -> Any: 131 + """Upload a file attachment to a ticket.""" 132 + if not _enabled(): 133 + return error_response("Support is disabled", 403) 134 + 135 + if "file" not in request.files: 136 + return error_response("No file provided") 137 + 138 + uploaded = request.files["file"] 139 + if not uploaded.filename: 140 + return error_response("No filename") 141 + 142 + try: 143 + import tempfile 144 + from pathlib import Path 145 + 146 + from apps.support.portal import PortalClient 147 + 148 + # Validate content type by extension 149 + suffix = Path(uploaded.filename).suffix.lower() 150 + if suffix not in PortalClient.ALLOWED_CONTENT_TYPES: 151 + return error_response( 152 + f"Unsupported file type: {suffix}. " 153 + f"Allowed: {', '.join(sorted(PortalClient.ALLOWED_CONTENT_TYPES))}" 154 + ) 155 + 156 + # Save to temp file, then upload via portal client 157 + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: 158 + uploaded.save(tmp) 159 + tmp_path = Path(tmp.name) 160 + 161 + try: 162 + from apps.support.tools import support_attach 163 + 164 + result = support_attach( 165 + ticket_id, 166 + str(tmp_path), 167 + filename=uploaded.filename, 168 + ) 169 + return jsonify(result), 201 170 + finally: 171 + tmp_path.unlink(missing_ok=True) 172 + 173 + except ValueError as exc: 174 + return error_response(str(exc)) 175 + except Exception as exc: 176 + logger.exception("Failed to upload attachment to ticket %d", ticket_id) 177 + return error_response(str(exc)) 178 + 179 + 126 180 # -- Feedback ---------------------------------------------------------------- 127 181 128 182
+19
apps/support/tools.py
··· 140 140 return client.reply_to_ticket(ticket_id, content) 141 141 142 142 143 + def support_attach( 144 + ticket_id: int, 145 + file_path: str, 146 + *, 147 + filename: str | None = None, 148 + portal_url: str | None = None, 149 + ) -> dict[str, Any]: 150 + """Attach a file to an existing ticket. 151 + 152 + Returns the attachment metadata from the portal. 153 + """ 154 + from pathlib import Path 155 + 156 + from apps.support.portal import get_client 157 + 158 + client = get_client(portal_url=portal_url) 159 + return client.attach_file(ticket_id, Path(file_path), filename=filename) 160 + 161 + 143 162 def support_announcements( 144 163 portal_url: str | None = None, 145 164 ) -> list[dict[str, Any]]:
+208 -16
apps/support/workspace.html
··· 162 162 .support-status-msg.success { color: #2e7d32; } 163 163 .support-status-msg.error { color: #c62828; } 164 164 165 + /* Attachments */ 166 + .support-attachments { 167 + margin-top: 0.5rem; 168 + font-size: 0.85rem; 169 + } 170 + .support-attachment-item { 171 + display: flex; 172 + align-items: center; 173 + gap: 0.35rem; 174 + color: var(--muted, #888); 175 + margin-top: 0.15rem; 176 + } 177 + .support-drop-zone { 178 + border: 2px dashed var(--border, #e0e0e0); 179 + border-radius: 8px; 180 + padding: 1rem; 181 + text-align: center; 182 + color: var(--muted, #888); 183 + font-size: 0.85rem; 184 + margin-top: 0.75rem; 185 + transition: all 0.15s; 186 + cursor: pointer; 187 + } 188 + .support-drop-zone.dragover { 189 + border-color: var(--facet-color, #3b82f6); 190 + background: rgba(59, 130, 246, 0.04); 191 + color: var(--text, #222); 192 + } 193 + .support-drop-zone input[type="file"] { display: none; } 194 + .support-file-list { 195 + margin-top: 0.5rem; 196 + font-size: 0.85rem; 197 + } 198 + .support-file-entry { 199 + display: flex; 200 + align-items: center; 201 + justify-content: space-between; 202 + padding: 0.25rem 0; 203 + } 204 + .support-file-entry .remove-file { 205 + background: none; 206 + border: none; 207 + color: var(--muted, #888); 208 + cursor: pointer; 209 + font-size: 0.9rem; 210 + padding: 0 0.25rem; 211 + } 212 + .support-file-entry .remove-file:hover { color: #c62828; } 213 + 165 214 /* Announcements banner */ 166 215 .support-announcements { 167 216 background: #fff8e1; ··· 322 371 if (msgs.length) { 323 372 html += '<h3 style="margin-top:1.5rem;">Thread</h3>'; 324 373 msgs.forEach(m => { 374 + let attachHtml = ''; 375 + const atts = m.attachments || []; 376 + if (atts.length) { 377 + attachHtml = '<div class="support-attachments">'; 378 + atts.forEach(a => { 379 + attachHtml += `<div class="support-attachment-item">\u{1F4CE} ${esc(a.filename || '?')} (${formatSize(a.size_bytes || 0)})</div>`; 380 + }); 381 + attachHtml += '</div>'; 382 + } 325 383 html += `<div class="support-message"> 326 384 <div class="support-message-meta">${esc(m.handle || 'unknown')} &middot; ${timeAgo(m.created_at)}</div> 327 385 <p>${esc(m.content || '')}</p> 386 + ${attachHtml} 328 387 </div>`; 329 388 }); 330 389 } ··· 332 391 if (t.status !== 'resolved') { 333 392 html += `<div class="support-reply-form"> 334 393 <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> 394 + <div class="support-drop-zone" id="attach-zone"> 395 + <input type="file" id="attach-input" multiple accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.pdf,.txt,.csv,.html,.md,.xml,.json"> 396 + Drop files here or click to attach (max 10 MB each, up to 5 files) 397 + </div> 398 + <div class="support-file-list" id="attach-file-list"></div> 399 + <div style="display:flex;gap:0.5rem;margin-top:0.5rem;"> 400 + <button class="support-btn" id="reply-submit">Send Reply</button> 401 + <button class="support-btn support-btn-secondary" id="attach-only-submit" style="display:none;">Upload Files Only</button> 402 + </div> 336 403 <div id="reply-status" class="support-status-msg"></div> 337 404 </div>`; 338 405 } ··· 345 412 list.style.display = ''; 346 413 }); 347 414 415 + // -- Attachment handling -- 416 + let pendingFiles = []; 417 + const zone = document.getElementById('attach-zone'); 418 + const fileInput = document.getElementById('attach-input'); 419 + const fileList = document.getElementById('attach-file-list'); 420 + const attachOnlyBtn = document.getElementById('attach-only-submit'); 421 + 422 + function addFiles(newFiles) { 423 + for (const f of newFiles) { 424 + if (f.size > 10 * 1024 * 1024) { 425 + showStatus('reply-status', f.name + ' exceeds 10 MB limit.', 'error'); 426 + continue; 427 + } 428 + if (pendingFiles.length >= 5) { 429 + showStatus('reply-status', 'Max 5 files per upload.', 'error'); 430 + break; 431 + } 432 + if (!pendingFiles.some(p => p.name === f.name && p.size === f.size)) { 433 + pendingFiles.push(f); 434 + } 435 + } 436 + renderFileList(); 437 + } 438 + 439 + function renderFileList() { 440 + if (!fileList) return; 441 + if (!pendingFiles.length) { 442 + fileList.innerHTML = ''; 443 + if (attachOnlyBtn) attachOnlyBtn.style.display = 'none'; 444 + return; 445 + } 446 + if (attachOnlyBtn) attachOnlyBtn.style.display = ''; 447 + fileList.innerHTML = pendingFiles.map((f, i) => 448 + `<div class="support-file-entry"> 449 + <span>\u{1F4CE} ${esc(f.name)} (${formatSize(f.size)})</span> 450 + <button class="remove-file" data-idx="${i}">\u00D7</button> 451 + </div>` 452 + ).join(''); 453 + fileList.querySelectorAll('.remove-file').forEach(btn => { 454 + btn.addEventListener('click', () => { 455 + pendingFiles.splice(parseInt(btn.dataset.idx), 1); 456 + renderFileList(); 457 + }); 458 + }); 459 + } 460 + 461 + if (zone) { 462 + zone.addEventListener('click', () => fileInput && fileInput.click()); 463 + zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); 464 + zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); 465 + zone.addEventListener('drop', e => { 466 + e.preventDefault(); 467 + zone.classList.remove('dragover'); 468 + if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files); 469 + }); 470 + } 471 + if (fileInput) { 472 + fileInput.addEventListener('change', () => { 473 + if (fileInput.files.length) addFiles(fileInput.files); 474 + fileInput.value = ''; 475 + }); 476 + } 477 + 478 + async function uploadPendingFiles(ticketId) { 479 + let uploaded = 0; 480 + for (const f of pendingFiles) { 481 + const form = new FormData(); 482 + form.append('file', f); 483 + const r = await fetch('/app/support/api/tickets/' + ticketId + '/attachments', { 484 + method: 'POST', 485 + body: form 486 + }); 487 + if (!r.ok) { 488 + const err = await r.json().catch(() => ({})); 489 + throw new Error(err.error || 'Upload failed for ' + f.name); 490 + } 491 + uploaded++; 492 + } 493 + return uploaded; 494 + } 495 + 348 496 const replyBtn = document.getElementById('reply-submit'); 349 497 if (replyBtn) { 350 498 replyBtn.addEventListener('click', async () => { 351 499 const text = document.getElementById('reply-text').value.trim(); 352 - if (!text) return; 500 + if (!text && !pendingFiles.length) return; 353 501 replyBtn.disabled = true; 502 + if (attachOnlyBtn) attachOnlyBtn.disabled = true; 354 503 const status = document.getElementById('reply-status'); 355 504 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'); 505 + // Send reply text if present 506 + if (text) { 507 + const r = await fetch('/app/support/api/tickets/' + id + '/reply', { 508 + method: 'POST', 509 + headers: {'Content-Type': 'application/json'}, 510 + body: JSON.stringify({content: text}) 511 + }); 512 + if (!r.ok) throw new Error('Failed to send reply'); 369 513 } 514 + // Upload attachments if any 515 + if (pendingFiles.length) { 516 + await uploadPendingFiles(id); 517 + pendingFiles = []; 518 + renderFileList(); 519 + } 520 + status.textContent = text ? 'Reply sent.' : 'Files uploaded.'; 521 + status.className = 'support-status-msg success'; 522 + document.getElementById('reply-text').value = ''; 523 + setTimeout(() => openTicket(id), 500); 370 524 } catch (e) { 371 - status.textContent = 'Failed to send reply.'; 525 + status.textContent = e.message || 'Failed to send.'; 372 526 status.className = 'support-status-msg error'; 373 527 } 374 528 replyBtn.disabled = false; 529 + if (attachOnlyBtn) attachOnlyBtn.disabled = false; 530 + }); 531 + } 532 + 533 + // Upload files only (no reply text) 534 + if (attachOnlyBtn) { 535 + attachOnlyBtn.addEventListener('click', async () => { 536 + if (!pendingFiles.length) return; 537 + replyBtn.disabled = true; 538 + attachOnlyBtn.disabled = true; 539 + const status = document.getElementById('reply-status'); 540 + try { 541 + await uploadPendingFiles(id); 542 + pendingFiles = []; 543 + renderFileList(); 544 + status.textContent = 'Files uploaded.'; 545 + status.className = 'support-status-msg success'; 546 + setTimeout(() => openTicket(id), 500); 547 + } catch (e) { 548 + status.textContent = e.message || 'Failed to upload.'; 549 + status.className = 'support-status-msg error'; 550 + } 551 + replyBtn.disabled = false; 552 + attachOnlyBtn.disabled = false; 375 553 }); 376 554 } 377 555 } catch (e) { ··· 428 606 const el = document.createElement('span'); 429 607 el.textContent = s; 430 608 return el.innerHTML; 609 + } 610 + 611 + function formatSize(bytes) { 612 + if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'; 613 + if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB'; 614 + return bytes + ' bytes'; 615 + } 616 + 617 + function showStatus(elId, msg, type) { 618 + const el = document.getElementById(elId); 619 + if (el) { 620 + el.textContent = msg; 621 + el.className = 'support-status-msg ' + type; 622 + } 431 623 } 432 624 433 625 function timeAgo(dateStr) {