From 77476d5cbfe5bab80b795899bbf9427f9a9065ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 2 May 2026 21:22:56 +0100 Subject: [PATCH] Add private profile disclosure command --- apps/bot/src/discord-bot-handlers.ts | 64 +++++++++++++++ apps/bot/src/discord-bot-support.ts | 116 +++++++++++++++++++++++++++ apps/bot/src/discord-bot.ts | 10 +++ config/system-messages.json | 8 ++ packages/shared/src/index.ts | 1 + state/evidence.jsonl | 1 + 6 files changed, 200 insertions(+) diff --git a/apps/bot/src/discord-bot-handlers.ts b/apps/bot/src/discord-bot-handlers.ts index 70022a4..eda6c53 100644 --- a/apps/bot/src/discord-bot-handlers.ts +++ b/apps/bot/src/discord-bot-handlers.ts @@ -45,6 +45,8 @@ import { inferSourceGroundingHint, renderRateLimitMessage, renderSystemMessage, + renderInteractionProfileDisclosure, + sendDirectMessage, sendChunkedChannelMessage, splitDiscordContent, truncate, @@ -98,6 +100,9 @@ export const commandDefinitions = [ new SlashCommandBuilder() .setName("provider-status") .setDescription("Show current provider availability for you."), + new SlashCommandBuilder() + .setName("profile") + .setDescription("DM your current Void profile read to you."), new SlashCommandBuilder() .setName("search-history") .setDescription("Search indexed channel history.") @@ -232,6 +237,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise situationalSocialRead.pronounEvidence, )) ?? interactionMemory; } + const sourceGrounding = inferSourceGroundingHint( options.prompt, await options.sourceArchiveRepository.listRepoSummaries(), @@ -502,6 +508,64 @@ export async function handleSummarizeChannel( ); } +export async function handleProfile(options: { + interaction: ChatInputCommandInteraction; + actor: Actor; + interactionMemory: InteractionMemoryBank; + auditLog: AuditLog; + systemMessages: SystemMessageCatalog; +}): Promise { + const profile = await options.interactionMemory.getProfile(options.actor.id); + const disclosure = renderInteractionProfileDisclosure( + options.actor.displayName, + profile, + ); + + if (!options.interaction.inGuild()) { + await replyEphemeral(options.interaction, disclosure); + return; + } + + try { + await sendDirectMessage(options.interaction.user, disclosure); + await options.auditLog.record({ + type: "profile.dm_sent", + actorId: options.actor.id, + details: { + command: "profile", + channelId: options.interaction.channelId, + channelName: + options.interaction.channel && "name" in options.interaction.channel + ? options.interaction.channel.name ?? null + : null, + }, + }); + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "profile.dm_sent"), + ); + } catch (error) { + await options.auditLog.record({ + type: "profile.dm_failed", + actorId: options.actor.id, + details: { + command: "profile", + channelId: options.interaction.channelId, + channelName: + options.interaction.channel && "name" in options.interaction.channel + ? options.interaction.channel.name ?? null + : null, + errorMessage: + error instanceof Error ? error.message : "Unexpected DM delivery failure.", + }, + }); + await replyEphemeral( + options.interaction, + renderSystemMessage(options.systemMessages, "profile.dm_failed"), + ); + } +} + export async function handleReindexChannel( interaction: ChatInputCommandInteraction, actor: Actor, diff --git a/apps/bot/src/discord-bot-support.ts b/apps/bot/src/discord-bot-support.ts index 5263570..e49e587 100644 --- a/apps/bot/src/discord-bot-support.ts +++ b/apps/bot/src/discord-bot-support.ts @@ -26,8 +26,10 @@ import { type ChannelIndexingPolicy, type ChannelIndexingTarget, type GuildContext, + type InteractionMemoryProfile, type SourceGroundingHint, type SourceMessage, + type SituationalSocialRead, shouldIndexChannel, } from "@voidbot/shared"; @@ -408,6 +410,120 @@ export async function notifyOwnerOfBotIssue( } } +export async function sendDirectMessage( + target: { send: (content: string) => Promise }, + content: string, +): Promise { + for (const chunk of splitDiscordContent(content)) { + await target.send(chunk); + } +} + +export function renderInteractionProfileDisclosure( + actorName: string, + profile: InteractionMemoryProfile | undefined, + situationalSocialRead?: SituationalSocialRead, +): string { + if (!profile) { + return [ + `Void's current read on ${actorName}:`, + "", + "There is not enough durable interaction memory yet to form a meaningful long-horizon profile.", + situationalSocialRead + ? [ + "", + "Ephemeral room read for this moment:", + `- Summary: ${situationalSocialRead.summary}`, + `- Room tone: ${situationalSocialRead.roomTone}`, + `- Speaker read: ${situationalSocialRead.speakerCurrentRead}`, + `- Social frame: ${situationalSocialRead.socialFrame}`, + `- Response guidance: ${situationalSocialRead.responseGuidance}`, + ].join("\n") + : "", + ] + .filter((section) => section.length > 0) + .join("\n"); + } + + const dimensions = + profile.interactionDimensions.length > 0 + ? profile.interactionDimensions + .map( + (dimension) => + `- ${dimension.label} (${dimension.score}/3): ${dimension.summary}`, + ) + .join("\n") + : "- No strong interaction dimensions yet."; + const traits = + profile.inferredTraits.length > 0 + ? profile.inferredTraits.join(", ") + : "No stable inferred traits yet."; + const recentEvents = + profile.recentEvents.length > 0 + ? profile.recentEvents + .slice() + .reverse() + .slice(0, 8) + .map( + (event) => + `- [${event.timestamp}] ${event.sourceKind === "ambient_mention" ? "ambient" : "direct"} ${event.sentiment} score=${event.score}: ${event.summary} Quote: "${event.excerpt}"`, + ) + .join("\n") + : "- No retained recent incidents."; + const pronounEvidence = + profile.pronounEvidence.length > 0 + ? profile.pronounEvidence + .map( + (entry) => + `- [${entry.timestamp}] ${entry.stance} ${entry.pronounSet} via ${entry.source} (${Math.round(entry.confidence * 100)}%): "${entry.excerpt}"`, + ) + .join("\n") + : "- No stored pronoun evidence yet."; + const situationalSection = situationalSocialRead + ? [ + "", + "Ephemeral room read for this moment:", + `- Summary: ${situationalSocialRead.summary}`, + `- Room tone: ${situationalSocialRead.roomTone}`, + `- Speaker read: ${situationalSocialRead.speakerCurrentRead}`, + `- Social frame: ${situationalSocialRead.socialFrame}`, + `- Response guidance: ${situationalSocialRead.responseGuidance}`, + ].join("\n") + : ""; + + return [ + `Void's current profile on ${actorName}:`, + "", + `Summary: ${profile.summary}`, + `Disposition: ${profile.disposition}`, + `Affinity score: ${profile.affinityScore}`, + `Interaction counts: total=${profile.totalInteractions}, direct=${profile.directInteractionCount}, ambient=${profile.ambientMentionCount}, positive=${profile.positiveCount}, neutral=${profile.neutralCount}, negative=${profile.negativeCount}`, + `Last interaction: ${profile.lastInteractionAt ?? "unknown"}`, + "", + `Psychological read: ${profile.psychologicalProfile}`, + `Inferred traits: ${traits}`, + "", + "Interaction dimensions:", + dimensions, + "", + "Pronoun handling:", + `- Policy: ${profile.pronounPolicy}`, + `- Resolved set: ${profile.resolvedPronounSet ?? "none"}`, + `- Confidence: ${profile.pronounConfidence !== undefined ? `${Math.round(profile.pronounConfidence * 100)}%` : "n/a"}`, + `- Guidance: ${profile.pronounGuidance}`, + "- Evidence:", + pronounEvidence, + "", + `Private response guidance: ${profile.responseGuidance}`, + "", + "Recent remembered incidents:", + recentEvents, + situationalSection, + ] + .filter((section) => section.length > 0) + .join("\n"); +} + export function truncate(input: string, limit: number): string { if (input.length <= limit) { return input; diff --git a/apps/bot/src/discord-bot.ts b/apps/bot/src/discord-bot.ts index ef12b8d..8b5f6b6 100644 --- a/apps/bot/src/discord-bot.ts +++ b/apps/bot/src/discord-bot.ts @@ -37,6 +37,7 @@ import { shouldIndexChannel } from "@voidbot/shared"; import { handleApproveJob, + handleProfile, handlePrompt, handleRejectJob, handleReindexChannel, @@ -516,6 +517,15 @@ export async function startBot(): Promise { ), ); break; + case "profile": + await handleProfile({ + interaction, + actor, + interactionMemory, + auditLog, + systemMessages: activeSystemMessages, + }); + break; case "search-history": await handleSearchHistory(interaction, retrievalService, activeSystemMessages); break; diff --git a/config/system-messages.json b/config/system-messages.json index 7cd9004..21ef2b6 100644 --- a/config/system-messages.json +++ b/config/system-messages.json @@ -139,6 +139,14 @@ "Current provider status:", "Here is the current state of the configured providers:" ], + "profile.dm_sent": [ + "I sent your current profile read by DM.", + "Your profile read is in your DMs." + ], + "profile.dm_failed": [ + "I tried to DM your profile, but Discord would not let me through.", + "I could not deliver the profile DM. Your privacy settings or DM permissions may be in the way." + ], "owner_codex.handoff_notice": [ "This request needs the fuller Codex flow. Check `{handoffPath}` and `{debugPath}` in the local workspace.", "This request is leaving Discord. Continue from `{handoffPath}` and use `{debugPath}` for the trace." diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9d0d48c..9db87df 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export const commandNames = [ "approve-job", "reject-job", "provider-status", + "profile", "reindex-channel", "set-style", ] as const; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 56181ab..b3c652f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -26,3 +26,4 @@ {"ts":"2026-05-02T20:25:00+01:00","type":"architecture","status":"ok","note":"Ephemeral room-reading now comes from a separate `think=false` local Ollama sidecar pass over recent room context instead of hand-built phrase heuristics, and the inferred reads are stored as audit events so they can later be aggregated into richer profiles without immediately hardening every passing vibe into durable identity truth."} {"ts":"2026-05-02T21:05:00+01:00","type":"bugfix","status":"ok","note":"Archived-history answers should not make the model infer time sense from bare ISO strings alone; history tool results now carry human-readable time context, and both reply lanes are explicitly told to narrate old events in the correct tense and avoid guessing pronouns when the supplied context does not state them."} {"ts":"2026-05-02T21:13:34+01:00","type":"architecture","status":"ok","note":"Pronoun handling now runs on structured evidence rather than name-guessing: the room-read sidecar emits current-speaker pronoun cues with provenance and confidence, interaction memory persists and merges them as identity state, and explicit self-statements or corrections override softer contextual inferences before the reply lanes consume private pronoun guidance."} +{"ts":"2026-05-02T21:23:00+01:00","type":"implementation","status":"ok","note":"Profile disclosure is now a dedicated `/profile` slash command instead of fuzzy chat intent detection: it DMs the caller a fuller internal profile read, keeps the public channel to a short confirmation or failure notice, and avoids turning privacy-sensitive introspection into another free-form language parsing stunt."}