import { deepMerge, sanitizeBotText, sleep } from "../utils.ts"; import { buildInitiativePrompt, buildSystemPrompt } from "../prompts/index.ts"; import { getMediaPromptCraftGuidance, getPromptStyle } from "../prompts/promptCore.ts"; import { composeDiscoveryImagePrompt, composeDiscoveryVideoPrompt, extractUrlsFromText, INITIATIVE_OUTPUT_JSON_SCHEMA, normalizeSkipSentinel, parseStructuredInitiativeOutput, splitDiscordMessage } from "./botHelpers.ts"; import { getBotName, getDiscoverySettings, getMemorySettings, getReplyPermissions, getResolvedTextInitiativeBinding, isResearchEnabled, getTextInitiativeSettings } from "../settings/agentStack.ts"; import { loadBehavioralMemoryFacts } from "./memorySlice.ts"; import { buildContextContentBlocks, type ContentBlock, type ContextMessage } from "../llm/serviceShared.ts"; import { executeReplyTool } from "../tools/replyTools.ts"; import type { ReplyToolContext, ReplyToolRuntime } from "../tools/replyTools.ts"; import { BROWSER_BROWSE_SCHEMA, DISCOVERY_SOURCE_ADD_SCHEMA, DISCOVERY_SOURCE_LIST_SCHEMA, DISCOVERY_SOURCE_REMOVE_SCHEMA, MEMORY_SEARCH_SCHEMA, WEB_SCRAPE_SCHEMA, WEB_SEARCH_SCHEMA, toAnthropicTool } from "../tools/sharedToolSchemas.ts"; import { normalizeDiscoveryUrl } from "../services/discovery.ts"; import type { BotContext } from "./botContext.ts";
const INITIATIVE_TICK_MAX_RUNTIME_MS = 30_000; const INITIATIVE_SOURCE_STATS_WINDOW_DAYS = 14; const INITIATIVE_LOOKBACK_MAX_CHANNEL_MESSAGES = 5; const INITIATIVE_MIN_GAP_ACTION_KINDS = [ "initiative_post", "initiative_skip" ] as const; const SOURCE_TYPE_LABELS = { reddit: "Reddit", rss: "RSS", youtube: "YouTube", x: "X" } as const;
const INTEREST_STOP_WORDS = new Set([ "about", "again", "also", "been", "cant", "dont", "from", "have", "just", "like", "more", "only", "really", "some", "that", "their", "there", "they", "this", "want", "with", "would", "your" ]);
type InitiativeGuildLike = { id?: string; };
type InitiativeChannelLike = { id: string; guildId?: string; name?: string; guild?: InitiativeGuildLike | null; isTextBased?: () => boolean; send?: (payload: unknown) => Promise<{ id: string; createdTimestamp: number; guildId: string; channelId: string; }>; sendTyping?: () => Promise; messages?: { fetch?: (messageId: string) => Promise<{ reply: (payload: unknown) => Promise<{ id: string; createdTimestamp: number; guildId: string; channelId: string; }>; }>; }; };
type InitiativeClientLike = BotContext["client"] & { user?: { id?: string; username?: string; } | null; channels: { cache: { get: (id: string) => InitiativeChannelLike | undefined; }; }; };
type StoredMessageRow = { message_id?: string; created_at?: string; guild_id?: string | null; channel_id?: string; author_id?: string; author_name?: string; is_bot?: boolean | number; content?: string; referenced_message_id?: string | null; };
type DiscoveryCandidate = { title?: string; url?: string; source?: string; sourceLabel?: string; excerpt?: string; publishedAt?: string | null; };
type InitiativePendingThoughtStatus = "queued" | "reconsider"; type InitiativePendingThoughtAction = "post_now" | "hold" | "drop";
export type InitiativePendingThought = { id: string; guildId: string; channelId: string; channelName: string; trigger: string; draftText: string; currentText: string; createdAt: number; updatedAt: number; basisAt: number; notBeforeAt: number; expiresAt: number; revision: number; status: InitiativePendingThoughtStatus; lastDecisionReason: string | null; lastDecisionAction: InitiativePendingThoughtAction | null; mediaDirective: "none" | "image" | "video" | "gif"; mediaPrompt: string | null; };
export type InitiativeRuntime = BotContext & { readonly client: InitiativeClientLike; readonly discovery: { collect: (payload: { settings: Record<string, unknown>; guildId: string; channelId: string; channelName: string; recentMessages: StoredMessageRow[]; }) => Promise<{ enabled: boolean; topics: string[]; candidates: DiscoveryCandidate[]; selected: DiscoveryCandidate[]; reports: Array<{ source?: string; fetched?: number; accepted?: number; error?: string | null; }>; errors: string[]; }>; } | null; readonly search: ReplyToolRuntime["search"]; initiativeCycleRunning: boolean; getPendingInitiativeThoughts: () => Map<string, InitiativePendingThought>; getPendingInitiativeThought: (guildId: string) => InitiativePendingThought | null; setPendingInitiativeThought: (guildId: string, thought: InitiativePendingThought | null) => void; canSendMessage: (maxPerHour: number) => boolean; canTalkNow: (settings: Record<string, unknown>) => boolean; hydrateRecentMessages: (channel: InitiativeChannelLike, limit: number) => Promise<unknown[]>; isChannelAllowed: (settings: Record<string, unknown>, channelId: string) => boolean; isNonPrivateReplyEligibleChannel: (channel: InitiativeChannelLike | null | undefined) => boolean; getSimulatedTypingDelayMs: (minMs: number, jitterMs: number) => number; markSpoke: () => void; composeMessageContentForHistory: (message: unknown, baseText?: string) => string; loadRelevantMemoryFacts: (payload: { settings: Record<string, unknown>; guildId?: string | null; channelId?: string | null; queryText?: string; trace?: Record<string, unknown>; limit?: number; }) => Promise<Array<Record<string, unknown>>>; buildMediaMemoryFacts: (payload: { userFacts: Array<Record<string, unknown>>; relevantFacts: Array<Record<string, unknown>>; }) => string[]; getImageBudgetState: (settings: Record<string, unknown>) => { canGenerate: boolean; remaining: number; }; getVideoGenerationBudgetState: (settings: Record<string, unknown>) => { canGenerate: boolean; remaining: number; }; getGifBudgetState: (settings: Record<string, unknown>) => { canFetch: boolean; remaining: number; }; getMediaGenerationCapabilities: (settings: Record<string, unknown>) => { simpleImageReady: boolean; complexImageReady: boolean; videoReady: boolean; }; resolveMediaAttachment: (payload: { settings: Record<string, unknown>; text: string; directive: { type: string | null; gifQuery?: string | null; imagePrompt?: string | null; complexImagePrompt?: string | null; videoPrompt?: string | null; }; trace: { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; }; }) => Promise<{ payload: { content: string; files?: unknown[]; }; media: unknown; }>; buildBrowserBrowseContext: (settings: Record<string, unknown>) => { enabled?: boolean; configured?: boolean; budget?: { canBrowse?: boolean; }; }; runModelRequestedBrowserBrowse: (payload: { settings: Record<string, unknown>; browserBrowse: { enabled?: boolean; configured?: boolean; budget?: { canBrowse?: boolean; }; }; query?: string; guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; signal?: AbortSignal; }) => Promise<{ used?: boolean; text?: string; steps?: number; hitStepLimit?: boolean; error?: string | null; blockedByBudget?: boolean; }>; };
type InitiativeChannelSummary = { guildId: string; channelId: string; channelName: string; channel: InitiativeChannelLike; recentMessages: StoredMessageRow[]; recentHumanMessageCount: number; lastHumanAt: string | null; lastHumanMessageId: string | null; lastHumanAuthorName: string | null; lastHumanSnippet: string | null; lastBotAt: string | null; };
type InitiativeSourceStat = { label: string; sharedCount: number; fetchedCount: number; engagementCount: number; lastUsedAt: string | null; };
const INITIATIVE_PENDING_THOUGHT_REVISIT_MS = 30_000; const INITIATIVE_PENDING_THOUGHT_MIN_EXPIRY_MS = 30 * 60_000; const INITIATIVE_PENDING_THOUGHT_HARD_MAX_AGE_MS = 2 * 60 * 60_000;
function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
function normalizePendingInitiativeThought( thought: InitiativePendingThought | null | undefined ): InitiativePendingThought | null { if (!thought || typeof thought !== "object") return null; const currentText = sanitizeBotText(String(thought.currentText || "").trim(), 1800); if (!currentText) return null; const guildId = String(thought.guildId || "").trim(); const channelId = String(thought.channelId || "").trim(); if (!guildId || !channelId) return null; const rawMediaDirective = String(thought.mediaDirective || "none").trim().toLowerCase(); const mediaDirective = rawMediaDirective === "image" || rawMediaDirective === "video" || rawMediaDirective === "gif" ? rawMediaDirective : "none"; const mediaPrompt = mediaDirective === "none" ? null : sanitizeBotText(String(thought.mediaPrompt || "").trim(), 900) || null; return { ...thought, guildId, channelId, channelName: String(thought.channelName || channelId).trim() || channelId, trigger: String(thought.trigger || "timer").trim() || "timer", draftText: sanitizeBotText(String(thought.draftText || currentText).trim(), 1800) || currentText, currentText, revision: Math.max(1, Number(thought.revision || 1)), status: thought.status === "reconsider" ? "reconsider" : "queued", lastDecisionReason: String(thought.lastDecisionReason || "").trim() || null, lastDecisionAction: thought.lastDecisionAction === "post_now" || thought.lastDecisionAction === "hold" || thought.lastDecisionAction === "drop" ? thought.lastDecisionAction : null, mediaDirective, mediaPrompt }; }
function clearPendingInitiativeThought(
runtime: InitiativeRuntime,
guildId: string,
{
reason = "cleared",
trigger = "timer",
now = Date.now()
}: {
reason?: string;
trigger?: string;
now?: number;
} = {}
) {
const pendingThought = normalizePendingInitiativeThought(runtime.getPendingInitiativeThought(guildId));
runtime.setPendingInitiativeThought(guildId, null);
if (!pendingThought) return null;
runtime.store.logAction({
kind: "initiative_skip",
guildId,
channelId: pendingThought.channelId,
userId: runtime.client.user?.id || null,
content: pending_thought_${reason},
metadata: {
thoughtId: pendingThought.id,
thoughtText: pendingThought.currentText,
thoughtRevision: pendingThought.revision,
trigger,
ageMs: Math.max(0, Math.round(now - Number(pendingThought.createdAt || now)))
}
});
return pendingThought;
}
function resolvePendingInitiativeThoughtExpiryAt( existingThought: InitiativePendingThought | null, minGapMs: number, now = Date.now() ) { const createdAt = Number(existingThought?.createdAt || now); const rollingExpiryAt = now + Math.max(INITIATIVE_PENDING_THOUGHT_MIN_EXPIRY_MS, minGapMs * 2); const boundedExpiryAt = Math.min(createdAt + INITIATIVE_PENDING_THOUGHT_HARD_MAX_AGE_MS, rollingExpiryAt); const previousExpiryAt = Number(existingThought?.expiresAt || 0); return previousExpiryAt > 0 ? Math.min(previousExpiryAt, boundedExpiryAt) : boundedExpiryAt; }
function pendingInitiativeThoughtIsExpired( thought: InitiativePendingThought | null | undefined, now = Date.now() ) { if (!thought) return false; const expiresAt = Number(thought.expiresAt || 0); const createdAt = Number(thought.createdAt || 0); const hardExpiresAt = createdAt > 0 ? createdAt + INITIATIVE_PENDING_THOUGHT_HARD_MAX_AGE_MS : 0; return ( (expiresAt > 0 && now >= expiresAt) || (hardExpiresAt > 0 && now >= hardExpiresAt) ); }
function savePendingInitiativeThought(
runtime: InitiativeRuntime,
{
guildId,
channelId,
channelName,
trigger,
draftText,
thoughtText,
mediaDirective,
mediaPrompt,
reason,
minGapMs,
existingThought = null,
now = Date.now()
}: {
guildId: string;
channelId: string;
channelName: string;
trigger: string;
draftText: string;
thoughtText: string;
mediaDirective: "none" | "image" | "video" | "gif";
mediaPrompt: string | null;
reason: string;
minGapMs: number;
existingThought?: InitiativePendingThought | null;
now?: number;
}
) {
const currentText = sanitizeBotText(String(thoughtText || "").trim(), 1800);
if (!currentText) {
return clearPendingInitiativeThought(runtime, guildId, {
reason: "empty_hold_thought",
trigger,
now
});
}
const expiresAt = resolvePendingInitiativeThoughtExpiryAt(existingThought, minGapMs, now);
if (expiresAt <= now) {
return clearPendingInitiativeThought(runtime, guildId, {
reason: "expired",
trigger,
now
});
}
const nextThought: InitiativePendingThought = {
id: existingThought?.id || ${guildId}:initiative:${now.toString(36)},
guildId,
channelId,
channelName,
trigger: String(trigger || existingThought?.trigger || "timer").trim() || "timer",
draftText: sanitizeBotText(String(draftText || currentText).trim(), 1800) || currentText,
currentText,
createdAt: existingThought?.createdAt || now,
updatedAt: now,
basisAt: now,
notBeforeAt: now + INITIATIVE_PENDING_THOUGHT_REVISIT_MS,
expiresAt,
revision: existingThought ? Math.max(1, Number(existingThought.revision || 1)) + 1 : 1,
status: "queued",
lastDecisionReason: String(reason || "").trim() || null,
lastDecisionAction: "hold",
mediaDirective,
mediaPrompt
};
runtime.setPendingInitiativeThought(guildId, nextThought);
runtime.store.logAction({
kind: "initiative_skip",
guildId,
channelId,
userId: runtime.client.user?.id || null,
content: existingThought ? "pending_thought_updated" : "pending_thought_created",
metadata: {
thoughtId: nextThought.id,
thoughtText: nextThought.currentText,
thoughtRevision: nextThought.revision,
trigger,
notBeforeAt: new Date(nextThought.notBeforeAt).toISOString(),
expiresAt: new Date(nextThought.expiresAt).toISOString(),
mediaDirective: nextThought.mediaDirective,
reason: nextThought.lastDecisionReason
}
});
return nextThought;
}
function buildInitiativeGenerationSettings(settings: Record<string, unknown>) { const binding = getResolvedTextInitiativeBinding(settings); return deepMerge(deepMerge({}, settings), { agentStack: { overrides: { orchestrator: { provider: binding.provider, model: binding.model } } }, interaction: { replyGeneration: { temperature: binding.temperature, maxOutputTokens: binding.maxOutputTokens, reasoningEffort: binding.reasoningEffort } } }); }
function countRecentActions(store: InitiativeRuntime["store"], kind: string, sinceIso: string) { return Number(store.countActionsSince(kind, sinceIso) || 0); }
export function getEligibleInitiativeChannelIds(settings: Record<string, unknown>) { const permissions = getReplyPermissions(settings); // Discovery channels are the canonical pool for proactive/initiative posts. // If empty, the initiative cycle has no eligible channels. return [...new Set( (Array.isArray(permissions.discoveryChannelIds) ? permissions.discoveryChannelIds : []) .map((value) => String(value || "").trim()) .filter(Boolean) )]; }
function getLastActionTimes(store: InitiativeRuntime["store"], kinds: string[]) { return kinds .map((kind) => store.getLastActionTime(kind)) .filter(Boolean) .map((value) => Date.parse(String(value))) .filter((value) => Number.isFinite(value)); }
function buildEligibleChannels(runtime: InitiativeRuntime, settings: Record<string, unknown>) { const lookbackMessages = Math.max( 4, Math.min(80, Number(getTextInitiativeSettings(settings).lookbackMessages) || 20) ); const candidateIds = getEligibleInitiativeChannelIds(settings);
return Promise.all(candidateIds.map(async (channelId) => { if (!runtime.isChannelAllowed(settings, channelId)) return null; const channel = runtime.client.channels.cache.get(channelId); if (!runtime.isNonPrivateReplyEligibleChannel(channel)) return null;
await runtime.hydrateRecentMessages(channel, lookbackMessages);
const rowsNewestFirst = runtime.store.getRecentMessages(channel.id, lookbackMessages) as StoredMessageRow[];
const recentMessages = rowsNewestFirst
.slice(0, INITIATIVE_LOOKBACK_MAX_CHANNEL_MESSAGES)
.reverse();
const lastHourCutoff = Date.now() - 60 * 60_000;
let lastHumanAt: string | null = null;
let lastHumanMessageId: string | null = null;
let lastHumanAuthorName: string | null = null;
let lastHumanSnippet: string | null = null;
let lastBotAt: string | null = null;
let recentHumanMessageCount = 0;
for (const row of rowsNewestFirst) {
const createdAtText = String(row?.created_at || "").trim();
const createdAtMs = Date.parse(createdAtText);
const isBot = row?.is_bot === true || row?.is_bot === 1;
const content = String(row?.content || "").replace(/\s+/g, " ").trim();
if (!isBot && Number.isFinite(createdAtMs) && createdAtMs >= lastHourCutoff) {
recentHumanMessageCount += 1;
}
if (!isBot && !lastHumanAt && createdAtText) {
lastHumanAt = createdAtText;
lastHumanMessageId = String(row?.message_id || "").trim() || null;
lastHumanAuthorName = String(row?.author_name || "").trim() || null;
lastHumanSnippet = content.slice(0, 180) || null;
}
if (isBot && !lastBotAt && createdAtText) {
lastBotAt = createdAtText;
}
}
return {
guildId: String(channel.guildId || channel.guild?.id || "").trim(),
channelId: channel.id,
channelName: String(channel.name || channel.id).trim() || channel.id,
channel,
recentMessages,
recentHumanMessageCount,
lastHumanAt,
lastHumanMessageId,
lastHumanAuthorName,
lastHumanSnippet,
lastBotAt
} satisfies InitiativeChannelSummary;
})).then((rows) => rows .filter((row): row is InitiativeChannelSummary => Boolean(row?.guildId && row.channelId)) ); }
function pickGuildChannelSet(channels: InitiativeChannelSummary[]) { const byGuild = new Map<string, InitiativeChannelSummary[]>(); for (const channel of channels) { const group = byGuild.get(channel.guildId) || []; group.push(channel); byGuild.set(channel.guildId, group); }
let bestGuildId = ""; let bestScore = -1; for (const [guildId, group] of byGuild.entries()) { const latestHumanMs = Math.max( ...group.map((entry) => Date.parse(String(entry.lastHumanAt || ""))).filter(Number.isFinite), 0 ); const totalRecentHuman = group.reduce((sum, entry) => sum + entry.recentHumanMessageCount, 0); const score = totalRecentHuman * 10_000_000 + latestHumanMs; if (score > bestScore) { bestScore = score; bestGuildId = guildId; } }
return bestGuildId ? byGuild.get(bestGuildId) || [] : []; }
function collectPendingInitiativeThoughtCandidates( runtime: InitiativeRuntime, eligibleChannels: InitiativeChannelSummary[], now = Date.now() ) { const blockedGuildIds = new Set(); const pendingThoughts = [...runtime.getPendingInitiativeThoughts().values()] .map((thought) => normalizePendingInitiativeThought(thought)) .filter((thought): thought is InitiativePendingThought => Boolean(thought)) .sort((left, right) => Number(left.createdAt || 0) - Number(right.createdAt || 0)); const dueCandidates: Array<{ pendingThought: InitiativePendingThought; guildChannels: InitiativeChannelSummary[]; }> = [];
for (const pendingThought of pendingThoughts) { if (pendingInitiativeThoughtIsExpired(pendingThought, now)) { clearPendingInitiativeThought(runtime, pendingThought.guildId, { reason: "expired", trigger: pendingThought.trigger, now }); continue; }
const guildChannels = eligibleChannels.filter((channel) => channel.guildId === pendingThought.guildId);
if (!guildChannels.length) {
clearPendingInitiativeThought(runtime, pendingThought.guildId, {
reason: "guild_no_longer_eligible",
trigger: pendingThought.trigger,
now
});
continue;
}
blockedGuildIds.add(pendingThought.guildId);
const hasNewGuildActivity = guildChannels.some((channel) =>
channel.recentMessages.some((message) => {
const createdAtMs = Date.parse(String(message?.created_at || ""));
return Number.isFinite(createdAtMs) && createdAtMs > Number(pendingThought.basisAt || 0);
})
);
if (hasNewGuildActivity && pendingThought.status !== "reconsider") {
const refreshedThought = {
...pendingThought,
status: "reconsider" as const,
updatedAt: now
};
runtime.setPendingInitiativeThought(pendingThought.guildId, refreshedThought);
if (Number(refreshedThought.notBeforeAt || 0) <= now) {
dueCandidates.push({
pendingThought: refreshedThought,
guildChannels
});
}
continue;
}
if (Number(pendingThought.notBeforeAt || 0) <= now) {
dueCandidates.push({
pendingThought,
guildChannels
});
}
}
return { dueCandidates, blockedGuildIds }; }
function buildInterestFacts({ recentGuildMessages, eligibleChannels, sourceStats }: { recentGuildMessages: StoredMessageRow[]; eligibleChannels: InitiativeChannelSummary[]; sourceStats: InitiativeSourceStat[]; }) { const facts: string[] = []; const tokenCounts = new Map<string, number>();
for (const row of recentGuildMessages) { if (row?.is_bot === true || row?.is_bot === 1) continue; const matches = String(row?.content || "") .toLowerCase() .match(/[a-z][a-z0-9_-]{3,24}/g) || []; for (const token of matches) { if (INTEREST_STOP_WORDS.has(token)) continue; tokenCounts.set(token, Number(tokenCounts.get(token) || 0) + 1); } }
const topTokens = [...tokenCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([token]) => token);
if (topTokens.length) {
facts.push(Recent chatter keeps circling around ${topTokens.join(", ")}.);
}
const activeChannel = eligibleChannels
.slice()
.sort((a, b) => b.recentHumanMessageCount - a.recentHumanMessageCount)[0];
if (activeChannel?.recentHumanMessageCount) {
facts.push(#${activeChannel.channelName} is the liveliest eligible channel right now.);
}
const topSource = sourceStats
.filter((entry) => entry.engagementCount > 0)
.slice()
.sort((a, b) => b.engagementCount - a.engagementCount)[0];
if (topSource) {
facts.push(${topSource.label} has been getting the strongest response from recent proactive posts.);
}
return facts.slice(0, 8); }
function configuredSourceLabels(discoverySettings: ReturnType) {
const labels = new Set();
if (discoverySettings.sources.reddit) {
for (const sub of discoverySettings.redditSubreddits) {
labels.add(r/${String(sub).replace(/^r\//i, "").trim()});
}
}
if (discoverySettings.sources.hackerNews) {
labels.add("Hacker News");
}
if (discoverySettings.sources.youtube) {
for (const channelId of discoverySettings.youtubeChannelIds) {
labels.add(YouTube ${String(channelId).trim()});
}
}
if (discoverySettings.sources.rss) {
for (const feedUrl of discoverySettings.rssFeeds) {
try {
labels.add(new URL(feedUrl).hostname);
} catch {
labels.add(String(feedUrl).trim());
}
}
}
if (discoverySettings.sources.x) {
for (const handle of discoverySettings.xHandles) {
labels.add(@${String(handle).replace(/^@/, "").trim()});
}
}
return [...labels];
}
function buildSourcePerformanceSummary( runtime: InitiativeRuntime, { guildId, discoverySettings, discoveryCandidates }: { guildId: string; discoverySettings: ReturnType; discoveryCandidates: DiscoveryCandidate[]; } ) { const sinceIso = new Date(Date.now() - INITIATIVE_SOURCE_STATS_WINDOW_DAYS * 24 * 60 * 60_000).toISOString(); const sourceLabels = configuredSourceLabels(discoverySettings); const rows = new Map<string, InitiativeSourceStat>();
const ensureRow = (label: string) => { const normalizedLabel = String(label || "").trim(); if (!normalizedLabel) return null; const existing = rows.get(normalizedLabel); if (existing) return existing; const next: InitiativeSourceStat = { label: normalizedLabel, sharedCount: 0, fetchedCount: 0, engagementCount: 0, lastUsedAt: null }; rows.set(normalizedLabel, next); return next; };
for (const label of sourceLabels) { ensureRow(label); } for (const candidate of discoveryCandidates) { const label = String(candidate?.sourceLabel || candidate?.source || "").trim(); const row = ensureRow(label); if (!row) continue; row.fetchedCount += 1; }
const recentActions = runtime.store.getRecentActions(300, { guildId, sinceIso, kinds: ["initiative_post", "discovery_feed_snapshot"] });
const initiativePosts = recentActions.filter((row) => row.kind === "initiative_post"); const messageIds = initiativePosts .map((row) => String(row?.message_id || "").trim()) .filter(Boolean); const engagementByMessageId = new Map( runtime.store.getReferencedMessageStats({ guildId, sinceIso, messageIds }).map((row) => [ String(row?.referenced_message_id || "").trim(), Number(row?.reaction_count || 0) + Number(row?.reply_count || 0) ]) );
for (const action of recentActions) { const metadata = isRecord(action?.metadata) ? action.metadata : {}; if (action.kind === "discovery_feed_snapshot") { const counts = Array.isArray(metadata.sourceCounts) ? metadata.sourceCounts : []; for (const entry of counts) { if (!isRecord(entry)) continue; const label = String(entry.sourceLabel || "").trim(); const row = ensureRow(label); if (!row) continue; row.fetchedCount += Math.max(0, Number(entry.count || 0)); } continue; }
const sourceLabelsForPost = Array.isArray(metadata.sourceLabels) ? metadata.sourceLabels : [];
const actionCreatedAt = String(action?.created_at || "").trim() || null;
const engagementCount = engagementByMessageId.get(String(action?.message_id || "").trim()) || 0;
for (const labelValue of sourceLabelsForPost) {
const row = ensureRow(String(labelValue || ""));
if (!row) continue;
row.sharedCount += 1;
row.engagementCount += engagementCount;
if (actionCreatedAt && (!row.lastUsedAt || Date.parse(actionCreatedAt) > Date.parse(row.lastUsedAt))) {
row.lastUsedAt = actionCreatedAt;
}
}
}
return [...rows.values()] .sort((a, b) => b.engagementCount - a.engagementCount || b.sharedCount - a.sharedCount || a.label.localeCompare(b.label) ) .slice(0, 10); }
function summarizeDiscoveryCandidates(discoveryCandidates: DiscoveryCandidate[]) { const counts = new Map<string, number>(); for (const candidate of discoveryCandidates) { const label = String(candidate?.sourceLabel || candidate?.source || "").trim(); if (!label) continue; counts.set(label, Number(counts.get(label) || 0) + 1); } return [...counts.entries()].map(([sourceLabel, count]) => ({ sourceLabel, count })); }
function sanitizeSourceValue(sourceType: string, rawValue: unknown) { const text = String(rawValue || "").trim(); if (!text) return ""; if (sourceType === "reddit") return text.replace(/^r//i, "").trim(); if (sourceType === "x") return text.replace(/^@/, "").trim(); if (sourceType === "rss") return normalizeDiscoveryUrl(text) || ""; return text; }
function buildSourceListSummary(discoverySettings: ReturnType) {
const lines = [
reddit (${discoverySettings.redditSubreddits.length}/${discoverySettings.maxSourcesPerType}): ${discoverySettings.redditSubreddits.join(", ") || "(none)"},
rss (${discoverySettings.rssFeeds.length}/${discoverySettings.maxSourcesPerType}): ${discoverySettings.rssFeeds.join(", ") || "(none)"},
youtube (${discoverySettings.youtubeChannelIds.length}/${discoverySettings.maxSourcesPerType}): ${discoverySettings.youtubeChannelIds.join(", ") || "(none)"},
x (${discoverySettings.xHandles.length}/${discoverySettings.maxSourcesPerType}): ${discoverySettings.xHandles.join(", ") || "(none)"}
];
return lines.join("
");
}
function updateDiscoverySources({
runtime,
settings,
sourceType,
value,
operation,
guildId,
channelId
}: {
runtime: InitiativeRuntime;
settings: Record<string, unknown>;
sourceType: string;
value: string;
operation: "add" | "remove";
guildId: string;
channelId: string | null;
}) {
const discoverySettings = getDiscoverySettings(settings);
if (!discoverySettings.allowSelfCuration) {
return { content: "Discovery self-curation is disabled.", isError: true };
}
if (!(sourceType in SOURCE_TYPE_LABELS)) {
return { content: Unsupported source type: ${sourceType}, isError: true };
}
if (discoverySettings.sources[sourceType as keyof typeof discoverySettings.sources] !== true) {
return { content: ${SOURCE_TYPE_LABELS[sourceType as keyof typeof SOURCE_TYPE_LABELS]} sources are disabled in settings., isError: true };
}
const normalizedValue = sanitizeSourceValue(sourceType, value); if (!normalizedValue) { return { content: "Missing or invalid source value.", isError: true }; }
const key = sourceType === "reddit" ? "redditSubreddits" : sourceType === "rss" ? "rssFeeds" : sourceType === "youtube" ? "youtubeChannelIds" : "xHandles"; const currentList = Array.isArray(discoverySettings[key]) ? discoverySettings[key] : []; const nextList = operation === "add" ? [...new Set([...currentList, normalizedValue])] : currentList.filter((entry) => String(entry || "").trim() !== normalizedValue);
if (operation === "add" && currentList.includes(normalizedValue)) {
return {
content: Already subscribed to ${normalizedValue}. ${buildSourceListSummary(discoverySettings)}
};
}
if (operation === "remove" && currentList.length === nextList.length) {
return {
content: ${normalizedValue} is not currently subscribed. ${buildSourceListSummary(discoverySettings)}
};
}
if (operation === "add" && nextList.length > discoverySettings.maxSourcesPerType) {
return {
content: Cannot add ${normalizedValue}. ${SOURCE_TYPE_LABELS[sourceType as keyof typeof SOURCE_TYPE_LABELS]} is capped at ${discoverySettings.maxSourcesPerType} sources.,
isError: true
};
}
const nextSettings = runtime.store.patchSettings({
initiative: {
discovery: {
[key]: nextList
}
}
});
const nextDiscoverySettings = getDiscoverySettings(nextSettings);
runtime.store.logAction({
kind: operation === "add" ? "initiative_source_add" : "initiative_source_remove",
guildId,
channelId,
userId: runtime.client.user?.id || null,
content: ${sourceType}:${normalizedValue},
metadata: {
sourceType,
value: normalizedValue,
currentSources: buildSourceListSummary(nextDiscoverySettings)
}
});
return {
content: ${operation === "add" ? "Added" : "Removed"} ${normalizedValue}. ${buildSourceListSummary(nextDiscoverySettings)}
};
}
async function executeInitiativeTool( runtime: InitiativeRuntime, { toolName, input, settings, guildId, channelId, signal }: { toolName: string; input: Record<string, unknown>; settings: Record<string, unknown>; guildId: string; channelId: string | null; signal?: AbortSignal; } ) { if (toolName === "discovery_source_list") { return { content: buildSourceListSummary(getDiscoverySettings(settings)) }; } if (toolName === "discovery_source_add" || toolName === "discovery_source_remove") { return updateDiscoverySources({ runtime, settings, sourceType: String(input?.sourceType || "").trim().toLowerCase(), value: String(input?.value || "").trim(), operation: toolName === "discovery_source_add" ? "add" : "remove", guildId, channelId }); }
const toolRuntime: ReplyToolRuntime = {
search: runtime.search,
browser: {
browse: async ({ settings: toolSettings, query, guildId: toolGuildId, channelId: toolChannelId, userId, source, signal: toolSignal }) => {
const browserBrowse = runtime.buildBrowserBrowseContext(toolSettings);
return await runtime.runModelRequestedBrowserBrowse({
settings: toolSettings,
browserBrowse,
query,
guildId: toolGuildId,
channelId: toolChannelId,
userId,
source,
signal: toolSignal
});
}
},
memory: runtime.memory,
store: runtime.store
};
const toolContext: ReplyToolContext = {
settings,
guildId,
channelId,
userId: runtime.client.user?.id || "",
sourceMessageId: initiative:${Date.now()},
sourceText: "",
botUserId: runtime.client.user?.id || undefined,
trace: {
guildId,
channelId,
userId: runtime.client.user?.id || null,
source: "initiative_tool"
},
signal
};
return await executeReplyTool(toolName, input, toolRuntime, toolContext);
}
function initiativeToolSet({ settings, allowWebSearch, allowWebScrape, allowBrowserBrowse, allowSelfCuration }: { settings: Record<string, unknown>; allowWebSearch: boolean; allowWebScrape: boolean; allowBrowserBrowse: boolean; allowSelfCuration: boolean; }) { const tools = []; const memoryEnabled = Boolean(getMemorySettings(settings).enabled); if (allowWebSearch) { tools.push(toAnthropicTool(WEB_SEARCH_SCHEMA)); } if (allowWebScrape) { tools.push(toAnthropicTool(WEB_SCRAPE_SCHEMA)); } if (allowBrowserBrowse) { tools.push(toAnthropicTool(BROWSER_BROWSE_SCHEMA)); } if (memoryEnabled) { tools.push(toAnthropicTool(MEMORY_SEARCH_SCHEMA)); } if (allowSelfCuration) { tools.push(toAnthropicTool(DISCOVERY_SOURCE_LIST_SCHEMA)); tools.push(toAnthropicTool(DISCOVERY_SOURCE_ADD_SCHEMA)); tools.push(toAnthropicTool(DISCOVERY_SOURCE_REMOVE_SCHEMA)); } return tools; }
export async function maybeRunInitiativeCycle(runtime: InitiativeRuntime) { if (runtime.initiativeCycleRunning) return; runtime.initiativeCycleRunning = true;
try { const settings = runtime.store.getSettings(); const initiative = getTextInitiativeSettings(settings); const permissions = getReplyPermissions(settings); const discoverySettings = getDiscoverySettings(settings); const memorySettings = getMemorySettings(settings); if (!initiative.enabled) return; if (initiative.maxPostsPerDay <= 0) return; if (!runtime.canSendMessage(permissions.maxMessagesPerHour)) return; if (!runtime.canTalkNow(settings)) return;
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const posts24h = countRecentActions(runtime.store, "initiative_post", since24h);
if (posts24h >= initiative.maxPostsPerDay) return;
const now = Date.now();
const minGapMs = Math.max(1, Number(initiative.minMinutesBetweenPosts || 0) * 60_000);
const eligibleChannels = await buildEligibleChannels(runtime, settings);
const {
dueCandidates: duePendingCandidates,
blockedGuildIds: pendingThoughtGuildIds
} = collectPendingInitiativeThoughtCandidates(runtime, eligibleChannels, now);
const freshEligibleChannels = eligibleChannels.filter((channel) => !pendingThoughtGuildIds.has(channel.guildId));
const freshGuildChannels = pickGuildChannelSet(freshEligibleChannels);
let freshPassAllowed = false;
if (freshGuildChannels.length) {
const lastPostTimes = getLastActionTimes(runtime.store, [...INITIATIVE_MIN_GAP_ACTION_KINDS]);
const lastPostTs = lastPostTimes.length ? Math.max(...lastPostTimes) : 0;
if (!lastPostTs || now - lastPostTs >= minGapMs) {
const eagerness = Math.max(0, Math.min(100, Number(initiative.eagerness) || 0));
const roll = Math.random() * 100;
freshPassAllowed = roll < eagerness;
}
}
const selectedPendingCandidate = !freshPassAllowed && duePendingCandidates.length > 0
? duePendingCandidates[0]
: null;
const pendingThought = selectedPendingCandidate?.pendingThought || null;
const guildChannels = selectedPendingCandidate?.guildChannels || freshGuildChannels;
if (!guildChannels.length) return;
const isPendingThoughtPass = Boolean(pendingThought);
if (!isPendingThoughtPass && !freshPassAllowed) return;
const guildId = guildChannels[0].guildId;
const recentGuildMessages = runtime.store.getRecentMessagesAcrossGuild(guildId, 180) as StoredMessageRow[];
const recentGuildQuery = recentGuildMessages
.slice(0, 24)
.map((row) => String(row?.content || "").trim())
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.slice(0, 500);
const recentGuildParticipantIds = [...new Set(
recentGuildMessages
.map((row) => String(row?.author_id || "").trim())
.filter(Boolean)
)];
const discoveryResult = runtime.discovery
? await runtime.discovery.collect({
settings,
guildId,
channelId: guildChannels[0].channelId,
channelName: guildChannels[0].channelName,
recentMessages: recentGuildMessages.slice(0, 20)
})
: {
enabled: false,
topics: [],
candidates: [],
selected: [],
reports: [],
errors: []
};
const memoryFacts = memorySettings.enabled
? await runtime.loadRelevantMemoryFacts({
settings,
guildId,
channelId: guildChannels[0].channelId,
queryText: [
recentGuildQuery,
...discoveryResult.candidates.slice(0, 4).map((item) => String(item?.title || "").trim())
]
.filter(Boolean)
.join(" ")
.slice(0, 500),
trace: {
guildId,
channelId: guildChannels[0].channelId,
userId: runtime.client.user?.id || null,
source: "initiative_prompt"
},
limit: 10
})
: [];
const guildProfile =
memorySettings.enabled && typeof runtime.memory?.loadGuildFactProfile === "function"
? runtime.memory.loadGuildFactProfile({
guildId
})
: { guidanceFacts: [] };
const behavioralFacts = await loadBehavioralMemoryFacts(runtime, {
settings,
guildId,
channelId: guildChannels[0].channelId,
queryText: [
recentGuildQuery,
...discoveryResult.candidates.slice(0, 4).map((item) => String(item?.title || "").trim())
]
.filter(Boolean)
.join(" ")
.slice(0, 500),
participantIds: recentGuildParticipantIds,
trace: {
guildId,
channelId: guildChannels[0].channelId,
userId: runtime.client.user?.id || null,
source: "initiative_behavioral_memory"
},
limit: 8
});
const sourcePerformance = buildSourcePerformanceSummary(runtime, {
guildId,
discoverySettings,
discoveryCandidates: discoveryResult.candidates
});
runtime.store.logAction({
kind: "discovery_feed_snapshot",
guildId,
channelId: guildChannels[0].channelId,
userId: runtime.client.user?.id || null,
content: `candidates=${discoveryResult.candidates.length}`,
metadata: {
sourceCounts: summarizeDiscoveryCandidates(discoveryResult.candidates)
}
});
const communityInterestFacts = buildInterestFacts({
recentGuildMessages,
eligibleChannels: guildChannels,
sourceStats: sourcePerformance
});
const imageBudget = runtime.getImageBudgetState(settings);
const videoBudget = runtime.getVideoGenerationBudgetState(settings);
const gifBudget = runtime.getGifBudgetState(settings);
const mediaCapabilities = runtime.getMediaGenerationCapabilities(settings);
const allowActiveCuriosity = Boolean(initiative.allowActiveCuriosity);
const webSearchToolAvailable = allowActiveCuriosity && isResearchEnabled(settings);
const browserBrowseContext = runtime.buildBrowserBrowseContext(settings);
const browserBrowseToolAvailable = allowActiveCuriosity &&
Boolean(browserBrowseContext.enabled) &&
Boolean(browserBrowseContext.configured) &&
browserBrowseContext.budget?.canBrowse !== false;
const allowSelfCuration = Boolean(discoverySettings.allowSelfCuration);
const botName = getBotName(settings);
const persona = getPromptStyle(settings);
const systemPrompt = buildSystemPrompt(settings);
const userPrompt = buildInitiativePrompt({
botName,
persona,
initiativeEagerness: Math.max(0, Math.min(100, Number(initiative.eagerness) || 0)),
channelSummaries: guildChannels,
pendingThought: pendingThought
? {
currentText: pendingThought.currentText,
status: pendingThought.status,
revision: pendingThought.revision,
ageMs: Math.max(0, Date.now() - Number(pendingThought.createdAt || Date.now())),
channelName: pendingThought.channelName,
lastDecisionReason: pendingThought.lastDecisionReason,
mediaDirective: pendingThought.mediaDirective,
mediaPrompt: pendingThought.mediaPrompt
}
: null,
discoveryCandidates: discoveryResult.candidates,
sourcePerformance,
communityInterestFacts,
relevantFacts: memoryFacts,
guidanceFacts: Array.isArray(guildProfile?.guidanceFacts) ? guildProfile.guidanceFacts : [],
behavioralFacts,
allowActiveCuriosity,
allowWebSearch: webSearchToolAvailable,
allowWebScrape: webSearchToolAvailable,
allowBrowserBrowse: browserBrowseToolAvailable,
allowMemorySearch: memorySettings.enabled,
allowSelfCuration,
allowImagePosts:
discoverySettings.allowImagePosts &&
imageBudget.canGenerate &&
(mediaCapabilities.simpleImageReady || mediaCapabilities.complexImageReady),
allowVideoPosts:
discoverySettings.allowVideoPosts &&
videoBudget.canGenerate &&
mediaCapabilities.videoReady,
allowGifPosts:
discoverySettings.allowReplyGifs &&
gifBudget.canFetch,
remainingImages: imageBudget.remaining,
remainingVideos: videoBudget.remaining,
remainingGifs: gifBudget.remaining,
maxMediaPromptChars: discoverySettings.maxMediaPromptChars,
mediaPromptCraftGuidance: getMediaPromptCraftGuidance(settings)
});
const initiativeSettings = buildInitiativeGenerationSettings(settings);
const tools = initiativeToolSet({
settings,
allowWebSearch: webSearchToolAvailable,
allowWebScrape: webSearchToolAvailable,
allowBrowserBrowse: browserBrowseToolAvailable,
allowSelfCuration
});
const trace = {
guildId,
channelId: guildChannels[0].channelId,
userId: runtime.client.user?.id || null,
source: "initiative_cycle",
event: isPendingThoughtPass ? "initiative_pending_thought" : "initiative_post",
reason: null,
messageId: null
};
let contextMessages: ContextMessage[] = [];
let generation = await runtime.llm.generate({
settings: initiativeSettings,
systemPrompt,
userPrompt,
contextMessages,
jsonSchema: tools.length ? "" : INITIATIVE_OUTPUT_JSON_SCHEMA,
tools,
trace
});
const startedAt = Date.now();
let toolLoopSteps = 0;
let totalToolCalls = 0;
while (
generation.toolCalls?.length &&
toolLoopSteps < Math.max(0, Number(initiative.maxToolSteps) || 0) &&
totalToolCalls < Math.max(0, Number(initiative.maxToolCalls) || 0) &&
Date.now() - startedAt < INITIATIVE_TICK_MAX_RUNTIME_MS
) {
const assistantContent = buildContextContentBlocks(generation.rawContent, generation.text);
if (contextMessages.length === 0) {
contextMessages = [
{ role: "user", content: userPrompt },
{ role: "assistant", content: assistantContent }
];
} else {
contextMessages = [
...contextMessages,
{ role: "assistant", content: assistantContent }
];
}
const toolResultMessages: ContentBlock[] = [];
for (const toolCall of generation.toolCalls) {
if (totalToolCalls >= Math.max(0, Number(initiative.maxToolCalls) || 0)) break;
totalToolCalls += 1;
const result = await executeInitiativeTool(runtime, {
toolName: toolCall.name,
input: isRecord(toolCall.input) ? toolCall.input : {},
settings,
guildId,
channelId: guildChannels[0].channelId
});
toolResultMessages.push({
type: "tool_result",
tool_use_id: toolCall.id,
content: result.content
});
}
contextMessages = [
...contextMessages,
{ role: "user", content: toolResultMessages }
];
generation = await runtime.llm.generate({
settings: initiativeSettings,
systemPrompt,
userPrompt: "",
contextMessages,
jsonSchema: "",
tools,
trace: {
...trace,
event: `initiative_post:tool_loop:${toolLoopSteps + 1}`
}
});
toolLoopSteps += 1;
}
const decision = parseStructuredInitiativeOutput(
generation.text,
discoverySettings.maxMediaPromptChars
);
if (decision.parseState === "unstructured" || decision.contractViolation) {
runtime.store.logAction({
kind: "initiative_skip",
guildId,
channelId: pendingThought?.channelId || guildChannels[0]?.channelId || null,
userId: runtime.client.user?.id || null,
content: decision.contractViolationReason || decision.reason || "skip",
metadata: {
parseState: decision.parseState,
reason: decision.reason || null,
contractViolation: decision.contractViolation,
contractViolationReason: decision.contractViolationReason,
pendingThoughtId: pendingThought?.id || null
}
});
return;
}
if (decision.action === "drop" || decision.skip) {
if (pendingThought) {
clearPendingInitiativeThought(runtime, guildId, {
reason: decision.reason || "model_drop",
trigger: pendingThought.trigger,
now: Date.now()
});
} else {
runtime.store.logAction({
kind: "initiative_skip",
guildId,
channelId: null,
userId: runtime.client.user?.id || null,
content: decision.reason || "skip",
metadata: {
parseState: decision.parseState,
reason: decision.reason || null
}
});
}
return;
}
const selectedChannel = guildChannels.find((channel) => channel.channelId === decision.channelId);
if (!selectedChannel || !selectedChannel.channel.send || !selectedChannel.channel.sendTyping) {
runtime.store.logAction({
kind: "bot_error",
guildId,
channelId: decision.channelId,
userId: runtime.client.user?.id || null,
content: `initiative_invalid_channel:${String(decision.channelId || "")}`
});
return;
}
const normalizedText = sanitizeBotText(normalizeSkipSentinel(decision.text || ""), 1800);
if (!normalizedText || normalizedText === "[SKIP]") return;
if (decision.action === "hold") {
const heldMediaDirective: "none" | "image" | "video" | "gif" =
decision.mediaDirective === "image" || decision.mediaDirective === "video" || decision.mediaDirective === "gif"
? decision.mediaDirective
: "none";
savePendingInitiativeThought(runtime, {
guildId,
channelId: selectedChannel.channelId,
channelName: selectedChannel.channelName,
trigger: pendingThought?.trigger || "timer",
draftText: pendingThought?.draftText || pendingThought?.currentText || normalizedText,
thoughtText: normalizedText,
mediaDirective: heldMediaDirective,
mediaPrompt: decision.mediaPrompt,
reason: decision.reason || "hold",
minGapMs,
existingThought: pendingThought,
now: Date.now()
});
return;
}
const mediaMemoryFacts = runtime.buildMediaMemoryFacts({
userFacts: [],
relevantFacts: memoryFacts
});
const mediaAttachment = await runtime.resolveMediaAttachment({
settings,
text: normalizedText,
directive: {
type:
decision.mediaDirective === "image"
? "image_simple"
: decision.mediaDirective === "video"
? "video"
: decision.mediaDirective === "gif"
? "gif"
: null,
gifQuery: decision.mediaDirective === "gif" ? decision.mediaPrompt : null,
imagePrompt:
decision.mediaDirective === "image" && decision.mediaPrompt
? composeDiscoveryImagePrompt(
decision.mediaPrompt,
normalizedText,
discoverySettings.maxMediaPromptChars,
mediaMemoryFacts
)
: null,
complexImagePrompt: null,
videoPrompt:
decision.mediaDirective === "video" && decision.mediaPrompt
? composeDiscoveryVideoPrompt(
decision.mediaPrompt,
normalizedText,
discoverySettings.maxMediaPromptChars,
mediaMemoryFacts
)
: null
},
trace: {
guildId,
channelId: selectedChannel.channelId,
userId: runtime.client.user?.id || null,
source: "initiative_post"
}
});
await selectedChannel.channel.sendTyping();
await sleep(runtime.getSimulatedTypingDelayMs(350, 900));
const chunks = splitDiscordMessage(mediaAttachment.payload.content);
const firstPayload = { ...mediaAttachment.payload, content: chunks[0] };
const replyToMessageId = String(decision.replyToMessageId || "").trim() || null;
let replyTarget = null;
if (replyToMessageId) {
try {
replyTarget = await selectedChannel.channel.messages.fetch(replyToMessageId);
} catch {
// Message not found or inaccessible — fall back to standalone post
}
}
const sent = replyTarget
? await replyTarget.reply(firstPayload)
: await selectedChannel.channel.send(firstPayload);
for (let index = 1; index < chunks.length; index += 1) {
await selectedChannel.channel.send({ content: chunks[index] });
}
runtime.markSpoke();
runtime.setPendingInitiativeThought(guildId, null);
runtime.store.recordMessage({
messageId: sent.id,
createdAt: sent.createdTimestamp,
guildId: sent.guildId,
channelId: sent.channelId,
authorId: runtime.client.user?.id || "unknown",
authorName: botName,
isBot: true,
content: runtime.composeMessageContentForHistory(sent, normalizedText),
referencedMessageId: replyToMessageId
});
const includedUrls = extractUrlsFromText(normalizedText)
.map((url) => normalizeDiscoveryUrl(url))
.filter(Boolean) as string[];
const matchedDiscoveryItems = discoveryResult.candidates.filter((candidate) =>
includedUrls.includes(normalizeDiscoveryUrl(candidate?.url || "") || "")
);
for (const url of includedUrls) {
runtime.store.recordSharedLink({
url,
source: matchedDiscoveryItems.find((item) => normalizeDiscoveryUrl(item?.url || "") === url)?.sourceLabel || "initiative_post"
});
}
runtime.store.logAction({
kind: "initiative_post",
guildId: sent.guildId,
channelId: sent.channelId,
messageId: sent.id,
userId: runtime.client.user?.id || null,
content: normalizedText,
metadata: {
reason: decision.reason || null,
pendingThoughtId: pendingThought?.id || null,
pendingThoughtRevision: pendingThought?.revision || null,
mediaDirective: decision.mediaDirective,
sourceLabels: [...new Set(
matchedDiscoveryItems
.map((item) => String(item?.sourceLabel || item?.source || "").trim())
.filter(Boolean)
)],
urls: includedUrls,
llm: {
provider: generation.provider,
model: generation.model,
usage: generation.usage,
costUsd: generation.costUsd,
toolLoopSteps,
totalToolCalls
},
channelSummary: {
channelId: selectedChannel.channelId,
channelName: selectedChannel.channelName
},
discoveryCandidateCount: discoveryResult.candidates.length
}
});
} finally { runtime.initiativeCycleRunning = false; } }
