- 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)
The chatbot agent now has 14 tools (was 4) so it can answer about ANY
server situation from live data instead of a static snapshot:
- get_server_stats (now also returns clean count)
- get_top_channels, get_recent_activity, get_top_flagged
- search_messages (LIKE keyword search)
- get_user_messages, get_user_profile, get_user_reputation
- get_channel_culture
- get_message_detail (full AI analysis of one message)
- get_message_reviews (human moderation queue by status)
- get_voice_recordings (with transcriptions)
- get_moderation_timeline (daily flagged/warn/clean trend)
- get_corrections (AI false-positive correction history)
Security/quality:
- Every executor now uses parameterized drizzle queries (eq/like/and).
The old code interpolated model-supplied IDs into sql.raw() — a SQL
injection vector. Removed.
- Split static tool *definitions* into chatbot.toolDefs.ts (no DB import)
so the LLM-facing schema can be unit-tested without loading the
database/config layer. chatbot.tools.ts keeps only the executor.
Verified: tsc + biome clean, 40 backend tests pass (4 new covering the
tool-contract: names unique, required args declared, full situation
coverage).
Co-Authored-By: Claude Opus 5 (Nous Research)
The chatbot already had an agentic tool loop (get_server_stats,
get_top_channels, get_recent_activity, get_top_flagged), but processMessage
still baked a serverInsights snapshot into the system prompt and told the
model to "answer from that data". That defeats the tools: the model answered
from a stale snapshot instead of living numbers, and the guild/channel scope
the frontend sends was never forwarded to the tools.
Changes (services/backend/src/modules/chatbot):
- Remove getServerInsights() + ServerInsights (dead after this change).
- buildSystemPrompt(): drop the hardcoded stats block; instruct the model it
has NO memorized server numbers and MUST call a tool for any server-data
question, answering only from tool results.
- processMessage(): stop fetching insights; pass the request guildId/channelId
scope through to callLLM.
- callLLM(): accept scope; auto-fill empty guildId/channelId on tool calls from
the request scope so the model never has to guess IDs and tools always query
the right server.
Behavior: answers now come from live DB data via tools, scoped to the server
the user is chatting in. tsc + biome + 36 backend tests green.
Co-Authored-By: Claude Opus 5 (Nous Research)
buildCorrectedFewShotExamples() (a getRecentCorrectedModerations(5)
DB hit) was called inside the per-sub-batch buildContent closure in
textBatchProcessor.ts — re-queried for every sub-batch (≈10× for a
200-msg burst) AND re-fired on each parse-error retry. mediaBatchProcessor
already hoisted it once. Mirror that: fetch once per runTextOnlyBatch,
reuse the cached string inside the closure.
No behavior change — identical content, fewer identical DB reads.
tsc + 129 tests + biome green.
Co-Authored-By: Claude Opus 5 (Nous Research)
The 32 few-shot examples each re-echoed score/confidence/
recommended_action/categories/policy_version inline (~150 chars ×
32). Those fields carry zero moderation-decision signal — the schema
and their ??-default coercion already live in OUTPUT_INSTRUCTIONS +
moderationResponseParser.ts. Removed 96 redundant key/value pairs.
Kept per-example: message_id, status, flags, severity, evidence,
analysis — the fields that actually teach decisions. Parser derives
the rest via ?? fallback, so real output shape is unchanged.
examples.ts: 21.7K→18.5K chars; FEW_SHOT(mixed) 15.3K→13.4K.
Total mixed system prompt now 33.9K (was 39.3K at audit start,
~14% leaner). tsc + 129 tests + biome green.
Co-Authored-By: Claude Opus 5 (Nous Research)
- prompts/system.ts: merge 3 overlapping framing blocks (Blok Data /
Konteks Pengguna / Framing Konteks vs Target) into 1 tight block —
same coverage, no duplicated "standalone judgment / profile-is-
reference-not-evidence" prose.
- prompts/output.ts: trim duplicated user_history/standalone paragraph
in PERSONALITY & MEMORI (keep concrete per-case lessons).
- prompts/examples.ts: drop 2 exact-duplicate-lesson few-shots (LGBT id=19
dup of id=30; weapons-tech id=33 dup of id=32). All teaching signals
retained via the surviving example of each lesson.
Static system prompt: text 32.7K→29.2K, mixed 39.3K→35.8K chars
(~10% smaller). No moderation rule, zero-tolerance category, or decision
tree altered — accuracy-controlling content untouched. tsc + 129 tests +
biome green.
Co-Authored-By: Claude Opus 5 (Nous Research)
- gateway-metrics: collectors now run per scrape so Prometheus sees real
data (process memory/uptime + live AI-analysis pipeline gauges) instead
of an always-empty stub. bootstrap registers the pipeline collectors.
- systemd: MemoryMax 512M -> 1G (live RSS ~500MiB, peak 508MiB; 512M left
~2% headroom and risked an OOM-kill restart; host has 8GB free).
- config: POSTGRES_POOL_MIN 2 -> 0 so main + 4 Piscina worker threads don't
hold ~10 permanently-open idle pg connections against PgBouncer.
- docs: rewrite stale ARCHITECTURE.md / MODULE_STRUCTURE.md (winston ->
pino, removed mock-crc/indonesianTextNormalizer, renamed
aiAnalysisWorker/llmModerationClient).
Verified: tsc clean, 129 vitest pass, biome clean on changed files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds per-message AI moderation analysis time (ai_analysis_duration_ms)
so operators can see how long the LLM took to moderate each message.
Gateway:
- messagesTable: new ai_analysis_duration_ms (bigint) column.
- AIAnalysisUpdate + buildAIAnalysisSet: carry analysisDurationMs through
both single and bulk update paths.
- ai-analysis-worker: measure wall-clock time around runModerationAnalysis
and attach it to every result in the batch.
Backend:
- Mirror schema column; messageMapper maps ai_analysis_duration_ms;
moderation-types + MappedMessage expose it.
Frontend:
- message.ts type gains ai_analysis_duration_ms.
- AiBadge (messages view) shows 'status · 1.2s' when duration is present;
analysis view badge mirrors the same formatting.
DB:
- scripts/add-ai-analysis-duration.sql (idempotent ADD COLUMN IF NOT EXISTS).
No behavior change for moderation logic; null until new gateway build
records values.
Qdrant upserts were failing with 'This operation was aborted' ~32x/2h,
so semantic moderation cache entries were silently dropped. Root cause:
upsertQdrantPoint ran ensureQdrantCollection() on EVERY call — a GET
(and sometimes DELETE+PUT) round-trip — while the request AbortController
had only a 10s timeout. Under moderation load Qdrant is busy (the
gmw_text_moderation collection is not yet HNSW-indexed, so searches are
full-scans), the extra round-trips pushed the upsert past 10s, and the
client aborted it.
- Memoise ensureQdrantCollection() at module scope so the collection is
verified exactly once per process (resetQdrantCollectionCache() for
tests / config reload).
- Bump the upsert request timeout 10s -> 30s so a transiently busy
Qdrant no longer aborts the write.
Qdrant server itself is healthy (<100ms for direct upsert; collection is
green), so no server-side change is needed. Semantic cache should now
populate reliably.
Address every remaining biome lint/format warning across both services
so the codebase ships warning-free:
- textCacheStore: drop unused deleteExpiredQdrantPoints import; hash
image cache key (sha256[:32]) so long/base64 URLs no longer blow the
text_analysis_cache PK B-tree 8191-byte index (was aborting the media
analysis lock INSERT).
- bootstrap: drop unused unhandledRejection promise param.
- moderationOrchestrator: drop unused destructure at L197.
- mediaDownloader / textBatchProcessor / transmitter: replace non-null
assertions with proper null guards (stickerName ?? '', urlImages.get
guard, backpressureQueue.shift guard).
- backend utils: throw lastError ?? fallback instead of lastError!.
- message-capture: remove unused (retentionDb), (moderationActionsDb,
reviewsDb); simplify renderDiscordMentions guard to optional chain.
- transmitter: remove dead write-only field + its assignments.
No behavior change beyond the cache-key hashing (now deterministic
fixed-length) and the intentional null-safety guards.
imageResizer.ts had a line exceeding the print width that biome flagged
as a formatter error, failing the Build & Deploy biome check. Re-format
the file. No logic change.
Two root causes behind 'all image analysis failing':
1. imageResizer still emitted lossless PNG for vision input. A 1024px
Facebook photo balloons to multi-MB PNG base64 that the vision model
silently rejects ('Vision API null response'). Switch to JPEG q85
(no upscaling) — same photo drops to ~100-400KB, model processes fine.
Re-encodes even already-small images so raw originals never bloat the
data URL. Added tests/imageResizer.test.ts covering both cases.
2. acquireMediaAnalysisLock INSERT aborted with 'index row requires N
bytes, maximum size is 8191'. text_analysis_cache.text is the PK in a
B-tree index (8191-byte/row cap); callers pass the raw image URL as the
key, and base64 data URLs / very long URLs blow past the limit, so the
lock INSERT fails and every media analysis is skipped. Hash the URL in
makeImageCacheKey (image:<sha256[:32]>) — fixed-length, deterministic,
well under the limit. All store/get/lock/delete callers already route
through this function so lookup stays consistent.
dontPatchELF only disabled the patchELF sub-phase; fixupPhase's
shrinkELF step still emits the same error on the prebuilt .node addons
and .o/.a object files in node_modules. Skip the entire fixupPhase
(dontFixup = true) for the gateway — node is the external interpreter
and .node addons are self-contained dlopen prebuilts, so Nix RPATH
patching/stripping is neither needed nor wanted.
Add dontPatchELF = true to the discord-gateway derivation. Nix's
fixupPhase runs patchELF over $out/node_modules and chokes on the
non-ET_DYN ELF files (.o/.a objects + prebuilt .node addons), emitting
hundreds of non-fatal 'patchelf: wrong ELF type' lines per build. The
real binary is node (external, RPATH-fixed) and the .node addons are
self-contained prebuilts loaded via dlopen, so Nix RPATH patching is
neither needed nor wanted. Shebang patching still runs.
Drop npm_config_build_from_source=true so node-pre-gyp downloads the
published prebuilt .node for Node 22 (ABI node-v127, linux-x64-glibc-2.35)
instead of compiling libopus C++ every build. Replace the hardcoded
'npm run install' (node-gyp compile) loop with 'pnpm rebuild @discordjs/opus'
which runs the package's own install script (prebuilt fetch, source build
only as fallback). sharp already uses @img prebuilt packages (its install
script failure is non-fatal), so only opus was actually compiling.
Router.push was a no-op in the standalone build (Next trailingSlash
interaction), so the sidebar buttons and command palette silently failed
to navigate. Replaced next/link + router.push with plain <a href> anchors
in NavRail and CommandPalette — verified working on all routes.
Biome tightened to zero warnings:
- Disable noArrayIndexKey (positional equalizer bars), noStaticElementInteractions
(intentional dismiss/hover overlays), useMediaCaption (voice clips)
- Avatar uses background-image instead of <img> (noImgElement)
- Command palette list items keyed correctly
- Format pass to satisfy the formatter
- Created nav-debug.cjs to log anchor tags and simulate clicks on the Voice navigation link, capturing click events and page navigation.
- Added nav-test.cjs to test the Voice link click and log the URL at various intervals, capturing any page errors.
- Introduced nav-test2.cjs to check the presence of specific elements on the /voice/ page and log any console errors.
- Implemented nav-test4019.cjs to monitor network requests and responses related to the Voice navigation, verifying button presence and click functionality.
Hapus template dashboard lama (top bar + side rail + main + right panel +
bottom prompt). Ganti dengan layout yang benar-benar beda:
- AmbientField: full-bleed WebGL canvas haze, drift speed + densitas
ngikut load server, warna ngikut signal moderasi terakhir
(clean→lime, warn→amber, flagged→vermilion). Background tanpa container.
- View jadi full-bleed: headline raksasa bottom-left, metric cluster
floating top-right (no box), event ribbon drift di tengah, command
whisper di very bottom.
- AmbientShell di layout.tsx: gak ada TopBar/LeftRail untuk /dashboard
exact. Route lain (messages/voice/media/dll) tetap ClassicShell.
- Tidak ada card, tidak ada grid, tidak ada panel, tidak ada tab.
Verified: tsc clean, next build 11/11 halaman, biome clean.
- config: add AI_LLM_VISION_BASE_URL + AI_LLM_VISION_API_KEY (separate from text router)
- llmClient: llmVision() now calls dedicated vision endpoint when configured
(axios POST to integrate.api.nvidia.com, model nvidia/nemotron-3-nano-omni-30b-a3b-reasoning,
reasoning_budget 16384, non-stream), falls back to router combo otherwise
- keeps text/moderation on omniroute, vision on NVIDIA direct
- VoiceView now reads connected/activeChannelName from useVoiceStatus
(SWR live, invalidated by connect/disconnect) instead of initialStatus
- Seed useSpeakers from live status.activeSpeakers
- Add 4s refreshInterval to useVoiceStatus so state converges
(tsc clean, next build green)
Symptom: video plays ~1s then freezes. BaseMediaStream sync logic:
- video _pts advances 33.3ms/frame (timeBase 1/fps), audio _pts advances
20ms/packet (timeBase 1/48000) — two synthetic frame-index timebases that
never share a clock.
- If audio starts late (ffmpeg audio init / Ogg header), ptsDelta = video-audio
stays positive → isAhead() true → video loops 'await sleep(frametime) while
isAhead()' → video freezes. Downchain: vPipe fills → proc.stdout paused →
demuxer emits ~15fps (log: 30 frames per 2s).
Upstream dank sets syncStream because node-av provides REAL PTS from NUT in a
consistent timebase. Our raw-h264 demuxer has no real PTS; per-stream sleep-PTS
pacing alone keeps both at 1000ms/s, which is correct without a shared clock.
Re-enable sync only if real PTS is added.
Lag root cause: vPipe/aPipe were objectMode PassThrough HWM 128 → the pipe
held up to 128 frames ≈ 4.3s of video before backpressure reached the encoder.
The viewer was watching a 4+ second stale backlog.
Fixes (both faithful to @dank074/discord-video-stream):
1. vPipe/aPipe HWM 2 — at most ~1-2 frames in flight (~66ms @ 30fps), so the
writeFrame() backpressure pauses ffmpeg stdout almost immediately and the
whole chain (encoder → NUT → demuxer → vPipe → BaseMediaStream → WebRTC)
runs at the sender's real pace, exactly like dank's 'resume &&= vPipe.write'.
2. Wire vStream.syncStream = aStream — audio is the master clock; video
sleeps/wakes on ptsDelta like upstream newApi.js. Prevents A/V drift under
variable encoder throughput.