docs: add agent codebase guide

This commit is contained in:
jjbrunton committed 2026-08-09 16:21:57 +01:00
1 parent 742c06d0c4
commit c630303b47
12 files changed
+881 -4483

No files matched your search

+95 -781
View File
@@ -1,781 +1,95 @@
# Service Catalog
Detailed documentation for each service in the RedditBot codebase.
---
## Table of Contents
1. [MonitorService](#monitorservice)
2. [ProcessorService](#processorservice)
3. [StrategySelector](#strategypicker)
4. [ResponseChecker](#responsechecker)
5. [ChatService](#chatservice)
6. [MediaService](#mediaservice)
7. [MediaSyncService](#mediasyncservice)
8. [AutoReplyService](#autoreplyservice)
9. [RedditApi](#redditapi)
10. [OpenRouterService](#openrouterservice)
11. [BrowserClient](#browserclient)
12. [MessagePage](#messagepage)
13. [RateLimiter](#ratelimiter)
---
## MonitorService
**File**: `src/services/monitor.ts`
**Lines**: 72
**Responsibility**: Discover new image posts across configured subreddits
### Purpose
Polls configured subreddits at regular intervals, discovers new image posts, filters out already-processed posts and previously-messaged users, and returns the single newest valid post for processing.
### Public Methods
```typescript
class MonitorService {
constructor(
private redditApi: RedditApi,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private config: Config
)
/**
* Discover the next post to process.
* Returns null if no valid posts are available.
*/
discoverNextPost(): Promise<Post | null>
}
```
### Algorithm
1. Shuffle subreddit order (randomize polling order each cycle)
2. For each subreddit:
- Fetch new posts via `RedditApi.fetchPosts()`
- Filter: image posts only
- Filter: author not deleted
- Filter: post not already in `processed_posts`
- Filter: author not previously messaged (or meets re-message criteria)
3. Return the newest valid post across all subreddits
4. If no valid posts found, return `null`
### Dependencies
- `RedditApi` - Fetch posts from Reddit
- `PostsRepository` - Check for already-processed posts
- `MessagesRepository` - Check for previously-messaged users
- `Config` - Subreddit list, poll interval, target gender
### Error Handling
- Skips subreddits that return errors (continues to next)
- Logs errors at `warn` level
- Returns `null` on any unrecoverable error
---
## ProcessorService
**File**: `src/services/processor.ts`
**Lines**: 132
**Responsibility**: Core processing pipeline for discovered posts
### Purpose
Orchestrates the full processing pipeline for a single discovered post: image download, gender detection, AI analysis, strategy selection, message generation, and DM sending.
### Public Methods
```typescript
class ProcessorService {
constructor(
private config: Config,
private redditApi: RedditApi,
private openrouter: OpenRouterService,
private strategySelector: StrategySelector,
private messageSender: MessagePage,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private successfulRepo: SuccessfulMessagesRepository,
private logger: Logger
)
/**
* Process a single discovered post through the full pipeline.
*/
processPost(post: Post): Promise<void>
}
```
### Pipeline Steps
```
1. Download image
└─► ImageService.downloadImage(url)
└─► Validate MIME type (image/jpeg, image/png, image/webp)
└─► Validate file size
└─► Convert to base64
2. Quick gender detection (cost-saving pre-filter)
└─► OpenRouter.detectGenderOnly(image)
└─► Skip if gender doesn't match targetGender
3. Select A/B strategy variant
└─► StrategySelector.selectStrategy(subreddit, author)
4. Full AI analysis
└─► OpenRouter.analyzeImage(image)
└─► Detect gender, describe image
└─► Handle refusal (mark post, try next)
5. Generate message
└─► OpenRouter.generateMessage(analysis, title, examples, strategy)
└─► Few-shot examples from SuccessfulMessagesRepository
6. Send DM
└─► MessagePage.sendDM(author, "Stunning", message)
└─► Handle failure (log, don't mark as sent)
7. Record in database
└─► Mark post as processed
└─► Mark message_sent = 1 if successful
└─► Store generated_message for reference
```
### Error Handling
- AI refusal: Post is marked as processed with error, skipped
- Image download failure: Post is marked with error, skipped
- DM send failure: Post is marked as not sent, logged
- All errors logged with context (post ID, subreddit, author)
### Cost Optimization
- Quick gender detection costs less than full analysis
- Non-matching genders are skipped before expensive analysis
- AI refusal detection prevents wasted tokens on rejected prompts
---
## StrategySelector
**File**: `src/services/strategy-selector.ts`
**Lines**: 101
**Responsibility**: A/B testing with explore/exploit algorithm
### Purpose
Selects message variants (strategies) for each post using an explore/exploit algorithm based on historical response rates. Optimizes for maximum response rate over time while continuing to explore under-tested variants.
### Public Methods
```typescript
class StrategySelector {
constructor(
private config: Config,
private messagesRepo: MessagesRepository
)
/**
* Select the best strategy variant for a given subreddit/author.
*/
selectStrategy(subreddit: string, author: string): string
}
```
### Algorithm
1. **Get strategy stats** from `MessagesRepository`:
- Response count per variant
- Sent count per variant
- Response rate per variant
2. **Apply minimum sample threshold**:
- If a variant has fewer than `minSamples` sends, treat it as untested
- Untested variants are excluded from exploitation
3. **Explore vs Exploit decision**:
- With probability `explorationRate`, select a random variant (explore)
- Otherwise, select the variant with the highest response rate (exploit)
4. **Fallback**:
- If no variants have enough data, select randomly from enabled variants
### Configuration
```yaml
strategyVariants:
- name: "variant_a"
prompt: "Compliment-based message..."
enabled: true
- name: "variant_b"
prompt: "Question-based message..."
enabled: true
```
### Dependencies
- `Config` - Strategy variants, exploration rate, min samples
- `MessagesRepository` - Response rate statistics
---
## ResponseChecker
**File**: `src/services/response-checker.ts`
**Lines**: 179
**Responsibility**: Monitor inbox for user replies
### Purpose
Background service that navigates the Reddit inbox via Playwright, identifies responses to bot-sent messages, updates the database, and stores successful message/response pairs for few-shot learning.
### Public Methods
```typescript
class ResponseCheckerService {
constructor(
private browser: BrowserClient,
private messagesRepo: MessagesRepository,
private successfulRepo: SuccessfulMessagesRepository,
private logger: Logger
)
/**
* Check inbox for responses to sent messages.
*/
checkResponses(): Promise<void>
}
```
### Algorithm
1. Navigate to `/message/inbox` via Playwright
2. Parse inbox items (messages, conversations)
3. For each response item:
- Extract recipient username and message text
- Match to `sent_messages` by recipient + timestamp proximity
- Update `got_response = 1` and `response_checked_at`
- Store the message/response pair in `successful_messages`
4. Update strategy statistics (response rates)
### Dependencies
- `BrowserClient` - Playwright browser context
- `MessagesRepository` - Update response flags
- `SuccessfulMessagesRepository` - Store learning pairs
- `Logger` - Activity logging
---
## ChatService
**File**: `src/services/chat.ts`
**Lines**: 1123+
**Responsibility**: Reddit chat scraping, messaging, and conversation management
### Purpose
The most complex service in the codebase. Manages Reddit chat interactions including scraping active conversations, loading conversation history, sending messages with human-like delays, detecting upsell/spam, and managing conversation caches.
### Public Methods
```typescript
class ChatService {
constructor(
private browser: BrowserClient,
private config: Config,
private logger: Logger
)
/** Fetch active conversations from Reddit */
fetchChats(): Promise<ChatSummary[]>
/** Load full conversation history */
loadConversation(chatId: string): Promise<Conversation>
/** Send a message in a conversation */
sendMessage(chatId: string, text: string): Promise<boolean>
/** Get cached conversations */
getCachedConversations(): Conversation[]
/** Check for new messages in conversations */
checkForUpdates(): Promise<Conversation[]>
/** Detect upsell/spam in a message */
detectUpsell(message: string): boolean
}
```
### Shadow DOM Handling
Reddit uses custom `faceplate-*` web components with shadow DOM. Standard CSS selectors cannot pierce these boundaries. The service uses JavaScript injection via `page.evaluate()`:
```javascript
// Traverse shadow roots to find chat elements
function traverseShadowRoots(root) {
const elements = root.querySelectorAll('.some-class');
const shadowChildren = root.querySelectorAll('*::shadow, ::deep');
// ... recursive traversal
}
```
### Conversation Cache
Conversations are cached in two layers:
1. **In-memory**: Fast access for active conversations
2. **Disk JSON**: Persistence across restarts at `data/chats/`
Cache structure:
```json
{
"chatId": "abc123",
"participants": ["user1", "bot"],
"messages": [
{ "author": "user1", "text": "Hey!", "timestamp": 1234567890 }
],
"lastUpdated": 1234567890,
"hasNewMessages": false
}
```
### Human-Like Messaging
Messages are sent with:
- Typing delay simulation (random delay before "typing" starts)
- Variable typing speed
- Random delays between messages
- Delivery verification
### Known Issues
- **Hardcoded username**: `myUsername = 'Upset_Marsupial1006'` is hardcoded in JS evaluation scripts. Should be parameterized from `Config`.
- **Size**: 1123+ lines suggests this should be split into smaller services.
### Dependencies
- `BrowserClient` - Playwright browser context
- `Config` - Rate limits, username (should be), check intervals
- `Logger` - Activity logging
---
## MediaService
**File**: `src/services/media.ts`
**Lines**: 254
**Responsibility**: Media download, caching, and indexing
### Purpose
Downloads images from chat conversations, resolves Matrix blurred thumbnails to full resolution, stores files locally, and maintains a deduplicated index.
### Public Methods
```typescript
class MediaService {
constructor(
private config: Config,
private browser: BrowserClient,
private logger: Logger
)
/** Download media from a conversation */
downloadMedia(chatId: string): Promise<string[]>
/** Get media index */
getIndex(): MediaIndex
/** Get a specific media file path */
getPath(hash: string): string | null
}
```
### Deduplication
Files are deduplicated using SHA-1 hashing:
```
SHA-1(image_bytes) → data/media/{hash}.{ext}
```
The index maps hashes to file paths:
```json
{
"abc123def456": "abc123def456.jpg",
"789xyz012abc": "789xyz012abc.png"
}
```
### Matrix Thumbnail Resolution
Reddit's Matrix system uses blurred placeholder thumbnails. The service resolves these to full resolution by:
1. Detecting Matrix thumbnail URLs
2. Extracting the full-resolution URL from the response
3. Downloading the full-resolution image
### Dependencies
- `Config` - Media directory path
- `BrowserClient` - Playwright for Matrix resolution
- `Logger` - Download activity
---
## MediaSyncService
**File**: `src/services/media-sync.ts`
**Lines**: 119
**Responsibility**: Periodic background media synchronization
### Purpose
Runs periodic background syncs to download media from chat conversations. Supports both incremental (top 10 chats) and full (all conversations) sync modes.
### Public Methods
```typescript
class MediaSyncService {
constructor(
private config: Config,
private mediaService: MediaService,
private chatService: ChatService,
private logger: Logger
)
/** Start periodic sync */
start(): void
/** Stop periodic sync */
stop(): void
/** Trigger incremental sync */
syncIncremental(): Promise<void>
/** Trigger full sync */
syncFull(): Promise<void>
/** Get sync status */
getStatus(): SyncStatus
}
```
### Sync Modes
| Mode | Description | Use Case |
|---|---|---|
| Incremental | Top 10 most recent conversations | Regular background sync |
| Full | All conversations | Initial setup, catch-up |
### Dependencies
- `Config` - Sync interval, enabled flag
- `MediaService` - Actual download logic
- `ChatService` - Conversation list
- `Logger` - Sync progress
---
## AutoReplyService
**File**: `src/services/auto-reply.ts`
**Lines**: 199
**Responsibility**: Automated reply to chat conversations
### Purpose
Monitors cached conversations for unread messages, detects upsell/spam, generates AI reply suggestions, and either auto-sends or marks as pending for manual approval.
### Public Methods
```typescript
class AutoReplyService {
constructor(
private config: Config,
private chatService: ChatService,
private openrouter: OpenRouterService,
private logger: Logger
)
/** Start auto-reply checking */
start(): void
/** Stop auto-reply checking */
stop(): void
/** Check for unread messages and generate replies */
checkAndReply(): Promise<void>
}
```
### Workflow
```
1. Check cached conversations for unread messages
│
├─► Skip if no new messages
│
├─► Detect upsell/spam keywords
│ └─► Flag conversation, skip reply
│
├─► Generate AI reply suggestion
│ └─► OpenRouter.generateReply(conversation history)
│
├─► If autoSend: send reply immediately
│ └─► ChatService.sendMessage()
│
└─► If !autoSend: mark as pending
└─► Dashboard shows pending replies for approval
```
### Dependencies
- `Config` - Enabled flag, autoSend flag, check interval
- `ChatService` - Conversation access, message sending
- `OpenRouterService` - Reply generation
- `Logger` - Activity logging
---
## RedditApi
**File**: `src/reddit/api.ts`
**Lines**: 233
**Responsibility**: Reddit API client
### Purpose
Axios-based client for interacting with Reddit's public API. Handles post fetching, user profile checking, and authenticated requests using Playwright browser cookies.
### Public Methods
```typescript
class RedditApi {
constructor(
private browser: BrowserClient,
private logger: Logger
)
/** Fetch new posts from a subreddit */
fetchPosts(subreddit: string, limit?: number): Promise<Post[]>
/** Check if a user is a seller/spam */
checkUserProfile(username: string): Promise<UserProfile>
/** Make authenticated request using browser cookies */
authenticatedRequest(url: string): Promise<any>
}
```
### Image URL Resolution
Fallback chain for extracting image URLs from post data:
```
1. post.preview.reddits[0].url (Reddit CDN preview)
2. post.url (i.redd.it direct)
3. post.thumbnail (if not self/spam)
4. Scan post.selftext for imgur/image links
```
### Seller/Spam Detection
Checks user profile for keywords:
- "seller"
- "onlyfans"
- "link in bio"
- Other configured `sellerKeywords`
### Dependencies
- `BrowserClient` - Browser cookies for authentication
- `Logger` - Request logging
---
## OpenRouterService
**File**: `src/ai/openrouter.ts`
**Lines**: 307
**Responsibility**: OpenRouter AI client
### Purpose
Client for the OpenRouter API (OpenAI-compatible SDK). Provides image analysis, message generation, reply suggestions, and upsell detection.
### Public Methods
```typescript
class OpenRouterService {
constructor(private config: Config, private logger: Logger)
/** Full image analysis with model fallback */
analyzeImage(imageBase64: string): Promise<ImageAnalysis>
/** Quick gender detection (cost-saving) */
detectGenderOnly(imageBase64: string): Promise<string>
/** Generate DM message */
generateMessage(
imageAnalysis: ImageAnalysis,
postTitle: string,
strategy: string
): Promise<string>
/** Generate chat reply */
generateReply(conversationHistory: Message[]): Promise<string>
/** Detect upsell/spam keywords */
detectUpsell(message: string): boolean
}
```
### Model Fallback Chain
```
Primary model (config.openrouter.model)
│
└─► If refusal detected:
│
└─► Try each fallback model (config.openrouter.fallbackModels)
│
└─► If all fail: throw error
```
### Refusal Detection
Heuristic patterns to detect when AI models refuse to process prompts:
```typescript
const refusalPatterns = [
/cannot.*analyze/i,
/unable.*process/i,
/against.*policy/i,
/cannot.*view/i,
// ...
];
```
### Few-Shot Learning
`generateMessage()` retrieves successful message/response pairs from the database and includes them as examples in the prompt:
```
"Here are examples of messages that got responses:
- Example 1: {original_message} → {response_snippet}
- Example 2: ...
Generate a similar message for this image..."
```
### Dependencies
- `Config` - API key, model selection, prompts
- `MessagesRepository` - Successful examples for few-shot learning
---
## BrowserClient
**File**: `src/browser/client.ts`
**Lines**: 302
**Responsibility**: Playwright persistent context manager
### Purpose
Manages the headless Playwright context used after an extension dump is imported. It accepts Reddit cookies, verifies account identity, and supplies authenticated context to the remaining browser-backed operations. It never performs login.
### Public Methods
```typescript
class BrowserClient {
constructor(private config: Config, private logger: Logger)
/** Initialize browser context */
initialize(): Promise<void>
/** Verify browser session is active */
verifySession(): Promise<boolean>
/** Import Reddit cookies from a validated extension dump */
importRedditCookies(cookies: Cookie[]): Promise<void>
/** Get page context for operations */
getPage(): Promise<Page>
/** Close browser */
close(): Promise<void>
}
```
### Import Flow
The dashboard validates a combined extension dump, verifies Matrix with `whoami`, imports Reddit cookies, verifies Reddit identity, and only then marks the shared account gate authenticated.
### Session Persistence
- Cookies and storage are saved to `config.browser.userDataDir`
- Subsequent launches restore the session automatically
- Session expiry pauses account work until a fresh extension dump is imported
### Dependencies
- `Config` - Persistent session directory
- `Logger` - Import verification progress and errors
---
## RateLimiter
**File**: `src/utils/rate-limiter.ts`
**Lines**: 114
**Responsibility**: Token-bucket rate limiting
### Purpose
Enforces rate limits on bot actions (primarily message sending) to avoid detection and bans. Uses a combination of minimum delay, random jitter, and hourly caps.
### Public Methods
```typescript
class RateLimiter {
constructor(config: RateLimitConfig)
/** Wait until a slot is available */
waitForSlot(): Promise<void>
/** Get remaining time until next slot */
getTimeUntilSlot(): number
/** Get current hourly count */
getHourlyCount(): number
/** Reset hourly counter */
resetHourlyCounter(): void
}
```
### Rate Limiting Strategy
```
1. Check hourly cap:
└─► If maxMessagesPerHour reached: wait until hour rolls over
2. Enforce minimum delay:
└─► lastActionTime + minDelayMs + random(0, jitterMs)
3. Track action timestamp
4. Increment hourly counter
```
### Dependencies
- `Config` - Rate limit settings
- `p-queue` - Concurrency control
# Service and module map
This is an ownership map, not an API dump. Read the owning implementation and tests before changing behavior.
## Lifecycle and account ownership
| Module | Owns | Important constraints |
| --- | --- | --- |
| `src/bootstrap.ts` | Dependency composition and startup wiring | Keep business policy in services; preserve initialization order and test seams. |
| `src/main-loop.ts` | Shared discovery/screening/dispatch workers and legacy loop | Loops are abortable; queue transitions and shutdown must remain recoverable. |
| `services/reddit-account-runtime.ts` | Immutable account runtime/factory/manager lifecycle | Same-account operations serialize; session refresh replaces one runtime. |
| `services/reddit-account-runtime-builder.ts` | Concrete account dependency graph and account loop | Account-specific browser, Matrix, repos, limiter, automation, chat, and media live here. |
| `services/reddit-accounts.ts` | Account import/activation orchestration | Verification must bind Reddit cookies and Matrix identity before activation. |
| `services/account-auth-context.ts` | Current verified identity snapshot | Account work pauses unless both sides authenticate. |
| `services/session-bound-runtime.ts` | Legacy auth-bound background lifecycle | Prevents background work while logged out. |
## Discovery, preparation, and delivery
| Module | Owns | Important constraints |
| --- | --- | --- |
| `services/monitor.ts` | Subreddit ordering, candidate fetching, age/gender/seller screening, rejection summaries | Cheap checks first; preserve exact rejection/deferred counts; listing 404 disables the subreddit. |
| `reddit/api.ts` | Reddit JSON calls, post parsing, seller/profile evidence, username resolution, error types | All reads must use the coordinator; redact error metadata. |
| `reddit/request-coordinator.ts` | Process-wide Reddit request spacing and cooldown | Space failed requests too; 429 cooldown is global. |
| `services/processor.ts` | Source-specific AI preparation and outbound safety | Returns prepared or explicit skip; performs no remote chat send. |
| `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. |
| `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. |
| `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. |
| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts | Mutations require the expected worker lease; recover expired/interrupted work. |
## Conversation, chat, and media
| Module | Owns | Important constraints |
| --- | --- | --- |
| `services/chat/service.ts` | Matrix conversation sync, cache updates, starts/replies, scoped cooldowns | Room-creation limits pause new outreach without blocking existing-room replies; Matrix acceptance and UI visibility are distinct. |
| `services/chat/matrix/client.ts` | Matrix HTTP protocol and error classification | Authentication/rate-limit errors need typed handling. |
| `services/chat/cache.ts` | Account-scoped cached conversations and sync state | SQLite is durable; do not share caches across accounts. |
| `services/chat/matrix/username-resolver.ts` | Matrix/Reddit identity mapping | Preserve unresolved internal IDs without leaking them in operator text. |
| `services/chat/matrix/token-refresher.ts` | Explicit refresh-unavailable behavior | New credentials come from verified extension dumps, not token sniffing. |
| `services/conversation-automation.ts` | Reply-first policy, claims, retries, handoffs, turn budgets | Snapshot policy; claim before send; reconcile pending claims; fail closed. |
| `services/conversation-state.ts` | Pure conversation parsing/state/safety helpers | Conversation text is untrusted data; keep prompt boundaries explicit. |
| `services/outbound-message-safety.ts` | Final no-proximity/no-meeting rule | This is defense in depth beyond prompts. |
| `services/conversation-outcome*.ts` | Response outcome classification/recording | Keep analytics attribution account/message scoped. |
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
| `services/media.ts` | Download, deduplicate, index, and list private media | Preserve account ownership and authenticated serving. |
| `services/media-sync.ts` | Periodic authenticated media sync | Stop while auth is unavailable and on runtime shutdown. |
## Persistence
| Module | Owns |
| --- | --- |
| `database/client.ts` | Database open settings and ordered migrations. |
| `database/repositories.ts` | Posts, messages, successes, activity, outbox, states, claims, outcomes, users, recipient history. |
| `database/account-repositories.ts` | Verified account records, conversations, sync cursors, and media content. |
| `database/ai-usage-repository.ts` | Model request/cost/reliability records. |
| `database/legacy-content-import.ts` | One-time ownership of legacy file-backed chat/media data. |
| `config/persistence.ts` | Secret-free application configuration in SQLite. |
Repositories should expose domain operations rather than raw SQL to callers. Multi-step state transitions belong in SQLite transactions when partial completion would break invariants.
## Web and agent surfaces
| Module | Owns |
| --- | --- |
| `web/server.ts` | Express lifecycle, middleware order, route registration, static dashboard/media. |
| `web/auth.ts` | Dashboard session and bearer-token authorization. |
| `web/context.ts` | Narrow route dependency container. |
| `web/routes/*.ts` | Focused endpoint families. |
| `web/api-catalog.ts` | Operation metadata, OpenAPI, and capability discovery. |
| `web/agent-job-manager.ts` | Bounded async sync jobs and job status. |
| `web/events/*` | Typed event publication, replay, subscriptions, and Express SSE. |
| `web/log-broadcaster.ts` | Recent logs plus legacy/typed stream adapters. |
| `mcp/api-client.ts`, `mcp/server.ts` | HTTP-backed stdio MCP tools. |
Authorization is semantic, not just authentication: read and control agent tokens intentionally cannot import sessions, send messages, activate accounts, mutate legacy config, or use debug routes.
## Frontend
| Area | Owns |
| --- | --- |
| `apps/dashboard/src/app/` | Shell, routing, providers, auth status, notifications, realtime repair. |
| `apps/dashboard/src/api/` | HTTP client, schema parsing, query keys/hooks. |
| `apps/dashboard/src/features/` | Route-level feature slices. |
| `apps/dashboard/src/components/` | Reusable domain-aware UI elements. |
| `packages/api-contracts/` | Shared strict transport schemas and inferred types. |
## Utility boundaries
- `utils/logger.ts`: Winston creation, child context, and attachable sinks. No production `console.log`.
- `utils/retry.ts`: bounded retry behavior for operations that are safe to repeat.
- `utils/image.ts`: download/MIME/size helpers.
- `utils/gender-tag.ts`: deterministic self-declared tag parsing.
- `utils/fetch-with-timeout.ts`: abortable HTTP timeout wrapper.
- `utils/json-safe.ts`: safe serialization for log/event data.
When a module starts owning two independent reasons to change, extract a narrow collaborator and inject it. Do not solve size alone by moving methods into a generic utility file.