notification manager for bsky
0
fork

Configure Feed

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

follow up / clean up

- user-authored guidance steers recommendation generation (in-memory)
- host-side replay wraps each dispatch so one failure doesn't skip later calls or bypass verification; toasts report ok / partial / failed per call
- safeGetActorProfiles falls back to DID labels when a failed call's input DID is malformed
- UI: subscriptions and settings moved to header-dialog controls, main body is just ask-noti + recommended actions
- client.ts re-transpiles per request in dev so edits no longer require a server restart
- regression tests for the replay contract

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

zzstoatzz 45889c0a 25b171d5

+611 -68
+23 -14
README.md
··· 1 1 # noti 2 2 3 - `noti` is a Bluesky notification manager: it looks at the live inbox, suggests a few actions to reduce noise, and lets the user ask for a specific action in plain language (ask noti) 3 + `noti` is a Bluesky notification manager: it looks at the live inbox, suggests a few actions to reduce noise, and lets you ask for a specific action in plain language. 4 4 5 - `noti` currently offers a small set of notification-management actions against the live Bluesky API: 5 + ## what it does 6 + 7 + against the live Bluesky API, noti can: 6 8 7 9 - mute or unmute an account 8 10 - turn post notifications on or off for a specific account 9 11 - turn reply notifications on or off for a specific account 10 12 - mark notifications seen 11 13 14 + every mutation is proposed by a model that writes the Bluesky SDK call, runs it in a sandbox with an allow-listed method surface injected, and re-reads appview or PDS state afterward before claiming success. partial failures inside a multi-call script are reported honestly per call. 12 15 13 - to take configuration actions, noti employs a [code-mode](https://blog.cloudflare.com/code-mode/)-inspired approach. the server gives the model live state plus a constrained SDK surface, the model writes the mutation code, the user can review it, the code runs in a sandbox with allow-listed SDK methods injected, and the server re-reads appview or PDS state afterward before claiming success. 16 + noti also takes free-form steering guidance (e.g. "normal discourse doesn't warrant a mute") and threads it into the generation prompts so suggestions stay calibrated to your tolerance. 17 + 18 + ## how it works 14 19 20 + the model receives live notification state plus a typed SDK surface ([code-mode](https://blog.cloudflare.com/code-mode/)-inspired) and returns a `run(agent, ctx)` function. the server: 21 + 22 + 1. validates the code against an allowlist 23 + 2. executes it in a QuickJS WASM sandbox that queues `{method, input}` records 24 + 3. replays each call against the real Bluesky agent, catching and reporting per-call failures 25 + 4. re-reads the relevant state and diffs before/after — success is only claimed when an observable upstream change occurs 15 26 16 27 ## running it 17 28 ··· 23 34 24 35 if self-hosting your own PDS, set `ATPROTO_PDS` to your PDS URL. 25 36 26 - if bsky.social's appview is flaky, set `ATPROTO_APPVIEW_PROXY=did:web:api.blacksky.community#bsky_appview` to route `app.bsky.*` reads through Blacksky's appview 37 + if bsky.social's appview is flaky, set `ATPROTO_APPVIEW_PROXY=did:web:api.blacksky.community#bsky_appview` to route `app.bsky.*` reads through Blacksky's appview. 27 38 28 39 ```bash 29 40 cp .env.example .env ··· 32 43 33 44 the app serves on `http://127.0.0.1:8000`. 34 45 35 - ## why this scope 46 + ## tests 36 47 37 - the goal is to help a user manage notifications: a reviewable action queue that can change and verify live notification configuration state. 48 + ```bash 49 + bun test 50 + ``` 38 51 39 - this led to two constraints: 40 - 41 - - keep the mutation surface small enough to verify cleanly 42 - - keep the UI centered on concrete actions instead of summarization or narration 52 + covers the host-side replay contract — all-ok sequences, mid-sequence failures that must not bypass later calls, and input-payload preservation for failure reporting. 43 53 44 54 ## next 45 55 46 56 - expand from per-actor activity subscriptions into the broader global notification-preferences surface 47 - - keep widening the code-mode SDK surface only when each added lever has a clear verification story 48 - - keep tightening the UI around clarity, hierarchy, and reversible controls 49 - - add daemon ingest process to keep windowed actor occurrences fresh regardless of noti requests 50 - - make this a multi-tenant app with OAuth 57 + - widen the code-mode SDK surface as each added lever gets a clear verification story 58 + - add a daemon ingest process to keep windowed actor occurrences fresh regardless of noti requests 59 + - multi-tenant with OAuth
+90 -3
src/client.ts
··· 6 6 label: string 7 7 href?: string 8 8 detail: string 9 + status?: 'ok' | 'failed' | 'no-diff' 9 10 } 10 11 11 12 type ToastPayload = { 12 13 description: string 13 14 verified: boolean 15 + partial?: boolean 14 16 details?: ToastDetail[] 15 17 text: string 16 18 } ··· 254 256 const root = toastRoot() 255 257 if (!root) return 256 258 259 + const toneClass = payload.partial 260 + ? 'toast-partial' 261 + : payload.verified 262 + ? 'toast-ok' 263 + : 'toast-bad' 264 + const kickerLabel = payload.partial 265 + ? 'partial' 266 + : payload.verified 267 + ? 'done' 268 + : 'needs attention' 269 + // Partial toasts stick around longer because they carry failure detail 270 + // the user needs to read. 271 + const dismissAfter = payload.partial ? 6400 : 3200 272 + 257 273 const toast = document.createElement('div') 258 - toast.className = `toast ${payload.verified ? 'toast-ok' : 'toast-bad'}` 274 + toast.className = `toast ${toneClass}` 259 275 260 276 const kicker = document.createElement('div') 261 277 kicker.className = 'toast-kicker' 262 - kicker.textContent = payload.verified ? 'done' : 'needs attention' 278 + kicker.textContent = kickerLabel 263 279 264 280 const title = document.createElement('div') 265 281 title.className = 'toast-title' ··· 270 286 if (payload.details?.length) { 271 287 for (const detail of payload.details) { 272 288 const line = document.createElement('div') 289 + line.className = `toast-line toast-line-${detail.status || 'ok'}` 273 290 if (detail.href) { 274 291 const link = document.createElement('a') 275 292 link.className = 'actor-link' ··· 289 306 290 307 toast.append(kicker, title, copy) 291 308 root.append(toast) 292 - window.setTimeout(() => toast.remove(), 3200) 309 + window.setTimeout(() => toast.remove(), dismissAfter) 293 310 } 294 311 295 312 function parseHtmlDocument(html: string) { ··· 306 323 replaceContents('controls-root', nextDoc) 307 324 replaceContents('ask-root', nextDoc) 308 325 replaceContents('queue-root', nextDoc) 326 + // Subscriptions live inside a dialog — refresh its body so the list stays 327 + // in sync after apply/mark-all-read, regardless of whether the dialog is open. 309 328 replaceContents('subscriptions-root', nextDoc) 329 + // Guidance panel is deliberately NOT refreshed — preserves in-flight typing. 310 330 311 331 const currentQueueCount = document.getElementById('queue-count') 312 332 const nextQueueCount = nextDoc.getElementById('queue-count') ··· 425 445 void loadMentionSuggestions(mention) 426 446 }) 427 447 448 + function openDialog(id: string) { 449 + const dialog = document.getElementById(id) 450 + if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal() 451 + } 452 + 453 + function closeDialog(dialog: HTMLDialogElement) { 454 + if (dialog.open) dialog.close() 455 + } 456 + 428 457 document.addEventListener('click', event => { 429 458 const target = event.target 430 459 if (target instanceof HTMLElement) { ··· 433 462 removeSelectedActor(String(removeButton.dataset.removeActorDid || '')) 434 463 return 435 464 } 465 + const opener = target.closest('[data-dialog]') 466 + if (opener instanceof HTMLElement && !(opener as HTMLButtonElement).disabled) { 467 + const id = opener.dataset.dialog 468 + if (id) { 469 + openDialog(id) 470 + return 471 + } 472 + } 473 + const closer = target.closest('[data-dialog-close]') 474 + if (closer instanceof HTMLElement) { 475 + const dialog = closer.closest('dialog') 476 + if (dialog instanceof HTMLDialogElement) { 477 + closeDialog(dialog) 478 + return 479 + } 480 + } 481 + // Backdrop click — dialog element itself is the event target when clicking 482 + // outside the shell. The inner .dialog-shell swallows its own clicks. 483 + if (target instanceof HTMLDialogElement && target.open) { 484 + closeDialog(target) 485 + return 486 + } 436 487 } 437 488 const root = mentionRoot() 438 489 if (!root || root.hidden) return ··· 485 536 } 486 537 }) 487 538 539 + async function handleGuidanceSave(form: HTMLFormElement) { 540 + const textarea = form.querySelector('textarea[name="guidance"]') 541 + const hint = document.getElementById('guidance-hint') 542 + const summary = document.querySelector('#guidance-panel .guidance-summary') 543 + const submitter = form.querySelector('button[type="submit"]') 544 + if (!(textarea instanceof HTMLTextAreaElement)) return 545 + if (submitter instanceof HTMLButtonElement) submitter.disabled = true 546 + try { 547 + const body = new FormData() 548 + body.set('guidance', textarea.value) 549 + const res = await fetch('/api/prefs', {method: 'POST', body}) 550 + if (!res.ok) throw new Error('save failed') 551 + const payload = (await res.json()) as {guidance?: string} 552 + const saved = typeof payload.guidance === 'string' ? payload.guidance : '' 553 + textarea.value = saved 554 + if (summary) summary.textContent = saved ? 'steering active' : 'add steering (optional)' 555 + if (hint) { 556 + hint.textContent = 'saved' 557 + hint.classList.add('saved') 558 + window.setTimeout(() => { 559 + hint.textContent = 'in-memory; resets when the server restarts' 560 + hint.classList.remove('saved') 561 + }, 1600) 562 + } 563 + } catch { 564 + if (hint) hint.textContent = 'could not save — try again' 565 + } finally { 566 + if (submitter instanceof HTMLButtonElement) submitter.disabled = false 567 + } 568 + } 569 + 488 570 document.addEventListener('submit', event => { 489 571 const form = event.target 490 572 if (!(form instanceof HTMLFormElement)) return ··· 503 585 if (action === '/mark-all-read') { 504 586 event.preventDefault() 505 587 void handleMarkAllRead(form) 588 + return 589 + } 590 + if (form.id === 'guidance-form') { 591 + event.preventDefault() 592 + void handleGuidanceSave(form) 506 593 } 507 594 }) 508 595
+28 -3
src/code-mode.ts
··· 3 3 // it drives the prompt (SDK_SURFACE), the string validator (ALLOWED_METHODS), 4 4 // the sandbox stubs (SANDBOX_PREAMBLE), and the host replay table (METHOD_DISPATCH). 5 5 6 + import type {CallResult} from './types' 7 + 6 8 const METHOD_DEFS = [ 7 9 { 8 10 fq: 'agent.app.bsky.graph.muteActor', ··· 192 194 193 195 const SANDBOX_TIMEOUT_MS = 5_000 194 196 195 - export async function executeCode<TCtx extends object>(code: string, agent: AgentSurface, ctx: TCtx) { 197 + export async function executeCode<TCtx extends object>( 198 + code: string, 199 + agent: AgentSurface, 200 + ctx: TCtx, 201 + ): Promise<CallResult[]> { 196 202 validateCode(code) 197 203 const QuickJS = await getQuickJS() 198 204 const vm = QuickJS.newContext() ··· 241 247 vm.dispose() 242 248 } 243 249 250 + // Host-side replay — each call is wrapped so one failure doesn't bypass 251 + // the others or the verification step. Earlier sandbox-phase errors still 252 + // throw (they're not partial-success scenarios); the try/catch here only 253 + // covers real upstream dispatch failures. 254 + const results: CallResult[] = [] 244 255 for (const call of callQueue) { 245 256 const dispatch = METHOD_DISPATCH[call.method] 246 - if (!dispatch) throw new CodeValidationError(`unknown method in queue: ${call.method}`) 247 - await dispatch(agent, call.input) 257 + if (!dispatch) { 258 + results.push({method: call.method, input: call.input, status: 'failed', error: 'unknown method in queue'}) 259 + continue 260 + } 261 + try { 262 + await dispatch(agent, call.input) 263 + results.push({method: call.method, input: call.input, status: 'ok'}) 264 + } catch (err) { 265 + results.push({ 266 + method: call.method, 267 + input: call.input, 268 + status: 'failed', 269 + error: err instanceof Error ? err.message : String(err), 270 + }) 271 + } 248 272 } 273 + return results 249 274 }
+22
src/prefs.ts
··· 1 + // In-memory user preferences that steer recommendation generation. 2 + // Scoped to the life of the process — no persistence, no per-user scoping. 3 + // The guidance string is appended verbatim to both system prompts so the 4 + // user can say things like "normal discourse and likes doesn't warrant a mute". 5 + 6 + const MAX_GUIDANCE_LENGTH = 2000 7 + 8 + let guidance = '' 9 + 10 + export function getGuidance(): string { 11 + return guidance 12 + } 13 + 14 + export function setGuidance(next: string): string { 15 + guidance = next.trim().slice(0, MAX_GUIDANCE_LENGTH) 16 + return guidance 17 + } 18 + 19 + export function guidancePromptBlock(): string { 20 + if (!guidance) return '' 21 + return `\nUser guidance for when actions are warranted (authored by the user, treat as steering, not as a hard constraint):\n${guidance}\n` 22 + }
+3 -1
src/recommend.ts
··· 9 9 10 10 import {actorProfileUrl} from './bluesky' 11 11 import {validateCode} from './code-mode' 12 + import {guidancePromptBlock} from './prefs' 12 13 import {SYSTEM_HANDLE_USER_REQUEST, SYSTEM_QUEUE_SUGGESTIONS, proposalTool, recommendationTool} from './recommend-prompt' 13 14 import type { 14 15 ActionProposal, ··· 53 54 toolChoice: ToolChoice, 54 55 system: string, 55 56 ): Promise<TResponse> { 57 + const fullSystem = system + guidancePromptBlock() 56 58 const res = await anthropic.messages.create({ 57 59 model, 58 60 max_tokens: 2400, 59 - system, 61 + system: fullSystem, 60 62 messages: [{role: 'user', content: prompt}], 61 63 tools: [tool], 62 64 tool_choice: toolChoice,
+58 -2
src/render/page.ts
··· 1 - import type {AppState} from '../types' 1 + import {getGuidance} from '../prefs' 2 + import type {AppState, ManagementTarget} from '../types' 2 3 import {escapeHtml} from './html' 3 4 import {proposalBlock, renderQueue, subscriptionsSection} from './queue' 4 5 import {styles} from './styles' 5 6 7 + const GEAR_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>` 8 + 9 + function guidancePanel() { 10 + const guidance = getGuidance() 11 + return ` 12 + <form class="guidance-form" id="guidance-form"> 13 + <label class="guidance-label" for="guidance-input"> 14 + tell noti when you do and don't want it to act 15 + </label> 16 + <textarea id="guidance-input" name="guidance" placeholder="e.g. normal discourse and likes don't warrant a mute. only suggest muting for repeated harassment or spam.">${escapeHtml(guidance)}</textarea> 17 + <div class="guidance-actions"> 18 + <span class="guidance-hint" id="guidance-hint">in-memory; resets when the server restarts</span> 19 + <button class="button" type="submit">save</button> 20 + </div> 21 + </form> 22 + ` 23 + } 24 + 25 + function subscriptionsCount(targets: ManagementTarget[]) { 26 + return targets.length 27 + } 28 + 6 29 export function renderPage(state: AppState) { 7 30 const subscriptions = subscriptionsSection(state.currentSubscriptions) 8 31 const queue = renderQueue(state) 9 32 const queueCount = state.queueState === 'loading' ? 'updating…' : `${state.recommendations.length} suggestions` 33 + const subCount = subscriptionsCount(state.currentSubscriptions) 10 34 11 35 return `<!doctype html> 12 36 <html lang="en"> ··· 26 50 <form method="post" action="/mark-all-read"> 27 51 <button class="button" type="submit">mark all read</button> 28 52 </form> 53 + <button class="button header-button" type="button" data-dialog="subscriptions-dialog" ${subCount === 0 ? 'disabled' : ''} aria-label="Open subscriptions"> 54 + subscriptions${subCount ? ` <span class="header-badge">${subCount}</span>` : ''} 55 + </button> 56 + <button class="button header-button icon-button" type="button" data-dialog="settings-dialog" aria-label="Open settings"> 57 + ${GEAR_ICON} 58 + </button> 29 59 </div> 30 60 </div> 31 61 ··· 52 82 <div class="queue-titlebar" id="queue-count">${queueCount}</div> 53 83 </div> 54 84 <section class="queue" id="queue-root">${queue}</section> 55 - <div id="subscriptions-root">${subscriptions}</div> 56 85 <a class="footer-link" href="https://bsky.app/settings/notifications" target="_blank" rel="noopener noreferrer">configure notification settings in Bluesky 🦋 ↗</a> 86 + 87 + <dialog class="dialog" id="subscriptions-dialog" aria-labelledby="subscriptions-dialog-title"> 88 + <div class="dialog-shell"> 89 + <header class="dialog-header"> 90 + <div> 91 + <div class="section-kicker">subscriptions</div> 92 + <h2 class="dialog-title" id="subscriptions-dialog-title">active subscriptions</h2> 93 + </div> 94 + <button class="icon-button dialog-close" type="button" data-dialog-close aria-label="Close">×</button> 95 + </header> 96 + <div class="dialog-body" id="subscriptions-root">${subscriptions}</div> 97 + </div> 98 + </dialog> 99 + 100 + <dialog class="dialog" id="settings-dialog" aria-labelledby="settings-dialog-title"> 101 + <div class="dialog-shell"> 102 + <header class="dialog-header"> 103 + <div> 104 + <div class="section-kicker">settings</div> 105 + <h2 class="dialog-title" id="settings-dialog-title">guidance</h2> 106 + </div> 107 + <button class="icon-button dialog-close" type="button" data-dialog-close aria-label="Close">×</button> 108 + </header> 109 + <div class="dialog-body" id="guidance-root">${guidancePanel()}</div> 110 + </div> 111 + </dialog> 112 + 57 113 <div class="toast-stack" id="toast-stack" aria-live="polite"></div> 58 114 </div> 59 115 <script type="module" src="/app.js"></script>
+4 -14
src/render/queue.ts
··· 129 129 } 130 130 131 131 export function subscriptionsSection(targets: ManagementTarget[]) { 132 - if (!targets.length) return '' 133 - const preview = targets 134 - .slice(0, 3) 135 - .map(target => target.actorName || target.actorHandle) 136 - .join(' · ') 137 - return ` 138 - <details class="subscriptions-panel"> 139 - <summary> 140 - <span class="section-kicker">subscriptions</span> 141 - <span class="subscriptions-summary">${targets.length} active${preview ? ` · ${escapeHtml(preview)}` : ''}</span> 142 - </summary> 143 - <section class="queue subscriptions-queue">${targets.map(subscriptionItem).join('')}</section> 144 - </details> 145 - ` 132 + if (!targets.length) { 133 + return '<div class="empty">no active subscriptions right now</div>' 134 + } 135 + return `<section class="queue subscriptions-queue">${targets.map(subscriptionItem).join('')}</section>` 146 136 } 147 137 148 138 export function proposalBlock(proposal?: ActionProposal) {
+123 -22
src/render/styles.ts
··· 253 253 min-height: 0; 254 254 margin-top: 10px; 255 255 } 256 + .header-button { 257 + display: inline-flex; 258 + align-items: center; 259 + gap: 6px; 260 + } 261 + .header-button:disabled { 262 + opacity: .45; 263 + cursor: not-allowed; 264 + } 265 + .header-badge { 266 + display: inline-flex; 267 + min-width: 20px; 268 + height: 20px; 269 + padding: 0 6px; 270 + align-items: center; 271 + justify-content: center; 272 + border-radius: 999px; 273 + background: rgba(94,163,255,0.18); 274 + color: var(--accent); 275 + font-size: 11px; 276 + font-weight: 800; 277 + line-height: 1; 278 + } 279 + .icon-button { 280 + display: inline-flex; 281 + align-items: center; 282 + justify-content: center; 283 + padding: 0; 284 + width: 42px; 285 + height: 42px; 286 + color: var(--muted); 287 + } 288 + .icon-button:hover { 289 + color: var(--text); 290 + } 291 + .dialog { 292 + border: 1px solid var(--border-strong); 293 + border-radius: 22px; 294 + background: linear-gradient(180deg, var(--surface), var(--surface-soft)); 295 + color: var(--text); 296 + padding: 0; 297 + max-width: min(560px, calc(100vw - 32px)); 298 + width: 100%; 299 + max-height: min(80vh, 720px); 300 + box-shadow: 0 30px 80px rgba(0,0,0,0.55); 301 + } 302 + .dialog::backdrop { 303 + background: rgba(4, 8, 13, 0.66); 304 + backdrop-filter: blur(2px); 305 + } 306 + .dialog-shell { 307 + display: flex; 308 + flex-direction: column; 309 + max-height: inherit; 310 + } 311 + .dialog-header { 312 + display: flex; 313 + align-items: center; 314 + justify-content: space-between; 315 + gap: 12px; 316 + padding: 16px 20px; 317 + border-bottom: 1px solid var(--border); 318 + } 319 + .dialog-header .section-kicker { margin-bottom: 2px; } 320 + .dialog-title { 321 + margin: 0; 322 + font-size: 20px; 323 + font-weight: 800; 324 + letter-spacing: -0.02em; 325 + } 326 + .dialog-close { 327 + font-size: 20px; 328 + line-height: 1; 329 + width: 34px; 330 + height: 34px; 331 + } 332 + .dialog-body { 333 + padding: 16px 20px 20px; 334 + overflow-y: auto; 335 + } 336 + .guidance-form { 337 + display: flex; 338 + flex-direction: column; 339 + gap: 10px; 340 + } 341 + .guidance-label { 342 + color: var(--muted-2); 343 + font-size: 13px; 344 + line-height: 1.4; 345 + } 346 + .guidance-form textarea { 347 + min-height: 72px; 348 + font-size: 14px; 349 + } 350 + .guidance-actions { 351 + display: flex; 352 + align-items: center; 353 + justify-content: space-between; 354 + gap: 10px; 355 + } 356 + .guidance-hint { 357 + color: var(--muted-2); 358 + font-size: 12px; 359 + line-height: 1.4; 360 + } 361 + .guidance-hint.saved { 362 + color: #6ee7a2; 363 + } 256 364 .queue-header { 257 365 display:flex; 258 366 align-items:center; ··· 267 375 } 268 376 .queue { display:grid; gap:14px; } 269 377 .queue-item { padding: 16px; } 270 - .subscriptions-panel { 271 - margin-top: 18px; 272 - border: 1px solid var(--border); 273 - background: linear-gradient(180deg, rgba(18,25,34,0.92), rgba(12,17,23,0.92)); 274 - border-radius: 22px; 275 - padding: 18px 20px; 276 - } 277 - .subscriptions-panel summary { 278 - display:flex; 279 - flex-direction:column; 280 - gap:8px; 281 - cursor:pointer; 282 - list-style:none; 283 - } 284 - .subscriptions-panel summary::-webkit-details-marker { display:none; } 285 - .subscriptions-summary { 286 - color: var(--muted); 287 - font-size: 15px; 288 - font-weight: 600; 289 - line-height: 1.4; 290 - } 291 378 .subscriptions-queue { 292 - margin-top: 16px; 379 + margin-top: 4px; 293 380 } 294 381 .subscription-actions { 295 382 display:flex; ··· 458 545 .toast-bad { 459 546 border-color: rgba(248, 113, 113, 0.35); 460 547 } 548 + .toast-partial { 549 + border-color: rgba(250, 204, 21, 0.45); 550 + } 551 + .toast-line { 552 + margin-top: 2px; 553 + } 554 + .toast-line-failed { 555 + color: #fca5a5; 556 + } 557 + .toast-line-no-diff { 558 + color: var(--muted-2); 559 + font-style: italic; 560 + } 461 561 .toast-kicker { 462 562 font-size: 11px; 463 563 font-weight: 800; ··· 468 568 } 469 569 .toast-ok .toast-kicker { color: #6ee7a2; } 470 570 .toast-bad .toast-kicker { color: #fca5a5; } 571 + .toast-partial .toast-kicker { color: #facc15; } 471 572 .toast-title { 472 573 font-size: 14px; 473 574 font-weight: 700;
+154 -9
src/server.ts
··· 19 19 searchActorSuggestions, 20 20 } from './bluesky' 21 21 import {executeCode} from './code-mode' 22 + import {getGuidance, setGuidance} from './prefs' 22 23 import {proposeAction, generateRecommendations} from './recommend' 23 24 import {renderPage, renderQueue} from './render/index' 24 25 import {knownActors} from './state' ··· 27 28 ActorSelection, 28 29 AppState, 29 30 ApplyResultLine, 31 + CallResult, 30 32 ManagementTarget, 31 33 Recommendation, 32 34 } from './types' 33 35 34 36 const port = Number(process.env.PORT || 8000) 35 - const clientScript = new Bun.Transpiler({loader: 'ts'}).transformSync( 36 - await Bun.file(new URL('./client.ts', import.meta.url)).text(), 37 - ) 37 + 38 + // client.ts is read as a file (not imported), so bun --watch doesn't pick up 39 + // its changes on its own. In dev, re-transpile per request so edits show up 40 + // without needing a server restart or a touch of server.ts. In production 41 + // (NODE_ENV=production), cache once at boot. 42 + const CLIENT_PATH = new URL('./client.ts', import.meta.url) 43 + const isDev = process.env.NODE_ENV !== 'production' 44 + let cachedClientScript: string | undefined 45 + 46 + async function buildClientScript() { 47 + return new Bun.Transpiler({loader: 'ts'}).transformSync(await Bun.file(CLIENT_PATH).text()) 48 + } 49 + 50 + async function clientScript() { 51 + if (isDev) return buildClientScript() 52 + if (!cachedClientScript) cachedClientScript = await buildClientScript() 53 + return cachedClientScript 54 + } 38 55 39 56 function placeholderFromTargets(targets: ManagementTarget[]) { 40 57 const top = targets[0] ··· 60 77 61 78 type ApplyVerification = { 62 79 verified: boolean 80 + partial: boolean 63 81 details: ApplyResultLine[] 64 82 } 65 83 ··· 75 93 return JSON.stringify({ 76 94 unread: unread.map(row => [row.uri, row.authorDid, row.reason, row.indexedAt]), 77 95 targets: targets.map(row => [row.actorDid, row.currentUnreadCount, row.subscriptionPosts, row.subscriptionReplies, row.muted]), 96 + guidance: getGuidance(), 78 97 }) 79 98 } 80 99 ··· 98 117 label, 99 118 href, 100 119 detail: change.muted ? 'muted in Bluesky' : 'unmuted in Bluesky', 120 + status: 'ok' as const, 101 121 } 102 122 } 103 123 return { 104 124 label, 105 125 href, 106 126 detail: subscriptionDetail(change.subscription), 127 + status: 'ok' as const, 107 128 } 108 129 }) 109 130 } 110 131 111 - function toastPayload(description: string, verified: boolean, details: ApplyResultLine[]) { 132 + // Best-effort actor DID extractor for a failed/ok call's input payload — 133 + // muteActor uses `actor`, putActivitySubscription uses `subject`, updateSeen has neither. 134 + function callActorDid(input: unknown): string | undefined { 135 + if (!input || typeof input !== 'object') return undefined 136 + const row = input as Record<string, unknown> 137 + const actor = typeof row.actor === 'string' ? row.actor : undefined 138 + const subject = typeof row.subject === 'string' ? row.subject : undefined 139 + return actor || subject 140 + } 141 + 142 + function methodLabel(method: string) { 143 + switch (method) { 144 + case 'muteActor': 145 + return 'mute' 146 + case 'unmuteActor': 147 + return 'unmute' 148 + case 'putActivitySubscription': 149 + return 'update subscription' 150 + case 'updateSeen': 151 + return 'mark read' 152 + default: 153 + return method 154 + } 155 + } 156 + 157 + // getActorProfiles rejects the whole batch if any DID is malformed — which 158 + // is exactly the scenario we end up in when a dispatch fails because the 159 + // input DID was bad. Falls back to an empty map so we still render something. 160 + async function safeGetActorProfiles( 161 + agent: Awaited<ReturnType<typeof createAgent>>, 162 + dids: string[], 163 + ) { 164 + if (!dids.length) return new Map() 165 + try { 166 + return await getActorProfiles(agent, dids) 167 + } catch (err) { 168 + console.warn('[verify] profile lookup failed, falling back to DIDs', err instanceof Error ? err.message : err) 169 + return new Map() 170 + } 171 + } 172 + 173 + async function failureLines( 174 + agent: Awaited<ReturnType<typeof createAgent>>, 175 + failures: CallResult[], 176 + ): Promise<ApplyResultLine[]> { 177 + const dids = [ 178 + ...new Set( 179 + failures.map(result => callActorDid(result.input)).filter((v): v is string => Boolean(v)), 180 + ), 181 + ] 182 + const profiles = await safeGetActorProfiles(agent, dids) 183 + return failures.map(result => { 184 + const did = callActorDid(result.input) 185 + const profile = did ? profiles.get(did) : undefined 186 + const label = profile?.name || profile?.handle || (did ? did : methodLabel(result.method)) 187 + return { 188 + label, 189 + href: profile?.profileUrl, 190 + detail: `could not ${methodLabel(result.method)} — ${result.error || 'unknown error'}`, 191 + status: 'failed' as const, 192 + } 193 + }) 194 + } 195 + 196 + function toastPayload(description: string, verified: boolean, partial: boolean, details: ApplyResultLine[]) { 112 197 return { 113 198 description, 114 199 verified, 115 - details: details.map(d => ({label: d.label, href: d.href, detail: d.detail})), 200 + partial, 201 + details: details.map(d => ({label: d.label, href: d.href, detail: d.detail, status: d.status})), 116 202 text: details.length 117 203 ? details.map(d => `${d.label}: ${d.detail}`).join('. ') 118 204 : verified ? 'Done.' : 'No state change detected.', ··· 129 215 affectedDids: string[], 130 216 beforeActorStates: Awaited<ReturnType<typeof getActorStates>>, 131 217 beforeUnreadCount: number, 218 + results: CallResult[], 132 219 ): Promise<ApplyVerification> { 133 220 const details: ApplyResultLine[] = [] 221 + const changedDids = new Set<string>() 222 + let observedChange = false 134 223 135 224 if (affectedDids.length) { 136 225 const after = await getActorStates(agent, affectedDids) 137 226 const changes = diffActorStates(beforeActorStates, after) 138 227 if (changes.length) { 228 + for (const change of changes) changedDids.add(change.actorDid) 139 229 details.push(...(await applyResultLines(agent, affectedDids, changes))) 230 + observedChange = true 140 231 } 141 232 } 142 233 ··· 147 238 details.unshift({ 148 239 label: 'Unread notifications', 149 240 detail: `${beforeUnreadCount} → ${afterUnreadCount}`, 241 + status: 'ok', 150 242 }) 243 + observedChange = true 151 244 } 152 245 } 153 246 247 + // Per-call reconciliation: 248 + // - failed calls → explicit failure line with the upstream error 249 + // - ok calls whose actor wasn't in the diff → "no observable change" line 250 + // (idempotent re-apply, propagation lag, or the model queued something 251 + // that was already true — worth surfacing honestly) 252 + const failures = results.filter(result => result.status === 'failed') 253 + if (failures.length) { 254 + details.push(...(await failureLines(agent, failures))) 255 + } 256 + 257 + const noDiffDids = [ 258 + ...new Set( 259 + results 260 + .filter(result => result.status === 'ok' && result.method !== 'updateSeen') 261 + .map(result => callActorDid(result.input)) 262 + .filter((did): did is string => typeof did === 'string' && did.length > 0 && !changedDids.has(did)), 263 + ), 264 + ] 265 + const noDiffProfiles = await safeGetActorProfiles(agent, noDiffDids) 266 + for (const result of results) { 267 + if (result.status !== 'ok') continue 268 + if (result.method === 'updateSeen') continue 269 + const did = callActorDid(result.input) 270 + if (!did || changedDids.has(did)) continue 271 + const profile = noDiffProfiles.get(did) 272 + details.push({ 273 + label: profile?.name || profile?.handle || did, 274 + href: profile?.profileUrl, 275 + detail: 'no observable change — already in that state or change has not propagated yet', 276 + status: 'no-diff', 277 + }) 278 + } 279 + 280 + const partial = failures.length > 0 && results.length > failures.length 281 + 154 282 if (!details.length) { 155 283 return { 156 284 verified: false, 285 + partial: false, 157 286 details: [ 158 287 { 159 288 label: 'Verification', 160 289 detail: 'No state change detected. The mutation may not have taken effect.', 290 + status: 'no-diff', 161 291 }, 162 292 ], 163 293 } 164 294 } 165 295 166 - return {verified: true, details} 296 + return {verified: observedChange, partial, details} 167 297 } 168 298 169 299 async function buildBaseState(): Promise<BaseState> { ··· 427 557 const url = new URL(req.url) 428 558 429 559 if (req.method === 'GET' && url.pathname === '/app.js') { 430 - return new Response(clientScript, { 560 + return new Response(await clientScript(), { 431 561 headers: {'content-type': 'text/javascript; charset=utf-8'}, 432 562 }) 433 563 } 434 564 435 565 if (req.method === 'GET' && url.pathname === '/health') { 436 566 return Response.json({status: 'ok'}) 567 + } 568 + 569 + if (req.method === 'GET' && url.pathname === '/api/prefs') { 570 + return Response.json({guidance: getGuidance()}) 571 + } 572 + 573 + if (req.method === 'POST' && url.pathname === '/api/prefs') { 574 + const form = await req.formData() 575 + const raw = form.get('guidance') 576 + const guidance = setGuidance(typeof raw === 'string' ? raw : '') 577 + return Response.json({guidance}) 437 578 } 438 579 439 580 if (req.method === 'GET' && url.pathname === '/api/actors/typeahead') { ··· 506 647 beforeActorStates: Object.fromEntries(beforeActorStates), 507 648 beforeUnreadCount, 508 649 }) 509 - await executeCode(code, agent, {sourceUris, notifications: relevant}) 650 + const results = await executeCode(code, agent, {sourceUris, notifications: relevant}) 510 651 const verification = await verifyApply( 511 652 agent, 512 653 code, 513 654 affectedDids, 514 655 beforeActorStates, 515 656 beforeUnreadCount, 657 + results, 516 658 ) 517 659 console.info('[apply] done', { 518 660 description, 519 661 affectedDids, 520 662 verified: verification.verified, 663 + partial: verification.partial, 664 + results, 521 665 details: verification.details, 522 666 }) 523 667 ··· 525 669 return Response.json({ 526 670 ok: true, 527 671 verified: verification.verified, 528 - toast: toastPayload(description, verification.verified, verification.details), 672 + partial: verification.partial, 673 + toast: toastPayload(description, verification.verified, verification.partial, verification.details), 529 674 }) 530 675 } 531 676
+8
src/types.ts
··· 68 68 label: string 69 69 href?: string 70 70 detail: string 71 + status?: 'ok' | 'failed' | 'no-diff' 72 + } 73 + 74 + export type CallResult = { 75 + method: string 76 + input: unknown 77 + status: 'ok' | 'failed' 78 + error?: string 71 79 } 72 80 73 81 export type ActionProposal = {
+98
test/code-mode.test.ts
··· 1 + // Regression coverage for the host-side replay contract in executeCode. 2 + // The bigger goal is the "partial success" story described in 3 + // notes/partial-failure-in-apply.md — if one dispatch throws, earlier calls 4 + // must still be reported as committed and later calls must still run. 5 + 6 + import {describe, expect, test} from 'bun:test' 7 + 8 + import {executeCode} from '../src/code-mode' 9 + 10 + type AgentStub = { 11 + calls: Array<{method: string; input: unknown}> 12 + app: { 13 + bsky: { 14 + graph: { 15 + muteActor: (input: {actor: string}) => Promise<void> 16 + unmuteActor: (input: {actor: string}) => Promise<void> 17 + } 18 + notification: { 19 + updateSeen: (input: {seenAt: string}) => Promise<void> 20 + putActivitySubscription: (input: unknown) => Promise<void> 21 + } 22 + } 23 + } 24 + } 25 + 26 + function makeAgent(options: {throwOn?: string; throwMessage?: string} = {}): AgentStub { 27 + const calls: AgentStub['calls'] = [] 28 + const record = (method: string) => async (input: unknown) => { 29 + calls.push({method, input}) 30 + if (options.throwOn === method) { 31 + throw new Error(options.throwMessage || 'simulated upstream failure') 32 + } 33 + } 34 + return { 35 + calls, 36 + app: { 37 + bsky: { 38 + graph: { 39 + muteActor: record('muteActor'), 40 + unmuteActor: record('unmuteActor'), 41 + }, 42 + notification: { 43 + updateSeen: record('updateSeen'), 44 + putActivitySubscription: record('putActivitySubscription'), 45 + }, 46 + }, 47 + }, 48 + } 49 + } 50 + 51 + describe('executeCode host-side replay', () => { 52 + test('reports ok for every call when none fail', async () => { 53 + const agent = makeAgent() 54 + const code = ` 55 + async function run(agent, ctx) { 56 + await agent.app.bsky.graph.muteActor({actor: 'did:plc:A'}) 57 + await agent.app.bsky.graph.muteActor({actor: 'did:plc:B'}) 58 + } 59 + ` 60 + const results = await executeCode(code, agent, {sourceUris: [], notifications: []}) 61 + expect(results).toHaveLength(2) 62 + expect(results.every(r => r.status === 'ok')).toBe(true) 63 + expect(agent.calls.map(c => c.method)).toEqual(['muteActor', 'muteActor']) 64 + }) 65 + 66 + test('mid-sequence failure does not bypass later calls or skip results', async () => { 67 + const agent = makeAgent({throwOn: 'muteActor', throwMessage: 'rate limited'}) 68 + const code = ` 69 + async function run(agent, ctx) { 70 + await agent.app.bsky.graph.unmuteActor({actor: 'did:plc:A'}) 71 + await agent.app.bsky.graph.muteActor({actor: 'did:plc:B'}) 72 + await agent.app.bsky.notification.updateSeen({seenAt: '2026-04-16T00:00:00.000Z'}) 73 + } 74 + ` 75 + const results = await executeCode(code, agent, {sourceUris: [], notifications: []}) 76 + 77 + // All three calls must be represented — this is the whole point of the fix. 78 + expect(results).toHaveLength(3) 79 + expect(results[0]).toMatchObject({method: 'unmuteActor', status: 'ok'}) 80 + expect(results[1]).toMatchObject({method: 'muteActor', status: 'failed', error: 'rate limited'}) 81 + expect(results[2]).toMatchObject({method: 'updateSeen', status: 'ok'}) 82 + 83 + // And the agent itself must have actually been called for the post-failure 84 + // call — earlier behavior would have thrown out of the loop and skipped it. 85 + expect(agent.calls.map(c => c.method)).toEqual(['unmuteActor', 'muteActor', 'updateSeen']) 86 + }) 87 + 88 + test('preserves the input payload in each result (used by failureLines to resolve actors)', async () => { 89 + const agent = makeAgent({throwOn: 'muteActor'}) 90 + const code = ` 91 + async function run(agent, ctx) { 92 + await agent.app.bsky.graph.muteActor({actor: 'did:plc:B'}) 93 + } 94 + ` 95 + const results = await executeCode(code, agent, {sourceUris: [], notifications: []}) 96 + expect(results[0].input).toEqual({actor: 'did:plc:B'}) 97 + }) 98 + })