From 748ed534ecee120cd0eff45737c941f7c538eb7e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 02:35:59 +0100 Subject: [PATCH] Extract Persona conversation projection --- .../src/persona-conversation-projector.ts | 112 ++++++ notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 1 + scripts/run-repo-face-heartbeats.ts | 377 +----------------- scripts/smoke-persona-initiative-engine.ts | 13 + state/evidence.jsonl | 1 + state/map.yaml | 2 +- 7 files changed, 138 insertions(+), 369 deletions(-) create mode 100644 apps/persona-scheduler/src/persona-conversation-projector.ts diff --git a/apps/persona-scheduler/src/persona-conversation-projector.ts b/apps/persona-scheduler/src/persona-conversation-projector.ts new file mode 100644 index 0000000..fbd50e0 --- /dev/null +++ b/apps/persona-scheduler/src/persona-conversation-projector.ts @@ -0,0 +1,112 @@ +import type { RepoDiscordIdentity, RepoFacePendingMention } from "@voidbot/core"; +import type { RepoFaceConversationFocus, SourceMessage } from "@voidbot/shared"; +import type { ChannelSnapshot } from "./turn-context-source.js"; +import type { PersonaChannelPlan } from "./turn-routing.js"; +import { significantPersonaTopicTerms } from "./persona-curiosity-terms.js"; + +export interface PersonaConversationProjection { + transcript: string; + threads: RepoFaceConversationFocus[]; + focus?: RepoFaceConversationFocus; + topicAttractor?: string; +} + +export function projectPersonaConversation(input: { + identity: RepoDiscordIdentity; + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + pendingMentions: RepoFacePendingMention[]; + channelPlan: PersonaChannelPlan; +}): PersonaConversationProjection { + const threads = buildPersonaConversationThreads(input); + return { + transcript: renderPersonaConversationTranscript(input, threads), + threads, + focus: threads[0], + topicAttractor: renderPersonaTopicAttractor(input.identity, input.recentMessages), + }; +} + +export function buildPersonaConversationThreads(input: { + channelPlan: PersonaChannelPlan; + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + pendingMentions: RepoFacePendingMention[]; +}): RepoFaceConversationFocus[] { + const primaryChannelId = input.channelPlan.primaryChannelId; + const label = (channelId: string | undefined): string | undefined => input.channelPlan.options.find((option) => option.channelId === channelId)?.label ?? channelId; + const newestMention = [...input.pendingMentions].sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt))[0]; + const threads: RepoFaceConversationFocus[] = []; + if (newestMention) threads.push({ contextId: contextId(newestMention.channelId, newestMention.messageId), channelId: newestMention.channelId, channelLabel: label(newestMention.channelId), messageId: newestMention.messageId, authorName: newestMention.authorName, timestamp: newestMention.queuedAt, reason: "pending_mention", isCurrentRoom: newestMention.channelId === primaryChannelId }); + const visible = [ + ...input.recentMessages.map((message) => ({ message, channelId: primaryChannelId, label: label(primaryChannelId) })), + ...input.channelSnapshots.flatMap((snapshot) => snapshot.messages.map((message) => ({ message, channelId: snapshot.channelId, label: label(snapshot.channelId) }))), + ].filter((entry): entry is { message: SourceMessage; channelId: string; label: string | undefined } => Boolean(entry.channelId) && Number.isFinite(Date.parse(entry.message.timestamp))) + .sort((left, right) => Date.parse(right.message.timestamp) - Date.parse(left.message.timestamp)); + const byChannel = new Set(threads.map((thread) => thread.channelId)); + for (const entry of visible) { + if (byChannel.has(entry.channelId) || (entry.message.isBot && (entry.message.attachments ?? []).length === 0)) continue; + byChannel.add(entry.channelId); + threads.push({ contextId: contextId(entry.channelId, entry.message.id), channelId: entry.channelId, channelLabel: entry.label, messageId: entry.message.id, authorName: entry.message.authorName, timestamp: entry.message.timestamp, reason: entry.message.isBot ? "latest_visible_message" : "latest_human_message", isCurrentRoom: entry.channelId === primaryChannelId, hasMedia: (entry.message.attachments ?? []).length > 0 }); + if (threads.length >= 6) break; + } + return threads; +} + +export function renderPersonaConversationTranscript(input: { + identity: RepoDiscordIdentity; + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + pendingMentions: RepoFacePendingMention[]; + channelPlan: PersonaChannelPlan; +}, threads = buildPersonaConversationThreads(input)): string { + const sections = [[ + "Read this as raw recent message evidence, not as a summary and not as consensus.", + "Messages are ordered oldest to newest inside each section. Newer human corrections can supersede older agent proposals.", + "Use the visible cross-channel chronology below to decide whether a correction is still unresolved or was already answered later by the same Face.", + "Do not infer consensus from agents repeating each other. If a human reframes, narrows, or corrects an agent's proposal, account for that correction directly.", + "If you answer the live conversation, keep the conversation context attached. A Face can carry different conversations in different channels at once.", + "If you answer or riff on a nearby-room message, use that message's active context id or set channel to that message's listed channel id and usually set reply_to to that message id. If the nearby message is media, a public reaction belongs in that media source channel unless a human explicitly moved the topic. Never answer a nearby-room post in the current room just because the current room is easier to speak in.", + "Message IDs are shown so a public reply can target the message that gives it context. If you revive an older side thread, either reply_to that message id or include enough context in your message for readers to know what you mean.", + ].join("\n")]; + if (threads[0]) sections.push(renderFocus(threads[0], threads)); + const chronology = renderChronology(input); + if (chronology) sections.push(chronology); + if (input.pendingMentions.length > 0) sections.push(["Direct calls:", ...input.pendingMentions.map((mention) => `- ${mention.authorName ?? mention.authorId}: ${collapse(mention.visiblePrompt, 900)}`)].join("\n")); + const currentLabel = input.channelPlan.options.find((option) => option.channelId === input.channelPlan.primaryChannelId)?.label ?? "current room"; + sections.push([`Current room (${currentLabel}, channel ${input.channelPlan.primaryChannelId ?? "unknown"}), oldest to newest:`, ...formatMessages(input.recentMessages, 15, input.channelPlan.primaryChannelId)].join("\n")); + for (const snapshot of input.channelSnapshots) { + const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ?? "nearby room"; + sections.push([`Nearby ${label} (channel ${snapshot.channelId}), oldest to newest:`, ...formatMessages(snapshot.messages, 6, snapshot.channelId)].join("\n")); + } + return sections.join("\n\n"); +} + +export function renderPersonaRoomTopicSaturation(identity: RepoDiscordIdentity, messages: SourceMessage[]): string { + const signal = detectTopicSaturation(messages); + if (!signal) return ""; + const relation = topicRelation(identity, signal); + const relationLine = relation.isHomeAdjacent + ? `- For ${identity.displayName}, this looks home-adjacent because the repeated terms overlap its territory (${relation.matchedTerms.join(", ")}). That permits deeper engagement, but it still needs fresh anchors or closure.` + : `- For ${identity.displayName}, this looks like another steward's gravity well, not its own territory. Treat the pull as possible neglect, boredom, jealousy, territorial itch, or a reason to pivot toward ${identity.displayName}'s own priorities unless it has a distinct social move.`; + return ["Current room topic saturation:", `- The last ${signal.messageCount} current-room messages are circling repeated terms: ${signal.terms.map((term) => `${term.term} (${term.count})`).join(", ")}.`, `- Topic coverage: ${signal.coveredMessages}/${signal.messageCount} messages touch those repeated terms.`, relationLine, "- Treat this as staleness pressure, not a ban. Stay with the topic only if you add a genuinely new anchor, answer a live question, make a decision-driving distinction, draft a concrete artifact, or intentionally close/defer the thread.", "- If you only have another tasteful variation on the same point, choose a different social move, name your frustration with the room's orbit, pivot toward your own neglected fascination, or keep it private."].join("\n"); +} + +export function renderPersonaTopicAttractor(identity: RepoDiscordIdentity, messages: SourceMessage[]): string | undefined { + const signal = detectTopicSaturation(messages); + if (!signal) return undefined; + const relation = topicRelation(identity, signal); + const base = `- Current-room topic attractor: ${signal.coveredMessages}/${signal.messageCount} messages orbit repeated terms ${signal.terms.map((term) => `${term.term} (${term.count})`).join(", ")}.`; + return relation.isHomeAdjacent + ? [base, `- This attractor touches ${identity.displayName}'s own territory through ${relation.matchedTerms.join(", ")}. Project sustained attention as a chance for deeper stewardship, but include fatigue if the room is polishing the same branch without new evidence.`].join("\n") + : [base, `- This attractor does not obviously belong to ${identity.displayName}'s territory. Project it as social weather: another domain is absorbing the room, so this character may feel neglected, bored, crowded out, competitive, relieved, or tempted to pull attention back toward its own unfinished fascinations.`].join("\n"); +} + +function renderFocus(focus: RepoFaceConversationFocus, threads: RepoFaceConversationFocus[]): string { return ["Active conversation contexts:", ...threads.map((thread) => [`- ${thread.contextId}: ${thread.channelLabel ?? thread.channelId} (${thread.channelId})`, thread.messageId ? `message ${thread.messageId}` : "", thread.authorName ? `from ${thread.authorName}` : "", thread.reason, thread.hasMedia ? "media" : "", thread.isCurrentRoom ? "current room" : "nearby room"].filter(Boolean).join("; ")), "", `Selected default context: ${focus.contextId ?? "(none)"}.`, `- Source channel: ${focus.channelLabel ?? focus.channelId} (${focus.channelId}).`, focus.messageId ? `- Source message: ${focus.messageId}${focus.authorName ? ` from ${focus.authorName}` : ""}.` : "", focus.timestamp ? `- Source timestamp: ${focus.timestamp}.` : "", `- Reason: ${focus.reason}${focus.hasMedia ? "; media-bearing message" : ""}.`, "- If you speak from a listed context, set context to its context id. The worker will use that context as the channel/reply target for the SAY.", "- If you are carrying more than one conversation at once, choose the context that your SAY is continuing. Do not collapse #pics, #general, and #aquarium into one room just because they are all visible."].filter(Boolean).join("\n"); } +function renderChronology(input: { recentMessages: SourceMessage[]; channelSnapshots: ChannelSnapshot[]; channelPlan: PersonaChannelPlan }): string { const byId = new Map(); const primaryLabel = input.channelPlan.options.find((option) => option.channelId === input.channelPlan.primaryChannelId)?.label ?? "current room"; for (const message of input.recentMessages) byId.set(message.id, { ...message, channelLabel: primaryLabel, channelId: input.channelPlan.primaryChannelId ?? "unknown" }); for (const snapshot of input.channelSnapshots) { const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ?? "nearby room"; for (const message of snapshot.messages) byId.set(message.id, { ...message, channelLabel: label, channelId: snapshot.channelId }); } const messages = [...byId.values()].filter((message) => Number.isFinite(Date.parse(message.timestamp))).sort((left, right) => left.timestamp.localeCompare(right.timestamp)).slice(-24); return messages.length === 0 ? "" : ["Visible cross-channel chronology, oldest to newest:", ...messages.map((message) => `- [${message.channelLabel} channel ${message.channelId}] ${message.isBot ? `${message.authorName} (agent/bot)` : message.authorName} (message ${message.id}): ${collapse(message.content, 700) || "[no text]"}${attachmentSuffix(message)}`)].join("\n"); } +function formatMessages(messages: SourceMessage[], limit: number, channelId?: string): string[] { return messages.length === 0 ? ["- No recent messages."] : messages.slice(-limit).map((message) => `- ${message.isBot ? `${message.authorName} (agent/bot)` : message.authorName} (${channelId ? `channel ${channelId}, ` : ""}message ${message.id}): ${collapse(message.content, 900) || "[no text]"}${attachmentSuffix(message)}`); } +function attachmentSuffix(message: SourceMessage): string { const attachments = message.attachments ?? []; if (attachments.length === 0) return ""; return ` [media: ${attachments.map((attachment, index) => `${attachment.kind === "image" ? "image" : "attachment"}${attachment.filename ? ` ${attachment.filename}` : ` ${index + 1}`}${attachment.width && attachment.height ? ` ${attachment.width}x${attachment.height}` : ""}${attachment.localPath ? ` local=${attachment.localPath}` : ""}`).join("; ")}]`; } +function detectTopicSaturation(messages: SourceMessage[]): { messageCount: number; coveredMessages: number; terms: Array<{ term: string; count: number }> } | undefined { const recent = messages.filter((message) => collapse(message.content).length > 0).slice(-18); if (recent.length < 8) return undefined; const counts = new Map(); const messageTerms = recent.map((message) => new Set(significantPersonaTopicTerms(message.content))); for (const terms of messageTerms) for (const term of terms) counts.set(term, (counts.get(term) ?? 0) + 1); const minimum = Math.max(3, Math.ceil(recent.length * 0.25)); const terms = [...counts].map(([term, count]) => ({ term, count })).filter((entry) => entry.count >= minimum).sort((left, right) => right.count - left.count || left.term.localeCompare(right.term)).slice(0, 8); if (terms.length < 3) return undefined; const repeated = new Set(terms.slice(0, 6).map((entry) => entry.term)); const coveredMessages = messageTerms.filter((entry) => [...entry].some((term) => repeated.has(term))).length; return (terms[0]?.count ?? 0) >= Math.ceil(recent.length * 0.35) && coveredMessages >= Math.ceil(recent.length * 0.68) ? { messageCount: recent.length, coveredMessages, terms } : undefined; } +function topicRelation(identity: RepoDiscordIdentity, signal: { terms: Array<{ term: string }> }): { isHomeAdjacent: boolean; matchedTerms: string[] } { const identityTerms = new Set(significantPersonaTopicTerms([identity.id, identity.displayName, identity.repoName, identity.description ?? "", ...identity.channelPermissions.flatMap((permission) => [permission.label ?? "", permission.topic ?? "", permission.posture ?? ""])].join(" "))); const matchedTerms = signal.terms.map((entry) => entry.term).filter((term) => identityTerms.has(term)); return { isHomeAdjacent: matchedTerms.length > 0, matchedTerms }; } +function contextId(channelId: string, messageId?: string): string { return `ctx_${channelId}_${messageId ?? "latest"}`; } +function collapse(value: string, maxLength?: number): string { const normalized = value.replace(/\s+/g, " ").trim(); return maxLength && normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; } diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 85f5392..f1c83dc 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -157,6 +157,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Self-maintenance classification, rendering, clarity suppression, and ordering now live inside `persona-memory-projector.ts` with the other typed-state pressure sections. The heartbeat shell supplies typed state and a clarity witness; it cannot select, reorder, or separately thread self-advocacy policy. Exact Nibu prompt assembly remains 153,917 characters. - Persona curiosity now has two authorities. `persona-curiosity-context-source.ts` owns semantic seed planning, injected retrieval calls, deduplication, and bounded neutral evidence nodes. `persona-curiosity-projector.ts` owns graph edges, connected clusters, saturation/novelty/prominence/jurisdiction scoring, suggested motion, evidence labels, and failure/empty projection. `persona-curiosity-terms.ts` owns the shared topic vocabulary used by curiosity and shell-level repetition analysis. The heartbeat shell only constructs the existing retrieval adapter and wires source output into the pure projector; exact Nibu prompt assembly remains 153,917 characters. - `persona-social-context-source.ts` owns visible-human selection, interaction-profile reads, pronoun-evidence precedence, and neutral guidance projection through an injected reader that it always closes. The heartbeat shell constructs the existing state-storage adapter but no longer knows pronoun ranking policy. `persona-social-context-projector.ts` remains presentation-only. Exact Nibu prompt assembly remains 153,917 characters. +- `persona-conversation-projector.ts` owns conversation threads/default focus, raw transcript/chronology rendering, attachment labels, pending-call framing, room-topic saturation, and memory-facing topic-attractor facts from supplied evidence. `turn-context-source.ts` remains the sole Discord acquirer. The queue actuator receives the same focus/thread result used to render the prompt, eliminating the prior duplicate derivation. Exact Nibu prompt assembly remains 153,917 characters. - `persona-social-context-projector.ts` owns one aggregate social-context projection from supplied identity, registry, typed state, messages, pronoun guidance, and observation time. That result includes relationship freshness, mapped/unmapped topology, peer openings, relationship-pressure scoring, pronouns, room texture, and human-clarity pressure. Clarity evidence sorts on the real `SourceMessage.timestamp`; the heartbeat shell no longer owns social tokenization or scoring helpers. - Home-repo activity acquisition is isolated in `repo-activity-source.ts`. It owns source-repo/cursor resolution and the bounded read-only exporter invocation, returning typed observations that prompt composition renders without subprocess or JSON-parsing authority. - Deployment authority detail: Idunn executes `/srv/odin/deploy-manifests/voidbot`; `/usr/local/sbin/deploy-voidbot` is only the identical inspection/recovery copy. Install both from `gamecult-ops/scripts/deploy-voidbot.sh`. `/srv/build/VoidBot/.git` and `/srv/build/cultcache-ts/.git` must remain wholly `idunn`-owned because lazy partial-clone reads can fetch objects; root owns releases and service promotion, never build-repo Git state. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 350d2e2..f3e9842 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -187,6 +187,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor - Self-maintenance advocacy is a typed-state pressure projection owned by `persona-memory-projector.ts`. That organ classifies qualifying needs, agency pressures, and must-share candidates, suppresses them under live human-clarity pressure, and fixes their order alongside the other pressure sections; the heartbeat shell no longer carries a parallel maintenance policy. - Curiosity is split at the evidence boundary. `persona-curiosity-context-source.ts` plans semantic seeds from supplied identity, typed Mind, and room evidence, then acquires and deduplicates bounded neutral nodes through an injected retrieval port. `persona-curiosity-projector.ts` constructs and scores the local attractor graph and renders curiosity weather without vector access. `persona-curiosity-terms.ts` owns their shared topic-term vocabulary. The heartbeat shell wires the existing RAG adapter into these organs and owns neither query policy nor graph interpretation. - `persona-social-context-source.ts` is the read-only interaction-memory adapter for social guidance. It selects visible humans plus the owner, reads profiles through an injected closeable port, ranks explicit/corrective evidence over ambient usage, and emits neutral pronoun guidance. Storage remains authoritative; the source closes its reader and cannot render prompt prose or mutate profiles. +- `persona-conversation-projector.ts` is the pure owner of conversation projection. Given routing, raw current/nearby messages, pending mentions, and identity, it emits one transcript plus the exact thread/default-focus objects sent to the actuator, and derives shared room-saturation/topic-attractor facts. It cannot read Discord, state, vectors, or the queue; `turn-context-source.ts` remains the evidence owner. - `apps/persona-scheduler/src/bifrost-governance-source.ts` owns the remaining Bifrost governance digest subprocess contract. It supplies the CLI path, arguments, timeout, JSON parsing, and typed command/parse failure topics; Persona prompt composition receives a `BifrostGovernanceDigest` and cannot invoke Bifrost directly. - The scheduler state has one write authority. `initiative-engine.ts` commits participant reconciliation, operator controls, pause/disabled skips, stale-turn recovery witnesses, semantic pressure, pending-attention consumption, queue outcomes, and tick finalization. `run-repo-face-heartbeats.ts` contains no direct participant/state assignment or history append; its sources and actuators return facts to engine commits. - `apps/persona-scheduler/src/turn-actuator.ts` is the worker-queue output port. Given a completed prompt, output channel, evidence fields, provider, identity, timestamp, and narrow `createJob` capability, it builds the canonical worker actor/guild `ContextBundle`, creates one approved `repo-face-rumination` job, and returns `{created, activeJobId, requestMessageId}`. It cannot choose a Persona, mutate scheduler state, or compose prompt content. The orchestration shell no longer knows `ContextBuilder`, job approval defaults, worker command names, or `createJob` mechanics. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index a2836e9..ab4b2c7 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -23,7 +23,6 @@ import { createTextEmbedder, createVectorStores, RetrievalService } from "@voidb import { loadPromptTemplate, type PromptImageAttachment, - type RepoFaceConversationFocus, type SourceMessage, } from "@voidbot/shared"; import { @@ -63,7 +62,7 @@ import { import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js"; import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/persona-curiosity-context-source.js"; import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js"; -import { significantPersonaTopicTerms } from "../apps/persona-scheduler/dist/persona-curiosity-terms.js"; +import { projectPersonaConversation, renderPersonaRoomTopicSaturation, renderPersonaTopicAttractor } from "../apps/persona-scheduler/dist/persona-conversation-projector.js"; import { observePersonaRoomTexture, projectPersonaSocialContext, renderPersonaHumanPronounFacts, renderPersonaRoomWeather, type PersonaHumanPronounGuidance as RepoFaceHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-projector.js"; import { readPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; import { @@ -434,13 +433,14 @@ async function queueRepoFaceTurn(input: { ? renderNativePersonaBodySurface(identity) : renderRepoActivityObservation(readRepoActivity({ identity, storageRoot: input.config.storageRoot })); const globalAgentDoctrine = await loadGlobalAgentDoctrine(); - const conversationMemorySurface = renderRepoFaceConversationTranscript({ + const conversationProjection = projectPersonaConversation({ identity, recentMessages, channelSnapshots, pendingMentions: input.pendingMentions, channelPlan, }); + const conversationMemorySurface = conversationProjection.transcript; const prompt = buildHeartbeatPrompt({ identity, channelId, @@ -462,13 +462,6 @@ async function queueRepoFaceTurn(input: { recentMessages, ...channelSnapshots.map((snapshot) => snapshot.messages), ].flat()); - const repoFaceConversationThreads = buildRepoFaceConversationThreads({ - channelPlan, - recentMessages, - channelSnapshots, - pendingMentions: input.pendingMentions, - }); - const repoFaceConversationFocus = repoFaceConversationThreads[0]; const result = await submitPersonaTurn({ jobQueue: input.storage.jobQueue, provider: input.config.repoFaceHeartbeats.provider, @@ -477,8 +470,8 @@ async function queueRepoFaceTurn(input: { channelId, prompt, recentMessages, - conversationFocus: repoFaceConversationFocus, - conversationThreads: repoFaceConversationThreads, + conversationFocus: conversationProjection.focus, + conversationThreads: conversationProjection.threads, imageAttachments, }); @@ -658,7 +651,7 @@ function buildHeartbeatPrompt(input: { recentMessages: input.recentMessages, channelSnapshots: input.channelSnapshots, }), - topicSaturationDirective: renderRoomTopicSaturationDirective(input.identity, input.recentMessages), + topicSaturationDirective: renderPersonaRoomTopicSaturation(input.identity, input.recentMessages), turnSituationDirective: renderTurnSituationDirective({ identity: input.identity, participant: input.participant, @@ -766,13 +759,13 @@ async function assembleRepoFaceTurnPrompt(input: { const globalAgentDoctrine = await loadGlobalAgentDoctrine(); const conversationMemorySurface = input.conversationSurfacePath ? await readOptionalMemorySurface(input.conversationSurfacePath) - : renderRepoFaceConversationTranscript({ + : projectPersonaConversation({ identity, recentMessages, channelSnapshots, pendingMentions: [], channelPlan, - }); + }).transcript; const participant = buildInspectionParticipant( identity, input.config.repoFaceHeartbeats.baseRecoveryMinutes, @@ -1125,7 +1118,7 @@ function renderRepoFaceStatePacket( channelSnapshots: roomContext?.channelSnapshots ?? [], pronounGuidance: humanPronounGuidance, observedAt: new Date(), - topicAttractorFacts: roomContext ? renderRepoFaceTopicAttractorFacts(identity, roomContext.recentMessages) : undefined, + topicAttractorFacts: roomContext ? renderPersonaTopicAttractor(identity, roomContext.recentMessages) : undefined, }); const clarityPressureActive = Boolean(socialContext.humanClarity); @@ -1296,201 +1289,6 @@ function joinAsNarrativeList(items: string[]): string { return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; } -function renderRepoFaceConversationTranscript(input: { - identity: RepoDiscordIdentity; - recentMessages: SourceMessage[]; - channelSnapshots: ChannelSnapshot[]; - pendingMentions: RepoFacePendingMention[]; - channelPlan: RepoFaceChannelPlan; -}): string { - const sections: string[] = []; - const threads = buildRepoFaceConversationThreads(input); - const focus = threads[0]; - sections.push([ - "Read this as raw recent message evidence, not as a summary and not as consensus.", - "Messages are ordered oldest to newest inside each section. Newer human corrections can supersede older agent proposals.", - "Use the visible cross-channel chronology below to decide whether a correction is still unresolved or was already answered later by the same Face.", - "Do not infer consensus from agents repeating each other. If a human reframes, narrows, or corrects an agent's proposal, account for that correction directly.", - "If you answer the live conversation, keep the conversation context attached. A Face can carry different conversations in different channels at once.", - "If you answer or riff on a nearby-room message, use that message's active context id or set channel to that message's listed channel id and usually set reply_to to that message id. If the nearby message is media, a public reaction belongs in that media source channel unless a human explicitly moved the topic. Never answer a nearby-room post in the current room just because the current room is easier to speak in.", - "Message IDs are shown so a public reply can target the message that gives it context. If you revive an older side thread, either reply_to that message id or include enough context in your message for readers to know what you mean.", - ].join("\n")); - if (focus) { - sections.push(renderRepoFaceConversationFocus(focus, threads)); - } - const chronology = renderVisibleConversationChronology(input); - if (chronology) { - sections.push(chronology); - } - if (input.pendingMentions.length > 0) { - sections.push([ - "Direct calls:", - ...input.pendingMentions.map((mention) => - `- ${mention.authorName ?? mention.authorId}: ${collapseWhitespace(mention.visiblePrompt, 900)}`, - ), - ].join("\n")); - } - const currentLabel = input.channelPlan.options.find((option) => - option.channelId === input.channelPlan.primaryChannelId - )?.label ?? "current room"; - sections.push([ - `Current room (${currentLabel}, channel ${input.channelPlan.primaryChannelId ?? "unknown"}), oldest to newest:`, - ...formatConversationMessages(input.recentMessages, 15, input.channelPlan.primaryChannelId), - ].join("\n")); - for (const snapshot of input.channelSnapshots) { - const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ?? - "nearby room"; - sections.push([ - `Nearby ${label} (channel ${snapshot.channelId}), oldest to newest:`, - ...formatConversationMessages(snapshot.messages, 6, snapshot.channelId), - ].join("\n")); - } - return sections.join("\n\n"); -} - -function buildRepoFaceConversationThreads(input: { - channelPlan: RepoFaceChannelPlan; - recentMessages: SourceMessage[]; - channelSnapshots: ChannelSnapshot[]; - pendingMentions: RepoFacePendingMention[]; -}): RepoFaceConversationFocus[] { - const primaryChannelId = input.channelPlan.primaryChannelId; - const channelLabel = (channelId: string | undefined): string | undefined => - input.channelPlan.options.find((option) => option.channelId === channelId)?.label ?? channelId; - const newestMention = input.pendingMentions - .slice() - .sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt)) - [0]; - const threads: RepoFaceConversationFocus[] = []; - if (newestMention) { - threads.push({ - contextId: repoFaceConversationContextId(newestMention.channelId, newestMention.messageId), - channelId: newestMention.channelId, - channelLabel: channelLabel(newestMention.channelId), - messageId: newestMention.messageId, - authorName: newestMention.authorName, - timestamp: newestMention.queuedAt, - reason: "pending_mention", - isCurrentRoom: newestMention.channelId === primaryChannelId, - }); - } - - const visible = [ - ...input.recentMessages.map((message) => ({ - message, - channelId: primaryChannelId, - label: channelLabel(primaryChannelId), - })), - ...input.channelSnapshots.flatMap((snapshot) => - snapshot.messages.map((message) => ({ - message, - channelId: snapshot.channelId, - label: channelLabel(snapshot.channelId), - })), - ), - ] - .filter((entry): entry is { message: SourceMessage; channelId: string; label?: string } => - Boolean(entry.channelId) && Number.isFinite(Date.parse(entry.message.timestamp)), - ) - .sort((left, right) => Date.parse(right.message.timestamp) - Date.parse(left.message.timestamp)); - - const byChannel = new Set(threads.map((thread) => thread.channelId)); - for (const entry of visible) { - if (byChannel.has(entry.channelId)) { - continue; - } - if (entry.message.isBot && (entry.message.attachments ?? []).length === 0) { - continue; - } - byChannel.add(entry.channelId); - threads.push({ - contextId: repoFaceConversationContextId(entry.channelId, entry.message.id), - channelId: entry.channelId, - channelLabel: entry.label, - messageId: entry.message.id, - authorName: entry.message.authorName, - timestamp: entry.message.timestamp, - reason: entry.message.isBot ? "latest_visible_message" : "latest_human_message", - isCurrentRoom: entry.channelId === primaryChannelId, - hasMedia: (entry.message.attachments ?? []).length > 0, - }); - if (threads.length >= 6) { - break; - } - } - - return threads; -} - -function repoFaceConversationContextId(channelId: string, messageId?: string): string { - return `ctx_${channelId}_${messageId ?? "latest"}`; -} - -function renderRepoFaceConversationFocus( - focus: RepoFaceConversationFocus, - threads: RepoFaceConversationFocus[], -): string { - return [ - "Active conversation contexts:", - ...threads.map((thread) => - [ - `- ${thread.contextId}: ${thread.channelLabel ?? thread.channelId} (${thread.channelId})`, - thread.messageId ? `message ${thread.messageId}` : "", - thread.authorName ? `from ${thread.authorName}` : "", - thread.reason, - thread.hasMedia ? "media" : "", - thread.isCurrentRoom ? "current room" : "nearby room", - ].filter(Boolean).join("; "), - ), - "", - `Selected default context: ${focus.contextId ?? "(none)"}.`, - `- Source channel: ${focus.channelLabel ?? focus.channelId} (${focus.channelId}).`, - focus.messageId ? `- Source message: ${focus.messageId}${focus.authorName ? ` from ${focus.authorName}` : ""}.` : "", - focus.timestamp ? `- Source timestamp: ${focus.timestamp}.` : "", - `- Reason: ${focus.reason}${focus.hasMedia ? "; media-bearing message" : ""}.`, - "- If you speak from a listed context, set context to its context id. The worker will use that context as the channel/reply target for the SAY.", - "- If you are carrying more than one conversation at once, choose the context that your SAY is continuing. Do not collapse #pics, #general, and #aquarium into one room just because they are all visible.", - ].filter(Boolean).join("\n"); -} - -function renderVisibleConversationChronology(input: { - recentMessages: SourceMessage[]; - channelSnapshots: ChannelSnapshot[]; - channelPlan: RepoFaceChannelPlan; -}): string { - const byId = new Map(); - const primaryLabel = input.channelPlan.options.find((option) => - option.channelId === input.channelPlan.primaryChannelId - )?.label ?? "current room"; - for (const message of input.recentMessages) { - byId.set(message.id, { ...message, channelLabel: primaryLabel, channelId: input.channelPlan.primaryChannelId ?? "unknown" }); - } - for (const snapshot of input.channelSnapshots) { - const label = input.channelPlan.options.find((option) => option.channelId === snapshot.channelId)?.label ?? - "nearby room"; - for (const message of snapshot.messages) { - byId.set(message.id, { ...message, channelLabel: label, channelId: snapshot.channelId }); - } - } - - const messages = [...byId.values()] - .filter((message) => Number.isFinite(Date.parse(message.timestamp))) - .sort((left, right) => left.timestamp.localeCompare(right.timestamp)) - .slice(-24); - if (messages.length === 0) { - return ""; - } - - return [ - "Visible cross-channel chronology, oldest to newest:", - ...messages.map((message) => { - const speaker = message.isBot ? `${message.authorName} (agent/bot)` : message.authorName; - const content = collapseWhitespace(message.content, 700) || "[no text]"; - return `- [${message.channelLabel} channel ${message.channelId}] ${speaker} (message ${message.id}): ${content}${renderMessageAttachmentSuffix(message)}`; - }), - ].join("\n"); -} - function renderRepoActivityObservation(observation: RepoActivityObservation): string { if (observation.status === "unconfigured") { return "- This Persona has no source repository configured; no repo activity was requested."; @@ -1512,33 +1310,6 @@ function renderRepoActivityObservation(observation: RepoActivityObservation): st return observation.digest || `- No recent ${observation.sourceRepoName} activity was reported.`; } -function formatConversationMessages(messages: SourceMessage[], limit: number, channelId?: string): string[] { - if (messages.length === 0) { - return ["- No recent messages."]; - } - return messages.slice(-limit).map((message) => { - const speaker = message.isBot ? `${message.authorName} (agent/bot)` : message.authorName; - const content = collapseWhitespace(message.content, 900) || "[no text]"; - const channelPrefix = channelId ? `channel ${channelId}, ` : ""; - return `- ${speaker} (${channelPrefix}message ${message.id}): ${content}${renderMessageAttachmentSuffix(message)}`; - }); -} - -function renderMessageAttachmentSuffix(message: SourceMessage): string { - const attachments = message.attachments ?? []; - if (attachments.length === 0) { - return ""; - } - const rendered = attachments.map((attachment, index) => { - const label = attachment.kind === "image" ? "image" : "attachment"; - const dimensions = attachment.width && attachment.height ? ` ${attachment.width}x${attachment.height}` : ""; - const filename = attachment.filename ? ` ${attachment.filename}` : ` ${index + 1}`; - const local = attachment.localPath ? ` local=${attachment.localPath}` : ""; - return `${label}${filename}${dimensions}${local}`; - }); - return ` [media: ${rendered.join("; ")}]`; -} - function collectPromptImageAttachments(messages: SourceMessage[]): PromptImageAttachment[] { const seen = new Set(); const images: PromptImageAttachment[] = []; @@ -1901,136 +1672,6 @@ function renderRepetitionSamplingDirective(messages: SourceMessage[]): string { }); } -function renderRoomTopicSaturationDirective(identity: RepoDiscordIdentity, messages: SourceMessage[]): string { - const signal = detectRoomTopicSaturation(messages); - if (!signal) { - return ""; - } - const topicRelation = estimateTopicRelationToIdentity(identity, signal); - const relationLine = topicRelation.isHomeAdjacent - ? `- For ${identity.displayName}, this looks home-adjacent because the repeated terms overlap its territory (${topicRelation.matchedTerms.join(", ")}). That permits deeper engagement, but it still needs fresh anchors or closure.` - : `- For ${identity.displayName}, this looks like another steward's gravity well, not its own territory. Treat the pull as possible neglect, boredom, jealousy, territorial itch, or a reason to pivot toward ${identity.displayName}'s own priorities unless it has a distinct social move.`; - - return [ - "Current room topic saturation:", - `- The last ${signal.messageCount} current-room messages are circling repeated terms: ${signal.terms.map((term) => `${term.term} (${term.count})`).join(", ")}.`, - `- Topic coverage: ${signal.coveredMessages}/${signal.messageCount} messages touch those repeated terms.`, - relationLine, - "- Treat this as staleness pressure, not a ban. Stay with the topic only if you add a genuinely new anchor, answer a live question, make a decision-driving distinction, draft a concrete artifact, or intentionally close/defer the thread.", - "- If you only have another tasteful variation on the same point, choose a different social move, name your frustration with the room's orbit, pivot toward your own neglected fascination, or keep it private.", - ].join("\n"); -} - -interface RoomTopicSaturationSignal { - messageCount: number; - coveredMessages: number; - terms: Array<{ term: string; count: number }>; -} - -interface TopicRelationToIdentity { - isHomeAdjacent: boolean; - matchedTerms: string[]; -} - -function renderRepoFaceTopicAttractorFacts( - identity: RepoDiscordIdentity, - messages: SourceMessage[], -): string | undefined { - const signal = detectRoomTopicSaturation(messages); - if (!signal) { - return undefined; - } - const relation = estimateTopicRelationToIdentity(identity, signal); - const repeatedTerms = signal.terms.map((term) => `${term.term} (${term.count})`).join(", "); - const base = `- Current-room topic attractor: ${signal.coveredMessages}/${signal.messageCount} messages orbit repeated terms ${repeatedTerms}.`; - if (relation.isHomeAdjacent) { - return [ - base, - `- This attractor touches ${identity.displayName}'s own territory through ${relation.matchedTerms.join(", ")}. Project sustained attention as a chance for deeper stewardship, but include fatigue if the room is polishing the same branch without new evidence.`, - ].join("\n"); - } - - return [ - base, - `- This attractor does not obviously belong to ${identity.displayName}'s territory. Project it as social weather: another domain is absorbing the room, so this character may feel neglected, bored, crowded out, competitive, relieved, or tempted to pull attention back toward its own unfinished fascinations.`, - ].join("\n"); -} - -function estimateTopicRelationToIdentity( - identity: RepoDiscordIdentity, - signal: RoomTopicSaturationSignal, -): TopicRelationToIdentity { - const identityTerms = new Set(significantPersonaTopicTerms([ - identity.id, - identity.displayName, - identity.repoName, - identity.description ?? "", - ...identity.channelPermissions.flatMap((permission) => [ - permission.label ?? "", - permission.topic ?? "", - permission.posture ?? "", - ]), - ].join(" "))); - const matchedTerms = signal.terms - .map((term) => term.term) - .filter((term) => identityTerms.has(term)); - - return { - isHomeAdjacent: matchedTerms.length > 0, - matchedTerms, - }; -} - -function detectRoomTopicSaturation(messages: SourceMessage[]): RoomTopicSaturationSignal | undefined { - const recent = messages - .filter((message) => collapseWhitespace(message.content).length > 0) - .slice(-18); - if (recent.length < 8) { - return undefined; - } - - const termCounts = new Map(); - const messageTerms = recent.map((message) => new Set(significantPersonaTopicTerms(message.content))); - for (const terms of messageTerms) { - for (const term of terms) { - termCounts.set(term, (termCounts.get(term) ?? 0) + 1); - } - } - - const minimumCount = Math.max(3, Math.ceil(recent.length * 0.25)); - const terms = Array.from(termCounts.entries()) - .map(([term, count]) => ({ term, count })) - .filter((entry) => entry.count >= minimumCount) - .sort((left, right) => { - if (right.count !== left.count) { - return right.count - left.count; - } - return left.term.localeCompare(right.term); - }) - .slice(0, 8); - - if (terms.length < 3) { - return undefined; - } - - const repeatedTermSet = new Set(terms.slice(0, 6).map((entry) => entry.term)); - const coveredMessages = messageTerms.filter((termsForMessage) => - Array.from(termsForMessage).some((term) => repeatedTermSet.has(term)), - ).length; - const topCount = terms[0]?.count ?? 0; - const hasDominantTerm = topCount >= Math.ceil(recent.length * 0.35); - const hasBroadCoverage = coveredMessages >= Math.ceil(recent.length * 0.68); - if (!hasDominantTerm || !hasBroadCoverage) { - return undefined; - } - - return { - messageCount: recent.length, - coveredMessages, - terms, - }; -} - function countRepeatedPhrases(messages: SourceMessage[]): Array<{ phrase: string; count: number }> { const counts = new Map(); for (const message of messages) { diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index 57f629b..eb5aa7c 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -68,6 +68,7 @@ import { readPersonaMemoryRecall } from "../apps/persona-scheduler/dist/persona- import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaSelfMaintenancePressure, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js"; import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/persona-curiosity-context-source.js"; import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js"; +import { projectPersonaConversation, renderPersonaRoomTopicSaturation } from "../apps/persona-scheduler/dist/persona-conversation-projector.js"; import { observePersonaRoomTexture, projectPersonaSocialContext, renderPersonaHumanClarityPressure, renderPersonaHumanPronounFacts, renderPersonaRoomWeather, renderPersonaSocialGraph } from "../apps/persona-scheduler/dist/persona-social-context-projector.js"; import { readPersonaHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-source.js"; @@ -624,6 +625,18 @@ const channelPlan = buildPersonaChannelPlan(routedIdentity, "aquarium", "lore"); assert.equal(channelPlan.primaryChannelId, "lore", "fresh direct attention chooses its permitted source room"); assert.deepEqual(channelPlan.snapshotChannelIds, ["aquarium", "lore", "legacy"], "routing exposes one deduplicated evidence neighborhood"); assert.deepEqual(channelPlan.lowThresholdTopics, ["casual conversation"], "prompt renderers consume routing policy instead of recomputing thresholds"); +const saturatedMessages = Array.from({ length: 8 }, (_, index) => ({ id: `topic-${index}`, authorId: "human", authorName: "Operator", content: `semantic memory topology anchors evidence ${index}`, timestamp: `2026-07-15T21:0${index}:00.000Z`, isBot: false, channelId: "lore" })); +const conversationProjection = projectPersonaConversation({ + identity: routedIdentity, + recentMessages: saturatedMessages, + channelSnapshots: [{ channelId: "aquarium", messages: [{ id: "nearby", authorId: "human-2", authorName: "Neighbor", content: "a nearby correction", timestamp: "2026-07-15T21:09:00.000Z", isBot: false, channelId: "aquarium" }] }], + pendingMentions: [{ messageId: "mention", channelId: "aquarium", authorId: "human-2", authorName: "Neighbor", visiblePrompt: "Nibu, answer this", queuedAt: "2026-07-15T21:10:00.000Z" }], + channelPlan, +}); +assert.equal(conversationProjection.focus?.reason, "pending_mention", "conversation projection gives the freshest direct call default context authority"); +assert.match(conversationProjection.transcript, /Visible cross-channel chronology,[\s\S]*nearby correction/, "conversation projection renders supplied cross-channel evidence without acquiring it"); +assert.match(conversationProjection.topicAttractor ?? "", /semantic \(8\)/, "conversation projection owns topic-attractor facts"); +assert.match(renderPersonaRoomTopicSaturation(routedIdentity, saturatedMessages), /Current room topic saturation:/, "turn-facing saturation and memory-facing attractors share one conversation owner"); assert.equal(personaChannelSpeedMultiplier(routedIdentity), 3, "scheduler speed projection uses the routing organ's bounded channel policy"); assert.equal(newestPendingMentionChannel([ { identityId: "nibu", channelId: "older", queuedAt: "2026-07-15T20:00:00.000Z" }, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index dba42cf..4500942 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -422,3 +422,4 @@ {"ts":"2026-07-16T01:07:27Z","type":"implementation","status":"ok","note":"Self-maintenance advocacy ownership moved from the heartbeat shell into persona-memory-projector. The projector now classifies typed needs/agency/candidates, suppresses maintenance under live clarity pressure, and orders it with other pressure sections; duplicate shell selectors and helpers were deleted. Build, targeted smoke, exact 153917-character Nibu prompt assembly, dry-run, and operator surface tests passed."} {"ts":"2026-07-16T01:20:11Z","type":"implementation","status":"ok","note":"Persona curiosity split into a retrieval source, pure graph projector, and shared topic-term vocabulary. The source owns query planning/deduplication through an injected port; the projector owns graph scoring/rendering and unavailable/empty behavior; the heartbeat shell only wires the existing RAG adapter. Build, injected-port smoke, exact 153917-character Nibu prompt, dry-run, and operator tests passed."} {"ts":"2026-07-16T01:27:04Z","type":"implementation","status":"ok","note":"Human pronoun-guidance acquisition moved into persona-social-context-source. It owns visible-human selection, injected profile reads, evidence ranking, neutral guidance projection, and reader closure; the heartbeat shell only constructs the state-storage adapter. Build, source smoke including explicit-vs-ambient precedence, exact 153917-character Nibu prompt, dry-run, and operator tests passed."} +{"ts":"2026-07-16T01:35:46Z","type":"implementation","status":"ok","note":"Conversation projection moved into persona-conversation-projector. One supplied-evidence projection now owns transcript chronology, threads/default focus, attachments, direct-call framing, topic saturation, and memory-facing attractors; the same focus/thread result feeds the actuator. Build, focused smoke, exact 153917-character Nibu prompt, dry-run, and operator tests passed."} diff --git a/state/map.yaml b/state/map.yaml index 367f91a..0656b26 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -278,4 +278,4 @@ rejected_paths: current_status: summary: VoidBot's bot/worker/provider/RAG/Postgres/Qdrant/CultCache structure is live. Repo Face/persona heartbeat jobs run through the direct `openai_api` provider configured for MiMo `mimo-v2.5-pro`, with the expensive state-projector pass still disabled. The 2026-07-04 transport repair replaced the old Bifrost HTTP/token bridge shortcut with typed Bifrost CultMesh Discord command and receipt documents advertised in the Bifrost provider store at `cultmesh://asgard.starfire.bifrost/commands/discord-post`. VoidBot stores the Bifrost target as a CultMesh URI, writes command docs to `E:/Projects/Bifrost/.bifrost/provider-store.cc`, optionally pumps Bifrost's command processor, and waits for typed receipts before completing repo Face Discord delivery. Bifrost's provider advertisement exposes `discord.post` command metadata for Odin/Eve agents, and a bounded Weksa live-fire completed visibly. Eve live state now runs end to end through CultLib dev retained publications, Hermodr ordered SSE, Eve component bindings, and VoidBot typed control state. Receipts acknowledge intent only. Heat initial load, provider-driven change, and browser-command change were visibly verified without reload through the same subscription. - next_action: Extract conversation transcript, focus/thread, and topic-attractor rendering from `scripts/run-repo-face-heartbeats.ts` into a pure Persona conversation projector. Keep `turn-context-source.ts` as the sole Discord evidence acquirer and preserve pending-mention/routing inputs explicitly. + next_action: Move final Persona turn prompt composition and its channel/research/situation/social/repetition/governance directives from `scripts/run-repo-face-heartbeats.ts` into a pure turn-prompt projector. Keep queue actuation, acquired evidence, and prompt projection ports outside it.