notification manager for bsky
0
fork

Configure Feed

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

sandbox execution and UI polish

Replace AsyncFunction eval with QuickJS WASM sandbox for code execution.
Generated code now runs in an isolated interpreter with no host globals —
the only capabilities are four injected SDK method stubs that queue calls
for host-side replay. Adds execution timeout via interrupt handler.

Also: inline favicon, drop external Google Fonts dependency, refine
actor resolution flow and client-side typeahead.

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

+826 -295
+4 -1
biome.json
··· 6 6 "linter": { 7 7 "enabled": true, 8 8 "rules": { 9 - "recommended": true 9 + "recommended": true, 10 + "suspicious": { 11 + "noDeprecatedImports": "error" 12 + } 10 13 } 11 14 } 12 15 }
bun.lockb

This is a binary file and will not be displayed.

+2 -1
package.json
··· 12 12 }, 13 13 "dependencies": { 14 14 "@anthropic-ai/sdk": "^0.89.0", 15 - "@atproto/api": "^0.19.8" 15 + "@atproto/api": "^0.19.8", 16 + "quickjs-emscripten": "^0.32.0" 16 17 }, 17 18 "devDependencies": { 18 19 "@biomejs/biome": "^2.4.12",
-37
src/actor-queries.ts
··· 1 - function cleanActorQuery(value: string) { 2 - return value 3 - .trim() 4 - .replace(/^@/, '') 5 - .replace(/[.?!,:;]+$/g, '') 6 - .replace(/(?:'s)?\s+(?:posts?|repl(?:y|ies)|notifications?)$/i, '') 7 - .trim() 8 - } 9 - 10 - export function extractActorQueries(request: string) { 11 - const out: string[] = [] 12 - const seen = new Set<string>() 13 - const add = (value: string | undefined) => { 14 - const cleaned = cleanActorQuery(value || '') 15 - if (!cleaned) return 16 - const key = cleaned.toLowerCase() 17 - if (seen.has(key)) return 18 - seen.add(key) 19 - out.push(cleaned) 20 - } 21 - 22 - for (const handle of request.match(/@[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)+/g) || []) { 23 - add(handle) 24 - } 25 - 26 - const patterns = [ 27 - /(?:from|for)\s+(.+?)$/i, 28 - /(?:mute|unmute)\s+(.+?)$/i, 29 - /(?:subscribe to|unsubscribe from)\s+(.+?)$/i, 30 - ] 31 - for (const pattern of patterns) { 32 - const match = request.match(pattern) 33 - if (match?.[1]) add(match[1]) 34 - } 35 - 36 - return out 37 - }
+26 -68
src/bluesky.ts
··· 1 + // Bluesky API layer — reads live state (notifications, subscriptions, mutes), 2 + // builds per-actor ManagementTargets for the model, and provides before/after 3 + // diffing for verification. No mutations here — those happen via code-mode.ts. 4 + 1 5 import { 2 6 AppBskyFeedPost, 3 - BskyAgent, 7 + AtpAgent, 4 8 type AppBskyNotificationListNotifications, 5 9 } from '@atproto/api' 6 10 ··· 44 48 } 45 49 46 50 export async function createAgent() { 47 - const agent = new BskyAgent({service}) 51 + const agent = new AtpAgent({service}) 48 52 await agent.login({ 49 53 identifier: requireEnv('ATPROTO_HANDLE'), 50 54 password: requireEnv('ATPROTO_PASSWORD'), ··· 52 56 return agent 53 57 } 54 58 59 + // When a notification is about someone else's post (e.g. a like on their post), 60 + // the notification has a reasonSubject URI but no text. This fetches the actual 61 + // post text so we can show a snippet in evidence rows. 55 62 async function fetchSubjectTexts( 56 - agent: BskyAgent, 63 + agent: AtpAgent, 57 64 notifications: AppBskyNotificationListNotifications.Notification[], 58 65 ) { 59 66 const uris = [ ··· 75 82 return texts 76 83 } 77 84 78 - export async function listNotifications(agent: BskyAgent): Promise<NormalizedNotification[]> { 85 + // Fetch the 50 most recent notifications, normalize into our flat shape, 86 + // enrich with subject text, and record into SQLite for historical pressure. 87 + export async function listNotifications(agent: AtpAgent): Promise<NormalizedNotification[]> { 79 88 const res = await agent.listNotifications({limit: 50}) 80 89 const subjectTexts = await fetchSubjectTexts(agent, res.data.notifications) 81 90 ··· 110 119 actorName: string 111 120 } 112 121 113 - export async function getActorProfiles(agent: BskyAgent, actorDids: string[]): Promise<Map<string, ActorRef>> { 122 + export async function getActorProfiles(agent: AtpAgent, actorDids: string[]): Promise<Map<string, ActorRef>> { 114 123 const unique = [...new Set(actorDids.filter(Boolean))] 115 124 const out = new Map<string, ActorRef>() 116 125 for (const batch of chunk(unique, 25)) { ··· 131 140 return out 132 141 } 133 142 134 - export async function resolveActor(agent: BskyAgent, query: string): Promise<ActorRef | undefined> { 135 - const trimmed = query.trim().replace(/^@/, '') 136 - if (!trimmed) return undefined 137 - 138 - if (trimmed.startsWith('did:')) { 139 - const profile = await agent.getProfile({actor: trimmed}) 140 - return actorRefFromProfile(profile.data) 141 - } 142 - 143 - if (trimmed.includes('.')) { 144 - try { 145 - const resolved = await agent.resolveHandle({handle: trimmed}) 146 - const profile = await agent.getProfile({actor: resolved.data.did}) 147 - return actorRefFromProfile(profile.data) 148 - } catch { 149 - // Fall through to typeahead. 150 - } 151 - } 152 - 153 - const typeahead = await agent.searchActorsTypeahead({q: trimmed, limit: 8}) 154 - const normalizedQuery = trimmed 155 - .toLowerCase() 156 - .normalize('NFKD') 157 - .replace(/[^\p{L}\p{N}]+/gu, ' ') 158 - .trim() 159 - const sorted = [...typeahead.data.actors].sort((a, b) => { 160 - const aHandle = a.handle.toLowerCase() 161 - const bHandle = b.handle.toLowerCase() 162 - const aName = (a.displayName || '').toLowerCase() 163 - const bName = (b.displayName || '').toLowerCase() 164 - const aExact = Number(aHandle === trimmed.toLowerCase() || aName === trimmed.toLowerCase()) 165 - const bExact = Number(bHandle === trimmed.toLowerCase() || bName === trimmed.toLowerCase()) 166 - if (bExact !== aExact) return bExact - aExact 167 - const aLoose = Number( 168 - aHandle.includes(trimmed.toLowerCase()) || 169 - aName.includes(trimmed.toLowerCase()) || 170 - normalizedQuery === 171 - (a.displayName || '') 172 - .toLowerCase() 173 - .normalize('NFKD') 174 - .replace(/[^\p{L}\p{N}]+/gu, ' ') 175 - .trim(), 176 - ) 177 - const bLoose = Number( 178 - bHandle.includes(trimmed.toLowerCase()) || 179 - bName.includes(trimmed.toLowerCase()) || 180 - normalizedQuery === 181 - (b.displayName || '') 182 - .toLowerCase() 183 - .normalize('NFKD') 184 - .replace(/[^\p{L}\p{N}]+/gu, ' ') 185 - .trim(), 186 - ) 187 - return bLoose - aLoose 188 - }) 189 - const best = sorted[0] 190 - return best ? actorRefFromProfile(best) : undefined 191 - } 192 - 193 143 export async function searchActorSuggestions( 194 - agent: BskyAgent, 144 + agent: AtpAgent, 195 145 query: string, 196 146 limit = 6, 197 147 ): Promise<ActorRef[]> { ··· 201 151 return res.data.actors.map(actorRefFromProfile) 202 152 } 203 153 204 - export async function listActivitySubscriptions(agent: BskyAgent) { 154 + export async function listActivitySubscriptions(agent: AtpAgent) { 205 155 const rows = new Map<string, ActivitySubscription>() 206 156 let cursor: string | undefined 207 157 do { ··· 222 172 return rows 223 173 } 224 174 225 - export async function listMutedActors(agent: BskyAgent) { 175 + export async function listMutedActors(agent: AtpAgent) { 226 176 const rows = new Set<string>() 227 177 let cursor: string | undefined 228 178 do { ··· 238 188 muted: boolean 239 189 } 240 190 241 - export async function getActorStates(agent: BskyAgent, actorDids: string[]): Promise<Map<string, ActorState>> { 191 + // Snapshot current subscription + mute state for a set of actors. 192 + // Used before and after apply to detect what actually changed. 193 + export async function getActorStates(agent: AtpAgent, actorDids: string[]): Promise<Map<string, ActorState>> { 242 194 const unique = [...new Set(actorDids.filter(Boolean))] 243 195 const [subscriptions, mutedActors] = await Promise.all([ 244 196 listActivitySubscriptions(agent), ··· 259 211 | {actorDid: string; kind: 'mute'; muted: boolean} 260 212 | {actorDid: string; kind: 'subscription'; subscription: {post: boolean; reply: boolean} | null} 261 213 214 + // Compare before/after snapshots — returns only fields that actually changed. 215 + // This is what verification reports to the user. 262 216 export function diffActorStates( 263 217 before: Map<string, ActorState>, 264 218 after: Map<string, ActorState>, ··· 291 245 })) 292 246 } 293 247 248 + // The core state-building function. Groups unread notifications by actor, 249 + // enriches with subscription/mute state and historical pressure from SQLite, 250 + // and produces the ManagementTarget[] that gets sent to the model. 251 + // Sorted: subscribed actors first, then by unread count, then by recency. 294 252 export function buildManagementTargets( 295 253 notifications: NormalizedNotification[], 296 254 subscriptionModes: Map<string, ActivitySubscription>,
+137 -45
src/client.ts
··· 1 + // Browser-side JS — transpiled from TS by Bun at startup and served as /app.js. 2 + // Handles: @ typeahead + actor selection, async propose/apply with progress states, 3 + // same-page section replacement, toast feedback. Enter submits, Shift+Enter for newline. 4 + 5 + type ToastDetail = { 6 + label: string 7 + href?: string 8 + detail: string 9 + } 10 + 1 11 type ToastPayload = { 2 12 description: string 3 13 verified: boolean 4 - label?: string 5 - href?: string 14 + details?: ToastDetail[] 6 15 text: string 7 16 } 8 17 ··· 13 22 profileUrl: string 14 23 } 15 24 25 + type SelectedActor = { 26 + did: string 27 + handle: string 28 + name: string 29 + } 30 + 16 31 type MentionQuery = { 17 32 query: string 18 33 start: number ··· 23 38 let mentionIndex = 0 24 39 let mentionFetchToken = 0 25 40 let mentionDebounce: number | undefined 26 - 27 - function cleanActorQuery(value: string) { 28 - return value 29 - .trim() 30 - .replace(/^@/, '') 31 - .replace(/[.?!,:;]+$/g, '') 32 - .replace(/(?:'s)?\s+(?:posts?|repl(?:y|ies)|notifications?)$/i, '') 33 - .trim() 34 - } 35 - 36 - function actorHint(message: string) { 37 - const handle = message.match(/@[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)+/) 38 - if (handle) return cleanActorQuery(handle[0]) 39 - const patterns = [ 40 - /(?:from|for)\s+(.+?)$/i, 41 - /(?:mute|unmute)\s+(.+?)$/i, 42 - /(?:subscribe to|unsubscribe from)\s+(.+?)$/i, 43 - ] 44 - for (const pattern of patterns) { 45 - const match = message.match(pattern) 46 - if (match?.[1]) { 47 - const candidate = cleanActorQuery(match[1]) 48 - if (candidate) return candidate 49 - } 50 - } 51 - return '' 52 - } 53 41 54 42 function feedbackRoot() { 55 43 return document.getElementById('proposal-feedback') ··· 68 56 return input instanceof HTMLTextAreaElement ? input : null 69 57 } 70 58 59 + function selectedActorsRoot() { 60 + return document.getElementById('selected-actors') 61 + } 62 + 63 + function selectedActorsInput() { 64 + const input = document.querySelector('#propose-form input[name="selectedActors"]') 65 + return input instanceof HTMLInputElement ? input : null 66 + } 67 + 68 + function selectedActors(): SelectedActor[] { 69 + const input = selectedActorsInput() 70 + if (!input?.value) return [] 71 + try { 72 + const parsed = JSON.parse(input.value) 73 + return Array.isArray(parsed) ? parsed.filter(Boolean) : [] 74 + } catch { 75 + return [] 76 + } 77 + } 78 + 79 + function renderSelectedActors(actors: SelectedActor[]) { 80 + const root = selectedActorsRoot() 81 + if (!root) return 82 + root.replaceChildren( 83 + ...actors.map(actor => { 84 + const chip = document.createElement('button') 85 + chip.type = 'button' 86 + chip.className = 'selected-actor' 87 + chip.dataset.removeActorDid = actor.did 88 + chip.title = `Remove @${actor.handle}` 89 + 90 + const label = document.createElement('span') 91 + label.className = 'selected-actor-label' 92 + label.textContent = `@${actor.handle}` 93 + const remove = document.createElement('span') 94 + remove.className = 'selected-actor-remove' 95 + remove.textContent = '×' 96 + chip.append(label, remove) 97 + return chip 98 + }), 99 + ) 100 + root.hidden = actors.length === 0 101 + } 102 + 103 + function setSelectedActors(actors: SelectedActor[]) { 104 + const input = selectedActorsInput() 105 + if (input) input.value = JSON.stringify(actors) 106 + renderSelectedActors(actors) 107 + } 108 + 109 + function syncSelectedActorsWithTextarea(textarea: HTMLTextAreaElement) { 110 + const next = selectedActors().filter(actor => textarea.value.includes(`@${actor.handle}`)) 111 + if (next.length !== selectedActors().length) { 112 + setSelectedActors(next) 113 + } 114 + } 115 + 71 116 function activeMention(textarea: HTMLTextAreaElement): MentionQuery | null { 72 117 const caret = textarea.selectionStart 73 118 const before = textarea.value.slice(0, caret) ··· 93 138 textarea.value = next 94 139 textarea.focus() 95 140 textarea.setSelectionRange(cursor, cursor) 141 + const actors = selectedActors() 142 + if (!actors.some(row => row.did === actor.did)) { 143 + setSelectedActors([...actors, {did: actor.did, handle: actor.handle, name: actor.name}]) 144 + } else { 145 + renderSelectedActors(actors) 146 + } 96 147 hideMentionMenu() 97 148 } 98 149 150 + function removeSelectedActor(actorDid: string) { 151 + const actors = selectedActors() 152 + const actor = actors.find(row => row.did === actorDid) 153 + if (!actor) return 154 + setSelectedActors(actors.filter(row => row.did !== actorDid)) 155 + const textarea = messageTextarea() 156 + if (!textarea) return 157 + const escaped = actor.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 158 + textarea.value = textarea.value 159 + .replace(new RegExp(`(^|\\s)@${escaped}\\b\\s*`, 'i'), '$1') 160 + .replace(/\s{2,}/g, ' ') 161 + .trim() 162 + textarea.focus() 163 + } 164 + 99 165 function renderMentionMenu(textarea: HTMLTextAreaElement, mention: MentionQuery, actors: ActorSuggestion[]) { 100 166 const root = mentionRoot() 101 167 if (!root || actors.length === 0) { ··· 127 193 128 194 async function loadMentionSuggestions(mention: MentionQuery) { 129 195 window.clearTimeout(mentionDebounce) 130 - if (!mention.query.trim()) { 131 - hideMentionMenu() 132 - return 133 - } 134 196 const token = ++mentionFetchToken 135 197 mentionDebounce = window.setTimeout(async () => { 136 198 try { 137 - const res = await fetch(`/api/actors/typeahead?q=${encodeURIComponent(mention.query)}`) 199 + const suffix = mention.query.trim() ? `?q=${encodeURIComponent(mention.query)}` : '' 200 + const res = await fetch(`/api/actors/typeahead${suffix}`) 138 201 if (!res.ok) throw new Error('typeahead failed') 139 202 const payload = (await res.json()) as {actors?: ActorSuggestion[]} 140 203 if (token !== mentionFetchToken) return ··· 162 225 feedback.replaceChildren(shell) 163 226 } 164 227 165 - function showProposalProgress(message: string) { 228 + function showProposalProgress(_message: string) { 166 229 const feedback = feedbackRoot() 167 230 if (!feedback) return () => {} 168 - const actor = actorHint(message) 231 + const actor = selectedActors()[0]?.handle || '' 169 232 const shell = document.createElement('div') 170 233 shell.className = 'loading-shell' 171 234 const title = document.createElement('div') ··· 204 267 205 268 const copy = document.createElement('div') 206 269 copy.className = 'toast-copy' 207 - if (payload.href && payload.label) { 208 - const link = document.createElement('a') 209 - link.className = 'actor-link' 210 - link.href = payload.href 211 - link.target = '_blank' 212 - link.rel = 'noopener noreferrer' 213 - link.textContent = payload.label 214 - copy.append(link, document.createTextNode(` ${payload.text}`)) 270 + if (payload.details?.length) { 271 + for (const detail of payload.details) { 272 + const line = document.createElement('div') 273 + if (detail.href) { 274 + const link = document.createElement('a') 275 + link.className = 'actor-link' 276 + link.href = detail.href 277 + link.target = '_blank' 278 + link.rel = 'noopener noreferrer' 279 + link.textContent = detail.label 280 + line.append(link, document.createTextNode(` ${detail.detail}`)) 281 + } else { 282 + line.textContent = `${detail.label}: ${detail.detail}` 283 + } 284 + copy.append(line) 285 + } 215 286 } else { 216 287 copy.textContent = payload.text 217 288 } ··· 345 416 document.addEventListener('input', event => { 346 417 const target = event.target 347 418 if (!(target instanceof HTMLTextAreaElement) || target.name !== 'message') return 419 + syncSelectedActorsWithTextarea(target) 348 420 const mention = activeMention(target) 349 421 if (!mention) { 350 422 hideMentionMenu() ··· 354 426 }) 355 427 356 428 document.addEventListener('click', event => { 429 + const target = event.target 430 + if (target instanceof HTMLElement) { 431 + const removeButton = target.closest('[data-remove-actor-did]') 432 + if (removeButton instanceof HTMLElement) { 433 + removeSelectedActor(String(removeButton.dataset.removeActorDid || '')) 434 + return 435 + } 436 + } 357 437 const root = mentionRoot() 358 438 if (!root || root.hidden) return 359 - const target = event.target 360 439 if (target instanceof Node && (root.contains(target) || target === messageTextarea())) return 361 440 hideMentionMenu() 362 441 }) ··· 394 473 } 395 474 }) 396 475 476 + document.addEventListener('keydown', event => { 477 + const textarea = messageTextarea() 478 + if (!(textarea instanceof HTMLTextAreaElement) || document.activeElement !== textarea) return 479 + const root = mentionRoot() 480 + if (root && !root.hidden) return 481 + if (event.key === 'Enter' && !event.shiftKey) { 482 + event.preventDefault() 483 + const form = textarea.closest('form') 484 + if (form) form.requestSubmit() 485 + } 486 + }) 487 + 397 488 document.addEventListener('submit', event => { 398 489 const form = event.target 399 490 if (!(form instanceof HTMLFormElement)) return ··· 415 506 } 416 507 }) 417 508 509 + renderSelectedActors(selectedActors()) 418 510 void loadQueue()
+130 -10
src/code-mode.ts
··· 1 + // Code-mode: the model writes JS against a typed SDK surface, validated and 2 + // executed in a QuickJS WASM sandbox. METHOD_DEFS is the single source of truth — 3 + // it drives the prompt (SDK_SURFACE), the string validator (ALLOWED_METHODS), 4 + // the sandbox stubs (SANDBOX_PREAMBLE), and the host replay table (METHOD_DISPATCH). 5 + 1 6 const METHOD_DEFS = [ 2 7 { 3 8 fq: 'agent.app.bsky.graph.muteActor', ··· 115 120 } 116 121 } 117 122 118 - export async function executeCode<TCtx extends object>(code: string, agent: unknown, ctx: TCtx) { 119 - validateCode(code) 120 - const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new ( 121 - ...args: string[] 122 - ) => (...fnArgs: unknown[]) => Promise<unknown> 123 - const runner = new AsyncFunction( 124 - 'agent', 125 - 'ctx', 126 - `"use strict";\n${code}\nreturn await run(agent, ctx);`, 123 + let _quickjs: Awaited<ReturnType<typeof import('quickjs-emscripten').getQuickJS>> | undefined 124 + 125 + async function getQuickJS() { 126 + if (!_quickjs) { 127 + const mod = await import('quickjs-emscripten') 128 + _quickjs = await mod.getQuickJS() 129 + } 130 + return _quickjs 131 + } 132 + 133 + // Derive short method name and namespace path from the fq string. 134 + // e.g. 'agent.app.bsky.graph.muteActor' → name: 'muteActor', path: ['app','bsky','graph'] 135 + function parseFq(fq: string) { 136 + const parts = fq.replace(/^agent\./, '').split('.') 137 + return {name: parts[parts.length - 1], path: parts.slice(0, -1)} 138 + } 139 + 140 + // The agent type is structural — any object matching the nested method shape works. 141 + // biome-ignore lint/suspicious/noExplicitAny: agent is structurally typed from METHOD_DEFS 142 + type AgentSurface = Record<string, any> 143 + 144 + type QueuedCall = {method: string; input: unknown} 145 + 146 + // Generated from METHOD_DEFS — maps short method names to real agent calls. 147 + const METHOD_DISPATCH: Record<string, (agent: AgentSurface, input: unknown) => Promise<unknown>> = 148 + Object.fromEntries( 149 + METHOD_DEFS.map(def => { 150 + const {name, path} = parseFq(def.fq) 151 + return [name, (agent: AgentSurface, input: unknown) => { 152 + let target = agent 153 + for (const segment of path) target = target[segment] 154 + return target[name](input) 155 + }] 156 + }), 127 157 ) 128 - return await runner(agent, ctx) 158 + 159 + // Generated from METHOD_DEFS — builds the nested agent stub for the sandbox. 160 + // Each method enqueues a {method, input} record and returns Promise.resolve(). 161 + const SANDBOX_PREAMBLE = (() => { 162 + const tree: Record<string, Record<string, string[]>> = {} 163 + for (const def of METHOD_DEFS) { 164 + const {name, path} = parseFq(def.fq) 165 + const ns = path.join('.') 166 + tree[ns] ??= {} 167 + tree[ns][name] ??= [] 168 + tree[ns][name].push(name) 169 + } 170 + // Group by namespace path to build nested object 171 + const grouped = new Map<string, string[]>() 172 + for (const def of METHOD_DEFS) { 173 + const {name, path} = parseFq(def.fq) 174 + const key = path.join('.') 175 + const existing = grouped.get(key) || [] 176 + existing.push(` ${name}: function(input) { __enqueue("${name}", JSON.stringify(input)); return Promise.resolve(); }`) 177 + grouped.set(key, existing) 178 + } 179 + // Build nested object string: app.bsky.graph → app: { bsky: { graph: { ... } } } 180 + // We know all paths are app.bsky.X so we can group by the third segment 181 + const namespaces = new Map<string, string[]>() 182 + for (const [ns, methods] of grouped) { 183 + const parts = ns.split('.') 184 + const leaf = parts[parts.length - 1] 185 + namespaces.set(leaf, methods) 186 + } 187 + const nsBlocks = [...namespaces.entries()] 188 + .map(([leaf, methods]) => ` ${leaf}: {\n${methods.join(',\n')}\n }`) 189 + .join(',\n') 190 + return `\nvar agent = {\n app: {\n bsky: {\n${nsBlocks}\n }\n }\n};\n` 191 + })() 192 + 193 + const SANDBOX_TIMEOUT_MS = 5_000 194 + 195 + export async function executeCode<TCtx extends object>(code: string, agent: AgentSurface, ctx: TCtx) { 196 + validateCode(code) 197 + const QuickJS = await getQuickJS() 198 + const vm = QuickJS.newContext() 199 + vm.runtime.setMemoryLimit(16 * 1024 * 1024) 200 + vm.runtime.setMaxStackSize(1024 * 1024) 201 + 202 + const deadline = Date.now() + SANDBOX_TIMEOUT_MS 203 + vm.runtime.setInterruptHandler(() => { 204 + if (Date.now() > deadline) return true 205 + return false 206 + }) 207 + 208 + const callQueue: QueuedCall[] = [] 209 + 210 + try { 211 + const enqueueHandle = vm.newFunction('__enqueue', (methodHandle, inputHandle) => { 212 + const method = vm.dump(methodHandle) 213 + const inputJson = vm.dump(inputHandle) 214 + const input = typeof inputJson === 'string' ? JSON.parse(inputJson) : inputJson 215 + if (typeof method !== 'string' || !(method in METHOD_DISPATCH)) { 216 + return 217 + } 218 + callQueue.push({method, input}) 219 + }) 220 + vm.setProp(vm.global, '__enqueue', enqueueHandle) 221 + enqueueHandle.dispose() 222 + 223 + const ctxJson = JSON.stringify(ctx) 224 + const fullCode = `${SANDBOX_PREAMBLE}\nvar ctx = ${ctxJson};\n${code}\nrun(agent, ctx);` 225 + 226 + const result = vm.evalCode(fullCode) 227 + if (result.error) { 228 + const err = vm.dump(result.error) 229 + result.error.dispose() 230 + throw new CodeValidationError(`sandbox error: ${typeof err === 'string' ? err : JSON.stringify(err)}`) 231 + } 232 + result.value.dispose() 233 + 234 + const pending = vm.runtime.executePendingJobs() 235 + if (pending.error) { 236 + const err = vm.dump(pending.error) 237 + pending.error.dispose() 238 + throw new CodeValidationError(`sandbox async error: ${typeof err === 'string' ? err : JSON.stringify(err)}`) 239 + } 240 + } finally { 241 + vm.dispose() 242 + } 243 + 244 + for (const call of callQueue) { 245 + const dispatch = METHOD_DISPATCH[call.method] 246 + if (!dispatch) throw new CodeValidationError(`unknown method in queue: ${call.method}`) 247 + await dispatch(agent, call.input) 248 + } 129 249 }
+48 -18
src/recommend-prompt.ts
··· 1 + // Two system prompts for two different tasks, sharing common text rules and SDK surface. 2 + // Tool schemas define the structured output — Claude is forced to respond through them. 3 + 1 4 import type {Tool} from '@anthropic-ai/sdk/resources/messages/messages' 2 5 3 6 import {SDK_SURFACE} from './code-mode' 4 7 5 - export const SYSTEM = ` 8 + const SHARED = ` 6 9 You help a Bluesky user manage their notifications. 7 - 8 - Propose concrete notification-management actions the user can review and apply. 9 10 Focus on changes to notification state, not inbox summaries or feed narration. 10 11 11 - You will receive: 12 - - per-actor management targets with unread pressure, history, subscription state, and mute state 13 - - the typed SDK surface you may write code against 14 - 15 - Your job is to write code that calls the SDK to reduce notification noise. 16 - Each recommendation must include working code that defines \`async function run(agent, ctx)\`. 17 - 18 12 Use the actor's DID (actorDid) in SDK calls, not their handle. 19 13 Use their display name (actorName) or handle (actorHandle) in descriptions for the user. 20 14 21 15 ${SDK_SURFACE} 22 16 23 - Prefer: 24 - - turning off replies for subscriptions that are too chatty 25 - - turning off posts and replies entirely for low-value subscriptions 26 - - muting clearly noisy accounts 27 - 28 17 When writing user-facing text: 29 18 - do not mention "selected notifications" or similar internal evidence-selection wording 30 19 - do not cite raw rolling counters like "56 in 24h" or "56 in 7d" 31 20 - prefer concrete actor names and the management action itself 32 21 - do not mention DIDs, internal targets, code generation, or other implementation details 22 + ` 33 23 34 - For direct user requests: 24 + export const SYSTEM_QUEUE_SUGGESTIONS = `${SHARED} 25 + You will receive per-actor management targets with unread pressure, history, subscription state, and mute state. 26 + Propose up to 3 concrete notification-management actions the user can review and apply. 27 + Each recommendation must include working code that defines \`async function run(agent, ctx)\`. 28 + 29 + Prefer subscription changes over muting: 30 + - if an actor has subscriptionPosts or subscriptionReplies on, turn those off first 31 + - turning off replies for subscriptions that are too chatty 32 + - turning off posts and replies entirely for high-volume or low-value subscriptions 33 + - only mute if subscriptions are already off and the actor is still generating noise 34 + 35 + Do not recommend: 36 + - subscription changes for an actor whose subscriptions are already off — unless they are still generating notifications through replies to your posts or mentions 37 + - actions that would not reduce future notification volume — unread notifications may be historical artifacts from before a subscription was changed 38 + - if there is nothing useful to change, return an empty recommendations array 39 + ` 40 + 41 + export const SYSTEM_HANDLE_USER_REQUEST = `${SHARED} 42 + You will receive one user request and per-actor management targets. 43 + 44 + Decide the response kind before anything else: 45 + - if the user is asking what this app can do, how to use it, or for examples, return kind "help" 46 + - if the user has not actually asked for a notification change, do not invent an action 47 + - for vague, fragmentary, or content-free inputs, return kind "refusal" with a short clarification and a few example asks 48 + - help responses should be short, concrete, limited to the app's real surface today, and never include code 49 + - in help/refusal example asks, use explicit @handles for actor-targeted requests 50 + - help/refusal example asks should either be mark-all-read or a single-actor request against one explicit @handle 51 + - do not suggest fuzzy actor names, multi-actor batch requests, or broad requests the current UI does not support 52 + 53 + If the request is clearly asking for a notification change, return kind "action": 35 54 - it is valid to propose actor-level subscription changes even when there are zero unread notifications 36 - - when the action targets actor state rather than specific unread items, return actorDids and sourceUris may be empty 55 + - when the action targets actor state rather than specific unread items, actorDids and sourceUris may be empty 37 56 - some actors may not allow subscriptions from this viewer; check subscriptionEligible, subscriptionPolicy, and subscriptionBlockedReason before proposing any subscription mutation 38 57 - if the requested action is impossible or already blocked by current actor policy / relationship state, do not return code; explain clearly why it cannot be done 58 + - each action must include working code that defines \`async function run(agent, ctx)\` 39 59 ` 40 60 41 61 export const recommendationTool = { ··· 69 89 input_schema: { 70 90 type: 'object', 71 91 properties: { 92 + kind: { 93 + type: 'string', 94 + enum: ['action', 'help', 'refusal'], 95 + description: 'Use action for a real mutation proposal, help for capability/how-to answers, refusal when the request cannot be carried out.', 96 + }, 72 97 message: {type: 'string', description: 'Brief explanation of what the proposed action will do.'}, 98 + examples: { 99 + type: 'array', 100 + items: {type: 'string'}, 101 + description: 'For help responses, a few example asks the user can try.', 102 + }, 73 103 actorDids: {type: 'array', items: {type: 'string'}, description: 'Actor DIDs this action will affect.'}, 74 104 sourceUris: {type: 'array', items: {type: 'string'}, description: 'URIs of notifications relevant to this action.'}, 75 105 description: {type: 'string', description: 'Short title for the action.'}, ··· 80 110 description: 'If the requested action cannot be done, explain why here and leave code empty.', 81 111 }, 82 112 }, 83 - required: ['message'] as string[], 113 + required: ['kind', 'message'] as string[], 84 114 }, 85 115 } satisfies Tool
+61 -21
src/recommend.ts
··· 1 + // LLM integration — two generation paths through one shared transport: 2 + // generateRecommendations() → SYSTEM_QUEUE_SUGGESTIONS → emit_recommendations tool 3 + // proposeAction() → SYSTEM_HANDLE_USER_REQUEST → emit_proposal tool 4 + // Both force tool_use so Claude responds with structured data, not freeform text. 5 + // Response fields are defensively narrowed (no Zod yet — manual typeof checks). 6 + 1 7 import Anthropic from '@anthropic-ai/sdk' 2 8 import type {Tool, ToolChoice} from '@anthropic-ai/sdk/resources/messages/messages' 3 9 4 10 import {actorProfileUrl} from './bluesky' 5 11 import {validateCode} from './code-mode' 6 - import {SYSTEM, proposalTool, recommendationTool} from './recommend-prompt' 12 + import {SYSTEM_HANDLE_USER_REQUEST, SYSTEM_QUEUE_SUGGESTIONS, proposalTool, recommendationTool} from './recommend-prompt' 7 13 import type { 8 14 ActionProposal, 9 15 ActorRef, ··· 25 31 } 26 32 27 33 type ProposalToolResponse = { 34 + kind?: unknown 28 35 message?: unknown 36 + examples?: unknown 29 37 actorDids?: unknown 30 38 sourceUris?: unknown 31 39 description?: unknown ··· 41 49 prompt: string, 42 50 tool: Tool, 43 51 toolChoice: ToolChoice, 52 + system: string, 44 53 ): Promise<TResponse> { 45 54 const res = await anthropic.messages.create({ 46 55 model, 47 - max_tokens: 1400, 48 - system: SYSTEM, 56 + max_tokens: 2400, 57 + system, 49 58 messages: [{role: 'user', content: prompt}], 50 59 tools: [tool], 51 60 tool_choice: toolChoice, ··· 54 63 if (!block || block.type !== 'tool_use') { 55 64 throw new Error(`missing tool response for ${tool.name}`) 56 65 } 66 + console.info(`[toolResponse] ${tool.name} stop=${res.stop_reason} usage=${JSON.stringify(res.usage)}`) 57 67 return block.input as TResponse 58 68 } 59 69 ··· 124 134 }) 125 135 } 126 136 127 - function targetPayload(targets: ManagementTarget[]) { 128 - return targets.slice(0, 16).map(target => ({ 137 + function targetPayload(targets: ManagementTarget[], maxTargets = 16) { 138 + return targets.slice(0, maxTargets).map(target => ({ 129 139 actorDid: target.actorDid, 130 140 actorHandle: target.actorHandle, 131 141 actorName: target.actorName, ··· 135 145 reasons: target.reasons, 136 146 subscriptionPosts: target.subscriptionPosts, 137 147 subscriptionReplies: target.subscriptionReplies, 148 + subscriptionPolicy: target.subscriptionPolicy, 149 + subscriptionEligible: target.subscriptionEligible, 150 + subscriptionBlockedReason: target.subscriptionBlockedReason, 138 151 muted: target.muted, 139 - sourceUris: target.sourceUris, 140 - examples: target.examples, 152 + sourceUris: target.sourceUris.slice(0, 5), 153 + examples: target.examples.slice(0, 2), 141 154 })) 142 155 } 143 156 ··· 160 173 targets: ManagementTarget[], 161 174 ) { 162 175 if (unread.length === 0 || targets.length === 0) return [] 176 + // Don't send targets with subscriptions already off to the queue — 177 + // their unreads are likely historical and mislead the model into recommending mutes. 178 + const actionableTargets = targets.filter(t => 179 + t.subscriptionPosts || t.subscriptionReplies || !t.currentUnreadCount, 180 + ) 181 + if (actionableTargets.length === 0) return [] 163 182 const notificationsByUri = new Map(unread.map(row => [row.uri, row])) 164 183 const targetsByDid = targetByDid(targets) 165 184 const payload = JSON.stringify({ 166 185 unreadCount: unread.length, 167 - targets: targetPayload(targets), 186 + targets: targetPayload(actionableTargets, 8), 168 187 }) 169 188 const result = await toolResponse<RecommendationToolResponse>( 170 189 payload, 171 190 recommendationTool, 172 191 recommendationToolChoice, 192 + SYSTEM_QUEUE_SUGGESTIONS, 173 193 ) 174 194 if (!Array.isArray(result.recommendations)) { 195 + console.warn('[recommendations] model returned non-array:', result) 175 196 return [] 176 197 } 177 198 const recommendations: Recommendation[] = [] ··· 180 201 const description = typeof row.description === 'string' ? row.description.trim() : '' 181 202 const why = typeof row.why === 'string' ? row.why : '' 182 203 const code = typeof row.code === 'string' ? row.code.trim() : '' 183 - const sourceUris = Array.isArray(row.sourceUris) 184 - ? row.sourceUris.filter((v: unknown): v is string => typeof v === 'string' && notificationsByUri.has(v)) 185 - : [] 186 - if (!description || !why.trim() || !code || sourceUris.length === 0) continue 204 + const rawSourceUris = Array.isArray(row.sourceUris) ? row.sourceUris : [] 205 + const sourceUris = rawSourceUris.filter((v: unknown): v is string => typeof v === 'string' && notificationsByUri.has(v)) 206 + if (!description || !why.trim() || !code || sourceUris.length === 0) { 207 + console.warn('[recommendations] dropped:', {description: description.slice(0, 60), hasWhy: !!why.trim(), hasCode: !!code, rawUris: rawSourceUris.length, matchedUris: sourceUris.length}) 208 + continue 209 + } 187 210 try { 188 211 validateCode(code) 189 - } catch { 212 + } catch (err) { 213 + console.warn('[recommendations] code validation failed:', description.slice(0, 60), err instanceof Error ? err.message : err) 190 214 continue 191 215 } 192 216 const actorDids = actorDidsForUris(sourceUris, notificationsByUri) ··· 222 246 unreadCount: unread.length, 223 247 targets: targetPayload(targets), 224 248 }) 225 - const result = await toolResponse<ProposalToolResponse>(payload, proposalTool, proposalToolChoice) 249 + const result = await toolResponse<ProposalToolResponse>(payload, proposalTool, proposalToolChoice, SYSTEM_HANDLE_USER_REQUEST) 226 250 251 + const kind = 252 + result?.kind === 'action' || result?.kind === 'help' || result?.kind === 'refusal' 253 + ? result.kind 254 + : 'refusal' 255 + const message = typeof result?.message === 'string' ? result.message.trim() : '' 256 + const examples = Array.isArray(result?.examples) 257 + ? result.examples 258 + .filter((value: unknown): value is string => typeof value === 'string' && value.trim().length > 0) 259 + .slice(0, 5) 260 + : [] 227 261 let recommendation: Recommendation | undefined 228 262 const notificationsByUri = new Map(unread.map(row => [row.uri, row])) 229 263 const description = typeof result?.description === 'string' ? result.description.trim() : '' ··· 238 272 ? result.sourceUris.filter((v: unknown): v is string => typeof v === 'string') 239 273 : [] 240 274 const sourceUris = requestedSourceUris.filter((uri: string) => notificationsByUri.has(uri)) 275 + if (kind === 'help') { 276 + return { 277 + kind: 'help', 278 + message: message || 'Here are a few things I can help you change in your Bluesky notifications.', 279 + examples, 280 + } 281 + } 241 282 if (description && why && code && (sourceUris.length > 0 || requestedActorDids.length > 0)) { 242 283 try { 243 284 validateCode(code) ··· 250 291 : '' 251 292 if (blockedReason) { 252 293 return { 294 + kind: 'refusal', 253 295 message: blockedReason, 254 296 } 255 297 } ··· 277 319 } 278 320 279 321 return { 322 + kind: recommendation ? 'action' : 'refusal', 280 323 message: 281 - typeof result?.message === 'string' && result.message.trim() 282 - ? result.message 283 - : unavailableReason 284 - ? unavailableReason 285 - : recommendation 286 - ? `Proposed: ${recommendation.description}.` 287 - : '', 324 + message || 325 + unavailableReason || 326 + (recommendation ? `Proposed: ${recommendation.description}.` : 'I couldn’t turn that into a notification change.'), 327 + examples: recommendation ? undefined : examples, 288 328 recommendation, 289 329 } 290 330 }
+6 -7
src/render/page.ts
··· 14 14 <meta charset="utf-8"> 15 15 <meta name="viewport" content="width=device-width, initial-scale=1"> 16 16 <title>noti</title> 17 - <link rel="preconnect" href="https://fonts.googleapis.com"> 18 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 19 - <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;700;800&display=swap" rel="stylesheet"> 17 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔔</text></svg>"> 20 18 <style>${styles}</style> 21 19 </head> 22 20 <body> ··· 32 30 </div> 33 31 34 32 <section class="ask" id="ask-root"> 35 - <div class="section-kicker">manage</div> 36 33 <div class="ask-title">ask noti</div> 37 - <div class="ask-copy">for a notification change, like muting someone or turning posts or replies on or off.</div> 34 + <div class="ask-copy">to mute an account, or change whose posts or replies you get notified about.</div> 38 35 <form class="ask-form" method="post" action="/propose" id="propose-form"> 39 36 <div class="ask-input-shell"> 37 + <div class="selected-actors" id="selected-actors" hidden></div> 38 + <input type="hidden" name="selectedActors" value="[]"> 40 39 <textarea name="message" placeholder="${escapeHtml(state.placeholder)}"></textarea> 41 40 <div class="mention-menu" id="actor-suggestions" hidden></div> 42 41 </div> 43 42 <div class="ask-actions"> 44 43 <div class="ask-hint">type <code>@</code> to look up an account</div> 45 - <button class="button propose" type="submit">suggest</button> 44 + <button class="button propose" type="submit">suggest action</button> 46 45 </div> 47 46 </form> 48 47 <div id="proposal-feedback">${proposalBlock(state.proposal)}</div> ··· 54 53 </div> 55 54 <section class="queue" id="queue-root">${queue}</section> 56 55 <div id="subscriptions-root">${subscriptions}</div> 57 - <a class="footer-link" href="https://bsky.app/notifications" target="_blank" rel="noopener noreferrer">view in Bluesky ↗</a> 56 + <a class="footer-link" href="https://bsky.app/settings/notifications" target="_blank" rel="noopener noreferrer">configure notification settings in Bluesky 🦋 ↗</a> 58 57 <div class="toast-stack" id="toast-stack" aria-live="polite"></div> 59 58 </div> 60 59 <script type="module" src="/app.js"></script>
+17 -3
src/render/queue.ts
··· 147 147 148 148 export function proposalBlock(proposal?: ActionProposal) { 149 149 if (!proposal) return '' 150 - const message = proposal.message ? `<div class="assistant-message">${escapeHtml(proposal.message)}</div>` : '' 151 - const card = proposal.recommendation ? recommendationItem(proposal.recommendation, true) : '' 152 - return `${message}${card}` 150 + const message = proposal.message ? `<div class="assistant-message">${escapeHtml(proposal.message).replace(/\n/g, '<br>')}</div>` : '' 151 + const examples = 152 + proposal.examples?.length 153 + ? `<ul class="help-examples">${proposal.examples.map(example => `<li><code>${escapeHtml(example)}</code></li>`).join('')}</ul>` 154 + : '' 155 + const helpCard = 156 + examples 157 + ? ` 158 + <section class="help-card"> 159 + <div class="queue-kicker">${proposal.kind === 'help' ? 'how to use it' : 'try asking'}</div> 160 + <div class="help-title">${proposal.kind === 'help' ? 'Try one of these' : 'For example'}</div> 161 + ${examples} 162 + </section> 163 + ` 164 + : '' 165 + const card = proposal.kind === 'action' && proposal.recommendation ? recommendationItem(proposal.recommendation, true) : '' 166 + return `${message}${helpCard}${card}` 153 167 } 154 168 155 169 export function renderQueue(state: Pick<AppState, 'recommendations' | 'queueState'>) {
+56 -1
src/render/styles.ts
··· 103 103 .ask-input-shell { 104 104 position: relative; 105 105 } 106 + .selected-actors { 107 + display: flex; 108 + flex-wrap: wrap; 109 + gap: 8px; 110 + margin: 0 0 10px; 111 + } 112 + .selected-actor { 113 + display: inline-flex; 114 + align-items: center; 115 + gap: 8px; 116 + border: 1px solid #3d5f84; 117 + border-radius: 999px; 118 + background: rgba(24,42,61,0.92); 119 + color: var(--text); 120 + font: inherit; 121 + padding: 6px 10px; 122 + cursor: pointer; 123 + } 124 + .selected-actor:hover { 125 + border-color: #5ea3ff; 126 + background: rgba(29,51,75,0.96); 127 + } 128 + .selected-actor-label { 129 + font-size: 13px; 130 + font-weight: 700; 131 + line-height: 1; 132 + } 133 + .selected-actor-remove { 134 + color: var(--muted); 135 + font-size: 14px; 136 + line-height: 1; 137 + } 106 138 .ask-actions { 107 139 display:flex; 108 140 align-items:center; ··· 193 225 color:#d7e2f0; 194 226 line-height: 1.5; 195 227 font-size: 15px; 228 + } 229 + .help-card { 230 + margin-top: 14px; 231 + border: 1px solid var(--border); 232 + background: linear-gradient(180deg, rgba(18,25,34,0.96), rgba(11,16,23,0.96)); 233 + border-radius: 18px; 234 + padding: 16px 18px; 235 + } 236 + .help-title { 237 + font-size: 22px; 238 + font-weight: 800; 239 + letter-spacing: -0.03em; 240 + margin-bottom: 10px; 241 + } 242 + .help-examples { 243 + margin: 0; 244 + padding-left: 18px; 245 + color: var(--muted); 246 + display: grid; 247 + gap: 8px; 248 + } 249 + .help-examples code { 250 + font-size: 13px; 196 251 } 197 252 #proposal-feedback { 198 253 min-height: 0; ··· 380 435 } 381 436 .toast-stack { 382 437 position: fixed; 383 - right: 16px; 438 + left: 16px; 384 439 bottom: 16px; 385 440 display: grid; 386 441 gap: 10px;
+211 -82
src/server.ts
··· 1 + // Request orchestrator — Bun HTTP server that wires together state building, 2 + // LLM generation, sandbox execution, and verification. Routes: 3 + // GET / → page load (builds state, renders, kicks off queue generation) 4 + // GET /queue → lazy-loaded recommendation cards 5 + // POST /propose → user ask → action | help | refusal 6 + // POST /apply → execute code → verify → toast result 7 + // POST /mark-all-read → updateSeen shortcut 8 + // GET /api/actors/typeahead → @ mention suggestions 9 + 1 10 import { 2 11 buildManagementTargets, 3 12 createAgent, ··· 7 16 listActivitySubscriptions, 8 17 listMutedActors, 9 18 listNotifications, 10 - resolveActor, 11 19 searchActorSuggestions, 12 20 } from './bluesky' 13 - import {extractActorQueries} from './actor-queries' 14 21 import {executeCode} from './code-mode' 15 22 import {proposeAction, generateRecommendations} from './recommend' 16 23 import {renderPage, renderQueue} from './render/index' 17 - import type {ActionProposal, AppState, ApplyResultLine, ManagementTarget, Recommendation} from './types' 24 + import {knownActors} from './state' 25 + import type { 26 + ActionProposal, 27 + ActorSelection, 28 + AppState, 29 + ApplyResultLine, 30 + ManagementTarget, 31 + Recommendation, 32 + } from './types' 18 33 19 34 const port = Number(process.env.PORT || 8000) 20 35 const clientScript = new Bun.Transpiler({loader: 'ts'}).transformSync( ··· 45 60 type ApplyVerification = { 46 61 verified: boolean 47 62 details: ApplyResultLine[] 63 + } 64 + 65 + type ActorSuggestion = ActorSelection & { 66 + profileUrl: string 48 67 } 49 68 50 69 const recommendationCache = new Map<string, {recommendations: Recommendation[]; at: number}>() ··· 89 108 } 90 109 91 110 function toastPayload(description: string, verified: boolean, details: ApplyResultLine[]) { 92 - const primary = details[0] 93 111 return { 94 112 description, 95 113 verified, 96 - label: primary?.label || '', 97 - href: primary?.href || '', 98 - text: primary?.detail || (verified ? 'Done.' : 'No state change detected.'), 114 + details: details.map(d => ({label: d.label, href: d.href, detail: d.detail})), 115 + text: details.length 116 + ? details.map(d => `${d.label}: ${d.detail}`).join('. ') 117 + : verified ? 'Done.' : 'No state change detected.', 99 118 } 100 119 } 101 120 ··· 200 219 } 201 220 } 202 221 203 - type ProposalTargetResolution = { 204 - targets: ManagementTarget[] 205 - resolvedActorDid?: string 206 - unresolvedQuery?: string 222 + function normalizeActorSelection(value: unknown): ActorSelection | null { 223 + if (!value || typeof value !== 'object') return null 224 + const row = value as Record<string, unknown> 225 + const did = typeof row.did === 'string' ? row.did.trim() : '' 226 + const handle = typeof row.handle === 'string' ? row.handle.trim() : '' 227 + const name = typeof row.name === 'string' ? row.name.trim() : '' 228 + if (!did || !handle) return null 229 + return { 230 + did, 231 + handle, 232 + name: name || handle, 233 + } 207 234 } 208 235 209 - async function extendTargetsForProposal(base: BaseState, message: string): Promise<ProposalTargetResolution> { 210 - const queries = extractActorQueries(message) 211 - if (!queries.length) return {targets: base.targets} 236 + function parseSelectedActors(form: FormData): ActorSelection[] { 237 + const raw = String(form.get('selectedActors') || '[]') 238 + try { 239 + const parsed = JSON.parse(raw) 240 + if (!Array.isArray(parsed)) return [] 241 + const out: ActorSelection[] = [] 242 + const seen = new Set<string>() 243 + for (const row of parsed) { 244 + const actor = normalizeActorSelection(row) 245 + if (!actor || seen.has(actor.did)) continue 246 + seen.add(actor.did) 247 + out.push(actor) 248 + } 249 + return out 250 + } catch { 251 + return [] 252 + } 253 + } 254 + 255 + function looksActorTargeted(message: string) { 256 + const lower = message.toLowerCase() 257 + return ( 258 + /(^|\s)@[a-z0-9._-]/i.test(message) || 259 + /\b(mute|unmute|subscribe|unsubscribe)\b/i.test(lower) || 260 + (/\b(from|for)\b/i.test(lower) && /\b(posts?|repl(?:y|ies)|notifications?)\b/i.test(lower)) 261 + ) 262 + } 212 263 264 + function localActorSuggestions(base: BaseState, query: string, limit = 8): ActorSuggestion[] { 265 + const lowered = query.trim().toLowerCase() 266 + const known = knownActors(query, Math.max(limit * 3, 18)) 267 + const existing = new Map(base.targets.map(target => [target.actorDid, target])) 268 + const rows = new Map<string, (ActorSuggestion & { 269 + activeSubscription: number 270 + currentTarget: number 271 + currentUnreadCount: number 272 + recent24hCount: number 273 + recent7dCount: number 274 + allTimeCount: number 275 + })>() 276 + 277 + for (const actor of known) { 278 + if (lowered) { 279 + const haystack = `${actor.handle} ${actor.name}`.toLowerCase() 280 + if (!haystack.includes(lowered)) continue 281 + } 282 + const target = existing.get(actor.did) 283 + rows.set(actor.did, { 284 + did: actor.did, 285 + handle: actor.handle, 286 + name: actor.name, 287 + profileUrl: `https://bsky.app/profile/${encodeURIComponent(actor.handle)}`, 288 + activeSubscription: Number(Boolean(target?.subscriptionPosts || target?.subscriptionReplies)), 289 + currentTarget: Number(Boolean(target)), 290 + currentUnreadCount: target?.currentUnreadCount || 0, 291 + recent24hCount: Math.max(actor.recent24hCount, target?.recent24hCount || 0), 292 + recent7dCount: Math.max(actor.recent7dCount, target?.recent7dCount || 0), 293 + allTimeCount: Math.max(actor.allTimeCount, target?.allTimeCount || 0), 294 + }) 295 + } 296 + 297 + for (const target of base.targets) { 298 + if (lowered) { 299 + const haystack = `${target.actorHandle} ${target.actorName}`.toLowerCase() 300 + if (!haystack.includes(lowered)) continue 301 + } 302 + const current = rows.get(target.actorDid) 303 + rows.set(target.actorDid, { 304 + did: target.actorDid, 305 + handle: target.actorHandle, 306 + name: target.actorName, 307 + profileUrl: `https://bsky.app/profile/${encodeURIComponent(target.actorHandle || target.actorDid)}`, 308 + activeSubscription: Number(Boolean(target.subscriptionPosts || target.subscriptionReplies)), 309 + currentTarget: 1, 310 + currentUnreadCount: target.currentUnreadCount, 311 + recent24hCount: Math.max(target.recent24hCount, current?.recent24hCount || 0), 312 + recent7dCount: Math.max(target.recent7dCount, current?.recent7dCount || 0), 313 + allTimeCount: Math.max(target.allTimeCount, current?.allTimeCount || 0), 314 + }) 315 + } 316 + 317 + return [...rows.values()] 318 + .sort((a, b) => { 319 + return ( 320 + b.activeSubscription - a.activeSubscription || 321 + b.currentTarget - a.currentTarget || 322 + b.recent24hCount - a.recent24hCount || 323 + b.recent7dCount - a.recent7dCount || 324 + b.allTimeCount - a.allTimeCount || 325 + a.name.localeCompare(b.name) 326 + ) 327 + }) 328 + .slice(0, limit) 329 + .map(({activeSubscription, currentTarget, currentUnreadCount, recent24hCount, recent7dCount, allTimeCount, ...actor}) => actor) 330 + } 331 + 332 + async function enrichTargetsForProposal(base: BaseState, selectedActors: ActorSelection[]): Promise<ManagementTarget[]> { 333 + if (!selectedActors.length) return base.targets 213 334 const existing = new Map(base.targets.map(target => [target.actorDid, target])) 214 335 const agent = await createAgent() 215 - for (const query of queries) { 216 - try { 217 - const actor = await resolveActor(agent, query) 218 - if (!actor) continue 219 - const profile = await agent.getProfile({actor: actor.did}) 220 - const allowSubscriptions = profile.data.associated?.activitySubscription?.allowSubscriptions 221 - const viewer = profile.data.viewer 222 - const subscriptionEligible = 223 - !allowSubscriptions || 224 - allowSubscriptions === 'all' || 225 - (allowSubscriptions === 'followers' && Boolean(viewer?.following)) || 226 - (allowSubscriptions === 'mutuals' && Boolean(viewer?.following) && Boolean(viewer?.followedBy)) 227 - const subscriptionBlockedReason = 228 - allowSubscriptions === 'mutuals' && !subscriptionEligible 229 - ? `${actor.name} only allows notification subscriptions from mutuals, and they do not follow you back.` 336 + const actorStates = await getActorStates( 337 + agent, 338 + selectedActors.map(actor => actor.did), 339 + ) 340 + let targets = [...base.targets] 341 + 342 + for (const actor of selectedActors) { 343 + const profile = await agent.getProfile({actor: actor.did}) 344 + const allowSubscriptions = profile.data.associated?.activitySubscription?.allowSubscriptions 345 + const viewer = profile.data.viewer 346 + const subscriptionEligible = 347 + !allowSubscriptions || 348 + allowSubscriptions === 'all' || 349 + (allowSubscriptions === 'followers' && Boolean(viewer?.following)) || 350 + (allowSubscriptions === 'mutuals' && Boolean(viewer?.following) && Boolean(viewer?.followedBy)) 351 + const subscriptionBlockedReason = 352 + allowSubscriptions === 'mutuals' && !subscriptionEligible 353 + ? `${actor.name} only allows notification subscriptions from mutuals, and they do not follow you back.` 230 354 : allowSubscriptions === 'followers' && !subscriptionEligible 231 355 ? `${actor.name} only allows notification subscriptions from followers, and you are not following them.` 232 - : undefined 233 - if (existing.has(actor.did)) { 234 - return { 235 - targets: base.targets.map(target => 236 - target.actorDid === actor.did 237 - ? { 238 - ...target, 239 - subscriptionPolicy: allowSubscriptions, 240 - subscriptionEligible, 241 - subscriptionBlockedReason, 242 - } 243 - : target, 244 - ), 245 - resolvedActorDid: actor.did, 246 - } 247 - } 248 - const state = (await getActorStates(agent, [actor.did])).get(actor.did) 249 - return { 250 - targets: [ 251 - ...base.targets, 252 - { 253 - actorDid: actor.did, 254 - actorHandle: actor.handle, 255 - actorName: actor.name, 256 - sourceUris: [], 257 - currentUnreadCount: 0, 258 - recent24hCount: 0, 259 - recent7dCount: 0, 260 - allTimeCount: 0, 261 - reasons: {}, 262 - subscriptionPosts: Boolean(state?.subscription?.post), 263 - subscriptionReplies: Boolean(state?.subscription?.reply), 264 - subscriptionPolicy: allowSubscriptions, 265 - subscriptionEligible, 266 - subscriptionBlockedReason, 267 - muted: Boolean(state?.muted), 268 - examples: [], 269 - }, 270 - ], 271 - resolvedActorDid: actor.did, 272 - } 273 - } catch (error) { 274 - console.error('actor resolution failed', query, error) 356 + : undefined 357 + const state = actorStates.get(actor.did) 358 + const current = existing.get(actor.did) 359 + 360 + if (current) { 361 + targets = targets.map(target => 362 + target.actorDid === actor.did 363 + ? { 364 + ...target, 365 + actorHandle: actor.handle || target.actorHandle, 366 + actorName: actor.name || target.actorName, 367 + subscriptionPolicy: allowSubscriptions, 368 + subscriptionEligible, 369 + subscriptionBlockedReason, 370 + } 371 + : target, 372 + ) 373 + continue 275 374 } 375 + 376 + targets.push({ 377 + actorDid: actor.did, 378 + actorHandle: actor.handle, 379 + actorName: actor.name, 380 + sourceUris: [], 381 + currentUnreadCount: 0, 382 + recent24hCount: 0, 383 + recent7dCount: 0, 384 + allTimeCount: 0, 385 + reasons: {}, 386 + subscriptionPosts: Boolean(state?.subscription?.post), 387 + subscriptionReplies: Boolean(state?.subscription?.reply), 388 + subscriptionPolicy: allowSubscriptions, 389 + subscriptionEligible, 390 + subscriptionBlockedReason, 391 + muted: Boolean(state?.muted), 392 + examples: [], 393 + }) 276 394 } 277 395 278 - return {targets: base.targets, unresolvedQuery: queries[0]} 396 + return targets 279 397 } 280 398 281 - async function buildProposalState(message: string): Promise<AppState> { 399 + async function buildProposalState(message: string, selectedActors: ActorSelection[]): Promise<AppState> { 282 400 const base = await buildBaseState() 283 401 const recommendations = await recommendationsFor(base) 284 - const resolution = await extendTargetsForProposal(base, message) 285 402 const proposal: ActionProposal = 286 - resolution.unresolvedQuery 403 + selectedActors.length === 0 && looksActorTargeted(message) 287 404 ? { 288 - message: `I couldn't identify "${resolution.unresolvedQuery}" from the name alone. Try again with their handle, like @name.bsky.social.`, 405 + kind: 'refusal', 406 + message: 'Choose an account with @ first, then ask for the notification change.', 289 407 } 290 - : await proposeAction(message, base.unread, resolution.targets) 408 + : await proposeAction(message, base.unread, await enrichTargetsForProposal(base, selectedActors)) 291 409 return { 292 410 unreadCount: base.unreadCount, 293 411 recommendations, ··· 317 435 318 436 if (req.method === 'GET' && url.pathname === '/api/actors/typeahead') { 319 437 const q = url.searchParams.get('q')?.trim() || '' 320 - if (!q) return Response.json({actors: []}) 321 - const agent = await createAgent() 322 - const actors = await searchActorSuggestions(agent, q, 6) 438 + const base = await buildBaseState() 439 + const local = localActorSuggestions(base, q, 8) 440 + const merged = new Map(local.map(actor => [actor.did, actor])) 441 + if (q) { 442 + const agent = await createAgent() 443 + const remote = await searchActorSuggestions(agent, q, 8) 444 + for (const actor of remote) { 445 + if (!merged.has(actor.did)) { 446 + merged.set(actor.did, actor) 447 + } 448 + } 449 + } 450 + const actors = [...merged.values()].slice(0, 8) 323 451 return Response.json({actors}) 324 452 } 325 453 ··· 404 532 if (req.method === 'POST' && url.pathname === '/propose') { 405 533 const form = await req.formData() 406 534 const message = String(form.get('message') || '').trim() 407 - const state = message ? await buildProposalState(message) : await buildState() 535 + const selectedActors = parseSelectedActors(form) 536 + const state = message ? await buildProposalState(message, selectedActors) : await buildState() 408 537 return new Response(renderPage(state), { 409 538 headers: {'content-type': 'text/html; charset=utf-8'}, 410 539 })
+120 -1
src/state.ts
··· 1 + // SQLite persistence — lightweight notification history for actor pressure metrics 2 + // (24h, 7d, all-time counts) and known-actor typeahead. Bluesky is always the 3 + // source of truth; this is supplementary context, not authoritative state. 4 + 1 5 import {mkdirSync} from 'node:fs' 2 6 import {dirname} from 'node:path' 3 7 import {Database} from 'bun:sqlite' 4 8 5 - import type {NormalizedNotification} from './types' 9 + import type {ActorSelection, NormalizedNotification} from './types' 6 10 7 11 const dbPath = process.env.SQLITE_PATH || 'data/noti-ts.db' 8 12 mkdirSync(dirname(dbPath), {recursive: true}) ··· 89 93 } 90 94 return out 91 95 } 96 + 97 + export type KnownActor = ActorSelection & { 98 + allTimeCount: number 99 + recent24hCount: number 100 + recent7dCount: number 101 + } 102 + 103 + export function knownActors(query: string, limit = 12): KnownActor[] { 104 + const now = Date.now() 105 + const since24h = new Date(now - 24 * 60 * 60 * 1000).toISOString() 106 + const since7d = new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString() 107 + const normalizedQuery = query.trim().toLowerCase() 108 + 109 + const sql = normalizedQuery 110 + ? ` 111 + WITH filtered AS ( 112 + SELECT * 113 + FROM notifications 114 + WHERE actor_did IS NOT NULL 115 + AND (LOWER(actor_handle) LIKE ?3 OR LOWER(actor_name) LIKE ?3) 116 + ), 117 + ranked AS ( 118 + SELECT 119 + actor_did as did, 120 + actor_handle as handle, 121 + actor_name as name, 122 + indexed_at, 123 + ROW_NUMBER() OVER (PARTITION BY actor_did ORDER BY indexed_at DESC) as rn 124 + FROM filtered 125 + ), 126 + counts AS ( 127 + SELECT 128 + actor_did as did, 129 + COUNT(*) as allTimeCount, 130 + SUM(CASE WHEN indexed_at >= ?1 THEN 1 ELSE 0 END) as recent24hCount, 131 + SUM(CASE WHEN indexed_at >= ?2 THEN 1 ELSE 0 END) as recent7dCount 132 + FROM filtered 133 + GROUP BY actor_did 134 + ) 135 + SELECT 136 + counts.did as did, 137 + ranked.handle as handle, 138 + ranked.name as name, 139 + counts.allTimeCount as allTimeCount, 140 + counts.recent24hCount as recent24hCount, 141 + counts.recent7dCount as recent7dCount 142 + FROM counts 143 + JOIN ranked ON ranked.did = counts.did AND ranked.rn = 1 144 + LIMIT ?4 145 + ` 146 + : ` 147 + WITH ranked AS ( 148 + SELECT 149 + actor_did as did, 150 + actor_handle as handle, 151 + actor_name as name, 152 + indexed_at, 153 + ROW_NUMBER() OVER (PARTITION BY actor_did ORDER BY indexed_at DESC) as rn 154 + FROM notifications 155 + WHERE actor_did IS NOT NULL 156 + ), 157 + counts AS ( 158 + SELECT 159 + actor_did as did, 160 + COUNT(*) as allTimeCount, 161 + SUM(CASE WHEN indexed_at >= ?1 THEN 1 ELSE 0 END) as recent24hCount, 162 + SUM(CASE WHEN indexed_at >= ?2 THEN 1 ELSE 0 END) as recent7dCount 163 + FROM notifications 164 + WHERE actor_did IS NOT NULL 165 + GROUP BY actor_did 166 + ) 167 + SELECT 168 + counts.did as did, 169 + ranked.handle as handle, 170 + ranked.name as name, 171 + counts.allTimeCount as allTimeCount, 172 + counts.recent24hCount as recent24hCount, 173 + counts.recent7dCount as recent7dCount 174 + FROM counts 175 + JOIN ranked ON ranked.did = counts.did AND ranked.rn = 1 176 + LIMIT ?3 177 + ` 178 + 179 + const queryHandle = `%${normalizedQuery}%` 180 + const rows = normalizedQuery 181 + ? (db 182 + .query(sql) 183 + .all(since24h, since7d, queryHandle, limit) as Array<{ 184 + did: string 185 + handle: string 186 + name: string 187 + allTimeCount: number 188 + recent24hCount: number 189 + recent7dCount: number 190 + }>) 191 + : (db 192 + .query(sql) 193 + .all(since24h, since7d, limit) as Array<{ 194 + did: string 195 + handle: string 196 + name: string 197 + allTimeCount: number 198 + recent24hCount: number 199 + recent7dCount: number 200 + }>) 201 + 202 + return rows.map(row => ({ 203 + did: row.did, 204 + handle: row.handle, 205 + name: row.name || row.handle, 206 + allTimeCount: Number(row.allTimeCount || 0), 207 + recent24hCount: Number(row.recent24hCount || 0), 208 + recent7dCount: Number(row.recent7dCount || 0), 209 + })) 210 + }
+8
src/types.ts
··· 27 27 profileUrl: string 28 28 } 29 29 30 + export type ActorSelection = { 31 + did: string 32 + handle: string 33 + name: string 34 + } 35 + 30 36 export type ManagementTarget = { 31 37 actorDid: string 32 38 actorHandle: string ··· 65 71 } 66 72 67 73 export type ActionProposal = { 74 + kind: 'action' | 'help' | 'refusal' 68 75 message: string 76 + examples?: string[] 69 77 recommendation?: Recommendation 70 78 } 71 79