feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

+35 -29
View File
@@ -12,7 +12,8 @@ import {
RecipientHistoryRepository,
} from '../database/repositories.js';
import { AccountContentRepository } from '../database/account-repositories.js';
import { RedditApiClient } from '../reddit/api.js';
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
import { OpenRouterClient } from '../ai/openrouter.js';
import { MonitorService } from './monitor.js';
import { ProcessorService, isSkip } from './processor.js';
@@ -52,8 +53,9 @@ export function createConcreteRedditAccountRuntimeFactory(
const recipientDeduplicator = new RecipientDeduplicator(
new RecipientHistoryRepository(options.db),
);
const redditRequestCoordinator = new RedditRequestCoordinator();
return new RedditAccountRuntimeFactory(
(session) => buildRuntime(options, session, recipientDeduplicator),
(session) => buildRuntime(options, session, recipientDeduplicator, redditRequestCoordinator),
);
}
@@ -61,6 +63,7 @@ async function buildRuntime(
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
session: VerifiedAccountSession,
recipientDeduplicator: RecipientDeduplicator,
redditRequestCoordinator: RedditRequestCoordinator,
): Promise<RedditAccountRuntime> {
const browserProfileDir = accountBrowserProfileDir(
path.resolve(process.cwd(), config.browser.sessionDir),
@@ -107,7 +110,7 @@ async function buildRuntime(
const accountContentRepo = new AccountContentRepository(db);
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
const redditApi = new RedditApiClient(accountConfig, logger);
const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator);
redditApi.setBrowserContext(browserClient.getContext()!);
const chatPage = await browserClient.getContext()!.newPage();
const aiClient = new OpenRouterClient(accountConfig, logger);
@@ -135,7 +138,7 @@ async function buildRuntime(
const chatService = new ChatService(
matrixClient, logger, successfulMessagesRepo, mediaService,
new MatrixTokenRefresher(), usernameResolver, chatCache,
messagesRepo, redditUsersRepo, accountAuth,
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
);
await chatService.loadCache();
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
@@ -227,23 +230,23 @@ class AccountRuntimeLoop {
nextResponseCheckAt = Date.now() + responseInterval;
await this.resources.responseChecker.checkForResponses();
}
if (!this.resources.chatService.getInvitationCooldown()) {
await this.processPosts(signal);
}
await delay(this.config.reddit.pollIntervalMs, signal);
} catch (error) {
if (signal.aborted) return;
if (error instanceof RedditRateLimitError) {
await delay(error.retryAfterMs + 1_000, signal);
continue;
}
this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error);
await delay(60_000, signal);
}
}
}
private async processPosts(signal: AbortSignal): Promise<void> {
const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
const posts = await monitorService.getUnprocessedPosts();
for (const post of posts) {
if (signal.aborted) return;
async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
const { postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
if (signal?.aborted) return;
try {
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
const isChat = subreddit?.type === 'chat';
const imageUrl = isChat ? null : redditApi.getImageUrl(post);
@@ -251,25 +254,26 @@ class AccountRuntimeLoop {
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(),
});
try {
if (
this.config.reddit.filterSellers
&& (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
&& await redditApi.isSellerProfile(post.author, this.config.reddit.sellerKeywords, this.config.reddit.sellerDomains)
) {
postsRepo.setError(post.id, 'Seller profile detected');
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (seller profile)`, recipient: post.author });
continue;
}
// Seller screening is performed once by the shared MonitorService before
// assignment. Do not repeat its profile request for every recipient.
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
const processed = await processorService.processPost(post);
if (isSkip(processed)) {
postsRepo.setError(post.id, processed.skipReason);
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author });
continue;
return;
}
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
await rateLimiter.throttle(async () => {
if (this.config.browser.dryRun) {
const reason = 'Dry run: message prepared but not sent';
postsRepo.setError(post.id, reason);
this.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'skipped',
message: `${reason} to u/${post.author}`, recipient: post.author,
});
return false;
}
if (!this.recipientDeduplicator.tryReserve(post.author)) {
postsRepo.setError(post.id, 'User already contacted across accounts');
this.resources.activityLogRepo.insert({
@@ -295,6 +299,9 @@ class AccountRuntimeLoop {
postId: post.id, recipient: post.author, subject: post.author,
message: processed.generatedMessage, sentAt: Date.now(),
strategyId: processed.strategyId, chatRoomId: chat.roomId,
experimentId: processed.experimentId, promptVersion: processed.promptVersion,
modelId: processed.modelId, selectionReason: processed.selectionReason,
sourceType: processed.sourceType,
});
this.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
@@ -303,12 +310,11 @@ class AccountRuntimeLoop {
void chatService.refreshTopChats(5);
return true;
}, (success) => success, signal);
} catch (error) {
if (signal.aborted) return;
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
}
} catch (error) {
if (signal?.aborted) return;
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
}
}
}