The bot's own VOICE_STATE_UPDATE showed server-level deaf:true — a
server-deafened member is NOT sent the streamer's audiovisual RTP by Discord,
which is the likely reason no H264 arrives despite the DAVE watch reaching
Ready. The previous fire-and-forget forceSelfServerUnmuteUndeafen() ran once
after the first Ready join and silently reverted on reconnect/restart.
- Export forceSelfServerUnmuteUndeafen from recorder.ts; re-assert it (with
read-back verification logging stillDeaf) at the START of every
startStreamWatch() before STREAM_WATCH is sent (dynamic import avoids the
recorder <-> videoRecorder <-> streamWatchReceiver module cycle).
- Re-assert it again after a successful voice reconnect.
- startStreamWatch() is now async; callers use void.
maxLen stayed 72 across 243 packets (no real H264, which is hundreds+ bytes) —
only 44-72-byte RTP packets on PT 76/72/73 arrive. Add ssrc + first-32-bytes
hex so we can identify exactly what Discord sends to the watch socket (control
packets vs stale video), which determines whether the gap is upstream routing
or whether large H264 packets are missing entirely.
Enhance watch-socket diagnostic to report distinct RTP payload types seen and
the max packet length, so we can distinguish 'only small control packets arrive
(no real H264)' from 'H264 arrives but decrypt fails'. Live already confirmed
dave=true ready=true with packets flowing but no burst — need to know if they're
tiny 52-byte control packets (PT 73) or large H264.
Instrument handleUdpMessage to log (rate-limited, first 3 then /20s) whether
video RTP packets actually reach the watch socket, and whether the DAVE session
is attached+ready and encryption key material present. Needed to diagnose why
no .h264 is written despite DAVE Ready + MLS: is the packet not arriving, or is
decrypt returning null?
CRITICAL: djs/voice stores the DAVESession wrapper at net.state.dave
(createDaveSession assigns to state.dave on op4 SessionDescription), NOT
inside connectionData. decryptVideoPacket looked up connectionData.dave which
is ALWAYS undefined -> every video packet hit '!dave?.session' guard and was
silently dropped, so no .h264/.mp4 ever got written despite the handshake
reaching Ready.
Fix: pass net.state.dave as a separate arg (the wrapper has .session ->
Davey.DAVESession) so the DAVE-layer decrypt (MediaType.VIDEO) actually runs.
Typecheck + build pass, lint clean (src/), 179/179 tests.
Live deploy 15:51 reached DAVE watch READY + completed MLS handshake on the
stream RTC (0->1->2->3->4, MLS commit processed, heartbeats alive). scan-on-
join also confirmed: 'Scanned pre-existing streamers on join watched=1'.
P4 remaining = capture actual video RTP (burst->mp4) while a stream is live.
If someone is ALREADY sharing screen / camera on when the bot joins the
channel, no voiceStateUpdate with streaming:true fires for them, so the
bot never sent STREAM_WATCH and missed their video entirely. trackChannel
now scans channel.members and starts a watch for anyone already streaming
(ignoring the bot itself and non-streamers). Idempotent: startStreamWatch
no-ops if a watch already exists. +2 tests (9/9 in videoRecorder).
djs/voice Networking emits stateChange(oldState, newState), but the watch
handler declared (newState, oldState) -- reversed. So the code-4 Ready
branch (which attaches udp.on('message') + logs 'DAVE watch READY') never
fired when entering Ready; it only fired spuriously when LEAVING Ready.
Result: full DAVE handshake completed on the watch RTC (Ready + DAVE MLA +
video stream 21029 active 1920x1080@60) but no UDP listener -> no video
captured. Swap to (oldState, newState) so enter-Ready wires the socket.
Add watch-state N->M log on every djs/voice Networking stateChange (with
hasUdp flag) so the stream-watch connection's exact progression is visible:
OpeningWs(0)->Identifying(1)->UdpHandshaking(2)->SelectingProtocol(3)->
Ready(4). Pins down where the DAVE flow stalls instead of guessing from the
absence of logs. Pairs with debug:true + watch-djs-debug.
Add debug:true to watch Networking options and wire net.on('debug') to
logger.info so djs/voice internal WS/DAVE state transitions appear in
journal. Without this, the stream-watch connection went silent after
'Streaming DAVE Networking' — no ready/error/close visible. Needed to
diagnose why the WS to stream endpoint 'c-sin14-xxx:2083' produced no
events.
Live log (14:52) showed the stream-watch flow reaching STREAM_CREATE +
STREAM_SERVER_UPDATE but then DAVE processProposals threw
'ValidationError(WrongGroupId)' -- the Davey MLS session derived the wrong
group because connectionOptions.channelId was the guild voice channel id.
Per Discord-RE StreamConnection.daveChannelId = BigInt(serverId)-1n, the
stream-watch DAVE MLS group is keyed to rtc_server_id-1, not the vc channel.
Fix: pass BigInt(serverId)-1n as channelId to the watch Networking.
This error also surface as an uncaughtException that crashed the gateway
(systemd restarted it). Correct channel id prevents it at the root.
The previous code read the sessionId from the selfbot client's voice manager
(client.voice.connection), which is no longer established since ensureSelfbotVoice
was removed — it would have sent sessionId:'none' in the watch Networking
identify and been rejected. Read the active session from the guild
@discordjs/voice connection (getVoiceConnection(guildId).state.networking...
connectionOptions.sessionId) instead.
The first streamWatchReceiver only did dave.session.decrypt(msg.subarray(12))
which skipped the outer legacy-AES layer Discord wraps around the DAVE payload
on every RTC packet (encrypt = dave.encrypt then aead_aes256_gcm with RTP
header as AAD). Port @discordjs/voice VoiceReceiver.decrypt/parsePacket
faithfully (header strip incl CSRC+extension+padding, AES-GCM auth tag, then
DAVE MediaType.VIDEO). Without this the .h264 would be garbage.
Replace the dead selfbot-v13 video path (WS 4017 DAVE). videoRecorder
now delegates to a new streamWatchReceiver that:
- sends STREAM_WATCH (op 20) on voiceState.streaming
- opens a @discordjs/voice Networking to the watch RTC (STREAM_CREATE +
STREAM_SERVER_UPDATE) with DAVE enabled
- decrypts H264 via Davey MediaType.VIDEO, depacketizes + muxes to mp4
- tears down on streaming-stop / leave / untrack
Remove ensureSelfbotVoice/createVideoStream/joinStreamConnection (dead).
recorder.ts no longer fires the futile eager selfbot join.
Supersedes the eager-selfbot connection plan: Discord now REQUIRES DAVE (E2EE,
WS 4017) on all voice RTC, and discord.js-selfbot-v13's voice stack predates
DAVE, so its video-receive path (joinChannel + joinStreamConnection +
receiver.createVideoStream) cannot authenticate. @discordjs/voice 0.19.2 exports
VoiceWebSocket/VoiceUDPSocket/DAVESession/Networking + @snazzah/davey supports
MediaType.VIDEO/Codec.H264 decrypt, so we can build a DAVE-capable stream-watch
connection. Phased plan: prototype (P2), gateway integration (P3), live verify (P4).
Video capture (camera + screen share) recorded ZERO frames because the selfbot
ClientVoiceManager.connection was created LAZILY — only when a user started
streaming. At that point the bot is already connected via @discordjs/voice, so
the selfbot re-join never gets a fresh VOICE_SERVER_UPDATE and times out with
VOICE_CONNECTION_TIMEOUT after 15s. joinStreamConnection (STREAM_WATCH) +
receiver.createVideoStream both need that selfbot VoiceConnection CONNECTED.
Fix: establish the selfbot VoiceConnection eagerly in recorder.startRecording,
BEFORE joinVoiceChannel, so it rides the bot's fresh join (Discord emits
VOICE_SERVER_UPDATE → selfbot authenticates). videoRecorder reuses the cached
connection per guild, tears it down on voice stop/destroy. Best-effort — never
blocks audio recording.
Verified: typecheck + build + biome (src/) green; 9/9 videoRecorder tests.
- mediaDownloader: flip URL candidate order so discord_url is tried
before uploaded_url (uploaded_url is archive-only fallback)
- ai-analysis-worker: remove upload-pending race guard that blocked
analysis until Tele upload completed; analysis now runs immediately
on the Discord CDN URL
- batchProcessor: remove upload-pending defer/poll-backoff logic
- individualFallbackProcessor: remove upload_pending requeue loop
- batchOutcomeClassifier/fallbackResultClassifier: drop upload_pending
classification (no longer needed)
- tests: update batchOutcomeClassifier + fallbackResultClassifier tests
to reflect removed upload_pending signal
Switch GMW's AI LLM base URL from 9router (https://9router.asepharyana.my.id/v1)
to omniroute on imrnes (http://100.121.180.82:20128/api/v1).
- Update default AI_LLM_BASE_URL in discord-gateway + backend config schemas
- Update .env.example documentation
- Update all 9router references in comments/docs/tests to omniroute
- Production BWS secret gmw_ai_llm_base_url already updated
Omniroute uses /api/v1 prefix (not /v1 like 9router), so the base URL
now correctly points at the right API path for the OpenAI SDK.
Root cause of 'navbar mobile tak bisa pindah halaman': Next <Link>
client-side navigation is dead app-wide. A React hydration mismatch
(#418: 'server rendered text didn't match the client') is thrown by the
SSR-seeded live feeds — relative times (formatRelativeTime(e.edited_at) /
m.created_at) computed with Date.now() render slightly differently on
server vs client, which breaks the Next client router (router.push is a
no-op). The desktop NavRail worked only because it uses plain <a href>
(hard navigation bypasses the broken router).
Fixes:
- mobile-nav.tsx: use plain <a href> (NOT Next <Link>), identical to the
working sidebar NavRail, so mobile nav always navigates regardless of
router state ('ikuti cara kerja sidebar').
- Add suppressHydrationWarning to the SSR-seeded relative-time spans so
server/client drift no longer throws #418 (EditHistory, LiveModerationFeed,
messages/results + detail rows, recordings, TermGlossary,
ChannelCultureGlossary, CategoryDrilldown).
Verified on non-prod :4024 @375px: Voice/Media/Search all navigate, no #418
in console. Plan: .hermes/plans/mobile-nav-hydration-fix.md
Use fixed w-[72px] shrink-0 pills (not flex-1) so the 7 bottom-nav items
keep their shape and scroll horizontally instead of compressing labels to
overlap; add truncating 10px mobile label (12px >=sm).
- Root cause of 'navbar mobile tak bisa pindah halaman': the chatbot FAB
(fixed right-4 bottom-5 z-50) overlapped the rightmost mobile bottom-nav
items (z-40) and intercepted taps. Raise the FAB above the nav on mobile
(bottom above nav, md:bottom-5 on desktop) so it never blocks nav taps.
- Mobile bottom nav now mirrors the FULL desktop sidebar (all 7 items:
dashboard, messages, voice, media, recordings, moderation, analysis);
horizontally scrollable + snap-to-active on narrow screens.
- Select dropdown: clamp portal position within viewport + min-width so it
never overflows off-screen on mobile triggers near the right edge; larger
tap targets on touch.
- Input/Textarea: text-base (16px) on touch to prevent iOS auto-zoom on
focus, text-sm on >=sm; comfortable mobile min-height for chat input.
- Add .hermes/plans/mobile-nav-form-responsive.md spec.
getRecentEdits SELECT ... m.content AS new_content returned the message's
ORIGINAL content (never updated on edit) instead of the post-edit content.
messages.content stores the original body; the current/last-edited body lives
in messages.edited_content. So before===after in the Message Edits diff.
Fix: COALESCE(m.edited_content, m.content) AS new_content so 'After' shows the
edited text and diffs against the captured before-content are meaningful.
Prevent recurring infinite restart loop (389x crash) caused by drizzle
re-applying already-applied migrations when public.__drizzle_migrations
tracking is empty/partial.
- 0017/0018: ADD COLUMN IF NOT EXISTS (re-run safe)
- 0019: DO-block rename that handles all prior states (server_name-only,
both columns, or server_nick-only) so it never errors or double-renames
- seedDrizzleHistory: reconcile tracked created_at to the journal's latest
'when' when the schema already reflects the latest migration, instead of
early-returning on an existing-but-empty/partial tracking table
Extract server_nick from metadata.member.displayName in backend messageMapper,
add server_nick to frontend MessageRecord type, and update messages view +
analysis view to display the member's server-specific nickname (with @username
as secondary context) instead of the global username.
Rename server_name (guild name) to server_nick and populate it from
the member's server-specific display name (metadata.member.displayName)
at write time. This is what the moderation dashboard should show as
TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global
username '.nichiyobi'. Backfilled 210 existing actions from messages
metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas
Minoritas', etc). Frontend TARGET shows server nick with global
username as secondary context.
Replace static OFFENSIVE_USERNAME_KEYWORDS substring matching with a
lightweight LLM call that evaluates whether a global username violates
server rules (gambling, scam, NSFW, SARA, etc). Fail-open design:
if the LLM call fails/times out, the nickname reset still completes.
After resetting an offensive server nickname to the global username,
check the global username against gambling/scam keyword list. If it
also violates, generate a random 'UserXXXXX' nickname to prevent
circumvention via offensive global usernames.
Denormalize guild name alongside username so the moderation dashboard
shows both TARGET and server even after message table purges.
Migration 0018. Frontend displays 'username · server_name' in TARGET.
Gateway (transmitter.ts):
- Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent
silent audio loss and resource leaks
- Voice activity timeout (10s): auto-stops transmitter when no PCM
received, preventing dead-air CPU waste on backgrounded tabs
- Stderr cap (4KB): prevents unbounded memory growth in long sessions
Gateway (voice.handler.ts):
- Double-check voiceController.getStatus().connected before starting
transmitter — detects stale player state after gateway disconnect
Frontend (context.tsx):
- Force-refetch voice status on WS reconnect — UI converges in <1s
instead of waiting up to 4s for SWR poll interval
All: tsc clean, biome clean
- Updated RootLayout to include a noise overlay class.
- Refactored LiveModerationFeed to use CSS animations for item entry.
- Simplified AmbientCanvas by removing WebGL and using pure CSS for ambient effects.
- Converted Chatbot to use CSS for floating window animations.
- Updated Card component to include a gradient border.
- Refactored PageTransition to use CSS for fade-scale animations.
- Enhanced SectionHeader with reveal-up animation class.
- Replaced GSAP animations in NavRail with CSS animations for item entry.
- Refactored VoiceStage to use CSS for speaker node animations and pulse rings.
- Removed GSAP dependencies from use-gsap-animation hook, implementing CSS-based stagger reveal.
- getMessageById: run findById and getEditHistory in parallel via
Promise.all instead of sequential awaits (halves latency for
message detail view)
- Drop 3 unused indexes on messages table: idx_messages_guild_ai_status_created
(0 scans), idx_messages_guild_ai_status_analyzed (97 scans),
idx_messages_guild_created_deleted (95 scans, superseded by covering index)
— saves ~5.9MB index space + reduces write amplification
- Add covering index idx_messages_guild_created_covering for the primary
findMany query pattern (guild_id + created_at DESC with INCLUDE columns)
Two bugs causing 23 spurious 'error' logs after successful deletions:
1. batchProcessor switch missing 'completed' case: partitionBatchOutcome
returns 'completed' for successful messages, but the switch only handled
'upload_pending' and 'api_failed'. Successful messages fell through to
default → re-enqueued to individual fallback → re-analyzed → re-delete
attempt → error (message already gone from Discord). Now explicitly
skips 'completed' messages.
2. isAlreadyDeletedError only caught codes 10008/404. Discord also returns
10003 (Unknown Channel) and 50001 (Missing Access) when a message or
channel is gone. Added these codes plus text-based fallback matching
'Unknown Message'/'Unknown Channel'.
Impact: eliminates ~23 redundant error logs per day + stops wasted LLM
calls re-analyzing already-processed messages.
isEligibleForAutoDelete was rejecting messages where recommendedAction
was 'warn' or 'review' — only 'delete' and 'escalate' were accepted.
This caused 28+ medium-severity flagged messages to be logged as
'not_eligible' instead of being auto-deleted.
Changes:
- deriveRecommendedAction: return 'delete' for flagged+medium severity
(previously only critical/high triggered delete; medium got 'review')
- isEligibleForAutoDelete: accept 'warn' as valid recommendedAction
alongside 'delete' and 'escalate'
Impact: ~28 pending medium-severity flagged messages + all future
'warn'-action flagged messages will now be eligible for auto-deletion.
- Refactored ChannelCultureGlossary, LiveModerationFeed, TermGlossary, and Chatbot components to use new design tokens and styles.
- Updated button, badge, and section components to align with the new design system.
- Enhanced the visual hierarchy and accessibility of various UI elements.
- Improved responsiveness and hover states across components.
- Replaced hardcoded colors with design tokens for better maintainability.
- Cleaned up imports and removed unused hooks in ModerationView and RecordingsView.
- Updated UI elements for better visual consistency and accessibility.
- Improved performance by optimizing rendering logic and reducing unnecessary state updates.
- Added filtering functionality in ChannelCultureGlossary and TermGlossary components.
- Enhanced LiveModerationFeed with GSAP animations for smoother transitions.
- Updated chatbot component for better user experience and responsiveness.
- Refined NavRail component for cleaner code and improved navigation item rendering.
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias
API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias
API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
- Change AI_LLM_BASE_URL default from omniroute.imrnes.team to 9router.asepharyana.my.id
- Update AI_LLM_MODEL default from 'text' to 'claude-opus-5' (bare model name
compatible with 9router/OpenAI-compatible router)
- Update .env.example and inline comments to reflect 9router
- discord-gateway config now matches backend (which already uses 9router)
- pickBatchWithinBudget: stop di overflow pertama (break), bukan skip —
batch tetap prefix kronologis tanpa gap analisis di tengah timeline.
Diekstrak ke batchBudget.ts (pure, estimator di-inject) + regression test.
- callModerationLLM: param opsional maxTokens; text/media caller menghitung
ceiling dari estimasi prompt (floor 2048, cap 16384) — batch kecil tak
lagi reserve window completion 16k.
- getPending/IncompleteMessagesByConversation: sort hasil UPDATE..RETURNING
by created_at ASC — Postgres tak menjamin urutan, konsumen (anchor konteks
messages[0], prefix batch) bergantung pada urutan kronologis.
- normalizeStoredStatus(): exact-hash & semantic (Qdrant/PG) cache reader
sebelumnya menipiskan 'warn' jadi 'flagged'/'clean' (type narrowing
legacy clean|flagged) — merusak gating auto-delete & label dashboard.
Kini status tersimpan dipertahankan penuh (clean/warn/flagged).
- prompts: hapus referensi <user_history> yang tak pernah di-inject,
SearXNG -> Wikipedia (sudah migrasi), referensi section yang tak ada,
typo 'secifik', dan baris list rusak '|-'.
- moderationBuilders: buang dead code buildUserProfilesBlock/
buildUserProfileRef/UserProfileEntry/buildUserHistoryXml (tanpa caller
produksi sejak context minimization) + test-nya.
- test baru: tests/storedStatusNormalization.test.ts (regresi warn).
upload.asepharyana.my.id redirect ke file mp3 tunggal; generic extractor
yt-dlp expose format ID '0' sehingga '-f bestaudio' gagal 'Requested
format is not available'. Chain bestaudio[ext=m4a]/bestaudio/best tetap
dapat m4a di YouTube dan jatuh ke 'best' untuk direct file.
- Recordings: custom RecordingAudioPlayer (play/pause, buffering spinner,
click-to-seek, time label, eq bars, single-playback antar kartu) +
highlight kartu now-playing
- Media: thumbnail di disc hero + queue row, equalizer saat playing,
badge 'up next', label Paused vs Now playing
- MiniPlayer global di AppFrame (fixed bottom-right, hidden on /media)
menggantikan use-media-player.tsx dead provider (dihapus)
- Voice: mic level meter live (AnalyserNode RMS) + slider mic/listen volume
SearXNG was already replaced by Wikipedia REST/Action APIs (wikipediaClient.ts).
Update comments to reflect the current implementation: term glossary now
resolves definitions via Wikipedia → Redis → Postgres cache chain, with no
SearXNG dependency.
Discord GoLive sends screen-share audio on a separate SSRC from the
user's microphone. In @discordjs/voice v0.19, VoiceReceiver.onUdpMessage
silently drops packets for SSRCs not in ssrcMap (which is only populated
from VOICE_STATE_UPDATE/VOICE_SERVER_UPDATE). This caused screen-share
audio to never trigger receiver.speaking and never reach the speakingHandler.
Fix: hookScreenShareAudio() wraps onUdpMessage to:
1. Detect incoming RTP packets with unknown SSRCs (OPRUS payload type 120)
2. Infer the owning userId by proximity to known audioSSRC
3. Clone the user's VoiceUserData into ssrcMap under the new SSRC
4. Let the original handler decrypt and forward to the subscription stream
5. Listen on ssrcMap 'create'/'update' events for video SSRC changes
Also removes the broken initial approach (polling ssrcMap which never
contains screen-share SSRCs).
- Add shared skeleton building blocks (SkeletonHero, SkeletonMetricRow,
SkeletonPanel, SkeletonRows) and wire them into every view's initial
loading branch, replacing bare spinners for a cohesive shimmering shell.
- Polish EmptyState with a glow-ring icon chip instead of a flat icon.
- Harden .light theme: color-scheme, tuned scrollbar + selection for pale
canvas. Dark/light theme toggle (next-themes) already persisted in TopBar.
User: 'jangan ada reputasi juga' — no profile, no reputation in the prompt,
raw messages only.
- textBatchProcessor: drop initializeUserReputation fetch + <user_reputation>
tag injection (kept the minimal <message> tag + reply/reference context).
- visionAnalyzer (prepareMediaMessage): same removal.
- prompts/system.ts + prompts/output.ts: replace <user_reputation>/<user_history>
instructions with an explicit 'no per-user profile/reputation context'
note so the LLM judges purely on message content + conversation/web/location.
- mediaBatchProcessor: fix stale comment.
Trust/infraction state is STILL written to the DB (userReputationsTable) for
enforcement — only the LLM context injection is removed, so moderation
actions (mute/ban via infraction thresholds) keep working.
Net: even smaller prompts (no per-user context at all) → more messages fit
per request, and one fewer DB round-trip per unique user per sub-batch.
tsc, biome, vitest (129) all clean.
User insight: personal profile summaries bloat the prompt (less room per
request) and add a per-user DB/Redis round-trip for little moderation signal.
Only the behavioural <user_reputation> history is kept.
- textBatchProcessor: stop fetching getUserProfile; remove <user_profiles>
block + <user_profile_ref> from message tags. Keep <user_reputation>.
- mediaBatchProcessor + visionAnalyzer: same removal (profile fetch + ref).
- prompts/system.ts + prompts/output.ts: drop stale <user_profiles>/
<user_profile_ref> instructions; point LLM at <user_reputation> instead.
- aiAnalyzer: gate userProfileLearner behind AI_USER_PROFILE_LEARNING_ENABLED
(default false) — generates profiles nobody reads, pure LLM/DB waste.
- Add AI_USER_PROFILE_LEARNING_ENABLED config knob.
Net: smaller prompts (more messages fit per request), fewer DB round-trips
per sub-batch, and no background LLM calls learning unused profiles.
tsc, biome, vitest (129) all clean.
User insight: rather than many small per-batch API requests, pack many
messages into ONE request so a burst is analyzed with far fewer calls.
- AI_LLM_TEXT_BATCH_SIZE 20 -> 60 (one request now carries ~3x more messages).
- AI_ANALYSIS_MAX_TARGET_TOKENS 4000 -> 14000 (the scheduler's token-budget
gate was trimming pending messages to ~20 before they reached the sub-batch
splitter; raising it lets ~60 messages through to a single LLM call).
- AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS 30000 -> 45000 (one larger call needs more
headroom; gemini-flash-lite has a 1M-token context so 14k+8k is trivial).
Net effect when ramai: a 60-message burst = 1-2 API calls instead of 3+,
less semaphore contention, faster throughput.
- Parallelize per-user reputation/profile fetches in textBatchProcessor
(was a serial ~2N DB/Redis round-trip loop per sub-batch; now Promise.all
over unique users). Cuts per-batch latency, biggest win on small/quiet
batches.
- Make the LLM concurrency semaphore dynamic (cached per config value) instead
of frozen at import time, so AI_LLM_MAX_CONCURRENT is tunable without code
change and reflects current config.
- Bump AI_LLM_MAX_CONCURRENT default 5 -> 8 (gemini-flash-lite is cheap; helps
throughput when busy).
- Lower AI_ANALYSIS_DEBOUNCE_MS 500 -> 250 (snappier first-message analysis
when quiet).
- Lower AI_ANALYSIS_RECOVERY_INTERVAL_MS 15000 -> 10000 (stuck/errored
messages re-analyze sooner).
tsc, biome, vitest (129) all clean.
Backend returns messages DESC (newest first); the view previously rendered
that directly, so the feed was inverted vs Discord (old at bottom, new at top)
while the load-older control sat at the top — contradictory.
- Reverse the display list so it reads oldest→newest top→bottom, like DC.
- Load-older (cursor to lower created_at) prepends at the top; scroll position
is preserved by offsetting scrollTop by the height added above.
- Open at the bottom (newest visible) on first load / scope change.
- New live messages append at the bottom and auto-scroll only when the user is
already near the bottom (nearBottomRef), so reading history isn't disrupted.
- Scroll container now tracked via ref; onScroll updates nearBottom + triggers
load-older when scrolled to the top.
tsc, biome, next build all clean.
- Set stream:false on the /chat/completions request so the bot gets one
complete response instead of an SSE token stream.
- Add reasoning_effort:"none" to suppress extended-thinking/reasoning tokens
(ignored by non-reasoning models like gemini-flash-lite).
- Add parseResponse(): handles both the JSON object 9router returns for
stream:false and the SSE text it may still emit, delegating SSE to parseSse.
Verified live: omniroute returns 200 application/json with message.content.
- Add viewport export with viewportFit: "cover" so iOS exposes
env(safe-area-inset-*) (required for the insets to take effect).
- NavRail / TopBar / main / Toaster now respect safe-area insets so content
clears the iPhone notch and home indicator in both portrait and landscape.
- prefers-reduced-motion: the media query already disabled declared animation
classes; harden it with a global transition/animation duration override and
kill the scan-line shimmer so motion-sensitive users get a fully static UI.
Verified tsc --noEmit + next build clean.
- SectionHeader: action (filters/legends) now wraps below the title on narrow
screens instead of overflowing beside it (flex-wrap, gap-2 sm:gap-3).
- GuildChannelPicker: selects go full-width and stack on mobile (w-full
sm:w-44 / sm:w-52) instead of fixed widths that exceeded a 375px viewport.
- Messages search: w-full sm:w-64 so it doesn't crowd the picker on mobile.
- TopBar: tighter padding (px-4 sm:px-5), smaller title on mobile, connection
status uses compact (dot only) on mobile, ambient pill hidden < sm.
- Shell main + dashboard channel label: responsive padding / shrink-0 widths.
Verified tsc --noEmit + next build clean; targets breakpoints 375/768/1024/1440.
- Show an explicit Loader2 spinner row ("Loading older…") while the next page
fetches, instead of a disabled button.
- Cap appended older pages at MAX_OLDER_PAGES=10 (500 messages) so a long
scroll-up never pulls the entire history; show a "capped" hint pointing to
search. Reset the counter when guild/channel changes.
Wire the existing useLoadMore + useMessagesHasMore pagination hooks into the
Messages view: add a "↑ Load older messages" button at the top of the list and
auto-load the next (older) page when the user scrolls to the top. Backend
messages.list already returns a created_at-based nextCursor (DESC order), so
older pages are just subsequent cursors. Newest-first live feed is preserved;
the load-older control is hidden during search.
Browser connects oRPC over wss://…/trpc (partysocket). The gmw-proxy nginx
only forwarded /api and /ws to the backend, so /trpc upgrades fell through to
Next.js SSR and the socket never opened ("WebSocket is not open"). Add a
/trpc location (WS upgrade headers) mirroring /ws. Backend already serves
oRPC on /trpc (HTTP RPCHandler + WS ORPCWebSocketServer on :4001).
Verified: ws://127.0.0.1:4001/trpc upgrade OPEN; SSR + server-side fetch RPCLink
also use /trpc directly so only the browser path was broken.
flake.nix only rewrote @/ aliases but left extensionless relative imports
(./router) in compiled dist/. node dist/index.js (how prod runs) cannot
resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend
crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared
scripts/fix-imports.mjs that appends .js to extensionless relative imports and
rewrites @/ aliases, and wire it into backend + discord-gateway build phases.
Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc
serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/
media/chatbot/analysis) end-to-end against Postgres + Redis. next build
passes with the oRPC client + partysocket.
Replace REST module routers with a single typed tRPC appRouter served over
/trpc (HTTP + WebSocket), and rewire the frontend to call it via
@trpc/client wsLink (browser) and httpLink (RSC data layer). Existing
/api/health + /api/metrics stay as plain Express for infra scraping.
Notable fixes surfaced by the live smoke test:
- Express 5 / path-to-regexp v8 rejects the /trpc/* wildcard route; use a
prefix middleware that computes opts.path from the URL instead.
- nodeHTTPRequestHandler treats opts.path as the literal procedure path, so
it is derived per-request from req.url.
- Two ws servers on one http.Server (the /ws voice socket + /trpc) collided
and returned 400 on upgrade; both now use noServer + a manually routed
server.on('upgrade') keyed by path.
Verified: BE tsc+biome+40 vitest green; FE tsc+biome green; live
HTTP and WebSocket calls returned real prod data.
Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
The standalone image analysis path (analyzeSingleMediaImage → llmVision →
llmChat) previously had no request-level timeout of its own — it silently
inherited the shared OpenAI client default (60s), and AI_LLM_MEDIA_ANALYSIS_
TIMEOUT_MS only governed the text+media *batch*, not a single vision call.
- Add AI_LLM_VISION_ANALYSIS_TIMEOUT_MS (default 60000) to config.
- llmChat now accepts an optional per-request `timeout` in LlmCallOpts,
forwarded to the OpenAI request options (falls back to the 60s client
default when omitted).
- llmVision passes config.AI_LLM_VISION_ANALYSIS_TIMEOUT_MS, so a single
image/sticker/emoji analysis gets a guaranteed 1-minute budget and is
independently tunable from the text path.
Verified: tsc + biome green, 129 gateway tests pass.
Co-Authored-By: Claude Opus 5 (Nous Research)
The sidebar rendered /dashboard twice: once as a hardcoded NavItem
(lines 45-50) and again via navItems.map() (navItems[0] is also
/dashboard). Dropped the hardcoded item so the single source of truth
(navItems in lib/navigation.ts) drives the rail. Removed the now-unused
LayoutDashboard import.
tsc + biome green.
Co-Authored-By: Claude Opus 5 (Nous Research)