From 89166d445652bdaf878bec452fa6e343bd0b0b32 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 16 Jul 2026 02:27:30 +0100 Subject: [PATCH] Extract Persona social guidance source --- .../src/persona-social-context-source.ts | 60 ++++++++++++ notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 1 + scripts/run-repo-face-heartbeats.ts | 94 ++++--------------- scripts/smoke-persona-initiative-engine.ts | 25 +++++ state/evidence.jsonl | 1 + state/map.yaml | 2 +- 7 files changed, 106 insertions(+), 78 deletions(-) create mode 100644 apps/persona-scheduler/src/persona-social-context-source.ts diff --git a/apps/persona-scheduler/src/persona-social-context-source.ts b/apps/persona-scheduler/src/persona-social-context-source.ts new file mode 100644 index 0000000..c88fa01 --- /dev/null +++ b/apps/persona-scheduler/src/persona-social-context-source.ts @@ -0,0 +1,60 @@ +import type { InteractionMemoryProfile, SourceMessage } from "@voidbot/shared"; +import type { ChannelSnapshot } from "./turn-context-source.js"; +import type { PersonaHumanPronounGuidance } from "./persona-social-context-projector.js"; + +export interface PersonaInteractionProfileReader { + getProfile(actorId: string): Promise; + close(): Promise; +} + +export async function readPersonaHumanPronounGuidance(input: { + ownerActorId: string; + ownerFallbackName: string; + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + openProfiles: () => Promise; +}): Promise { + const visibleHumans = new Map(); + for (const message of [...input.recentMessages, ...input.channelSnapshots.flatMap((snapshot) => snapshot.messages)]) { + if (!message.isBot && message.authorId) visibleHumans.set(message.authorId, message.authorName || message.authorId); + } + visibleHumans.set(input.ownerActorId, visibleHumans.get(input.ownerActorId) ?? input.ownerFallbackName); + const profiles = await input.openProfiles(); + try { + const observations = await Promise.all([...visibleHumans].map(async ([actorId, fallbackName]) => ({ + actorId, + fallbackName, + profile: await profiles.getProfile(actorId), + }))); + return observations.map(({ actorId, fallbackName, profile }) => profile ? projectPronounGuidance(actorId, fallbackName, profile) : undefined) + .filter((entry): entry is PersonaHumanPronounGuidance => entry !== undefined); + } finally { + await profiles.close(); + } +} + +export function projectPronounGuidance(actorId: string, fallbackName: string, profile: InteractionMemoryProfile): PersonaHumanPronounGuidance | undefined { + if (profile.pronounPolicy === "unknown" || profile.resolvedPronounSets.length === 0) return undefined; + const evidence = [...profile.pronounEvidence] + .filter((entry) => entry.stance === "prefer" || entry.stance === "avoid") + .sort((left, right) => pronounEvidenceRank(profile, right) - pronounEvidenceRank(profile, left))[0]; + return { + actorId, + actorName: profile.actorName || fallbackName, + guidance: profile.pronounGuidance, + resolvedPronounSet: profile.resolvedPronounSet, + policy: profile.pronounPolicy, + confidence: profile.pronounConfidence, + evidenceExcerpt: evidence?.excerpt, + }; +} + +function pronounEvidenceRank(profile: InteractionMemoryProfile, entry: InteractionMemoryProfile["pronounEvidence"][number]): number { + const sourceRank: Record = { explicit_self_statement: 10_000, explicit_correction: 9_000, direct_third_party_statement: 7_000, contextual_relational_inference: 3_000, ambient_usage: 1_000 }; + const resolvedSetBonus = profile.resolvedPronounSets.includes(entry.pronounSet) ? 50_000 : 0; + const stanceBonus = entry.stance === "prefer" ? 1_000 : 0; + const confidenceBonus = Math.round(entry.confidence * 100); + const timestampMs = Date.parse(entry.timestamp); + const recencyBonus = Number.isFinite(timestampMs) ? timestampMs / 10_000_000_000 : 0; + return resolvedSetBonus + (sourceRank[entry.source] ?? 0) + stanceBonus + confidenceBonus + recencyBonus; +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b989b0e..85f5392 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -156,6 +156,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - `persona-memory-projector.ts` owns the full typed-state packet contract, including clarity-conditioned agency pressure, incubation, deferred speech, recent receipts, self-maintenance advocacy, final ordering of named external fact sections, final vocabulary transformation, and leak validation. Prompt assembly parity is covered by `--assemble-prompt nibu` producing the same 153,917-character prompt across these ownership cuts. - 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-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 1e2a26e..350d2e2 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -186,6 +186,7 @@ VoidBot runs on Yggdrasil. Idunn owns deployment and consumes upstream repositor - Human-clarity chronology, social topology, peer openings, and relationship-pressure scoring now share the same social-context owner. The shell consumes one named projection result and contains no duplicate social scorer. - 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. - `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 f19a6d8..a2836e9 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -22,7 +22,6 @@ import { import { createTextEmbedder, createVectorStores, RetrievalService } from "@voidbot/rag"; import { loadPromptTemplate, - type InteractionMemoryProfile, type PromptImageAttachment, type RepoFaceConversationFocus, type SourceMessage, @@ -66,6 +65,7 @@ import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/per import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-projector.js"; import { significantPersonaTopicTerms } from "../apps/persona-scheduler/dist/persona-curiosity-terms.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 { readDiscordActivitySnapshot, type IdleCoolingSnapshot, @@ -1248,83 +1248,23 @@ async function loadRepoFaceHumanPronounGuidance( channelSnapshots: ChannelSnapshot[]; }, ): Promise { - const visibleHumans = new Map(); - for (const message of [ - ...(roomContext?.recentMessages ?? []), - ...(roomContext?.channelSnapshots.flatMap((snapshot) => snapshot.messages) ?? []), - ]) { - if (!message.isBot && message.authorId) { - visibleHumans.set(message.authorId, message.authorName || message.authorId); - } - } - visibleHumans.set(config.ownerDiscordId, visibleHumans.get(config.ownerDiscordId) ?? "Metacrat"); - - const storage = await createStateStorage({ - backend: config.stateStorageBackend, - databaseDsn: config.databaseDsn, - jobsFile: config.jobsFile, - auditLogFile: config.auditLogFile, - interactionMemoryFile: config.interactionMemoryFile, - rateLimitStateFile: config.rateLimitStateFile, + return readPersonaHumanPronounGuidance({ + ownerActorId: config.ownerDiscordId, + ownerFallbackName: "Metacrat", + recentMessages: roomContext?.recentMessages ?? [], + channelSnapshots: roomContext?.channelSnapshots ?? [], + openProfiles: async () => { + const storage = await createStateStorage({ + backend: config.stateStorageBackend, + databaseDsn: config.databaseDsn, + jobsFile: config.jobsFile, + auditLogFile: config.auditLogFile, + interactionMemoryFile: config.interactionMemoryFile, + rateLimitStateFile: config.rateLimitStateFile, + }); + return { getProfile: (actorId) => storage.interactionMemory.getProfile(actorId), close: () => storage.close() }; + }, }); - - try { - const profiles = await Promise.all( - [...visibleHumans.entries()].map(async ([actorId, fallbackName]) => ({ - actorId, - fallbackName, - profile: await storage.interactionMemory.getProfile(actorId), - })), - ); - - return profiles - .map(({ actorId, fallbackName, profile }) => - profile ? repoFacePronounGuidanceFromProfile(actorId, fallbackName, profile) : undefined, - ) - .filter((entry): entry is RepoFaceHumanPronounGuidance => entry !== undefined); - } finally { - await storage.close(); - } -} - -function repoFacePronounGuidanceFromProfile( - actorId: string, - fallbackName: string, - profile: InteractionMemoryProfile, -): RepoFaceHumanPronounGuidance | undefined { - if (profile.pronounPolicy === "unknown" || profile.resolvedPronounSets.length === 0) { - return undefined; - } - - const evidence = [...profile.pronounEvidence] - .filter((entry) => entry.stance === "prefer" || entry.stance === "avoid") - .sort((left, right) => pronounEvidenceRank(profile, right) - pronounEvidenceRank(profile, left))[0]; - - return { - actorId, - actorName: profile.actorName || fallbackName, - guidance: profile.pronounGuidance, - resolvedPronounSet: profile.resolvedPronounSet, - policy: profile.pronounPolicy, - confidence: profile.pronounConfidence, - evidenceExcerpt: evidence?.excerpt, - }; -} - -function pronounEvidenceRank(profile: InteractionMemoryProfile, entry: InteractionMemoryProfile["pronounEvidence"][number]): number { - const sourceRank: Record = { - explicit_self_statement: 10_000, - explicit_correction: 9_000, - direct_third_party_statement: 7_000, - contextual_relational_inference: 3_000, - ambient_usage: 1_000, - }; - const resolvedSetBonus = profile.resolvedPronounSets.includes(entry.pronounSet) ? 50_000 : 0; - const stanceBonus = entry.stance === "prefer" ? 1_000 : 0; - const confidenceBonus = Math.round(entry.confidence * 100); - const timestampMs = Date.parse(entry.timestamp); - const recencyBonus = Number.isFinite(timestampMs) ? timestampMs / 10_000_000_000 : 0; - return resolvedSetBonus + (sourceRank[entry.source] ?? 0) + stanceBonus + confidenceBonus + recencyBonus; } function normalizeSocialLabel(value: string | undefined): string { diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index c39d766..57f629b 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -69,6 +69,7 @@ import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaP import { readPersonaCuriosityEvidence } from "../apps/persona-scheduler/dist/persona-curiosity-context-source.js"; import { projectPersonaCuriosityContext } from "../apps/persona-scheduler/dist/persona-curiosity-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"; function participant(identityId: string, nextTurnAt: number): InitiativeParticipant { return { @@ -456,6 +457,30 @@ const roomObservation = observePersonaRoomTexture({ assert.equal(roomObservation?.texture, "light", "social-context projection derives room weather from supplied messages without acquiring Discord state"); assert.match(renderPersonaRoomWeather({ identity: routedIdentity, recentMessages: [], channelSnapshots: [] }), /No current room weather/, "absent room evidence stays explicit"); assert.match(renderPersonaHumanPronounFacts([{ actorId: "human", actorName: "Operator", guidance: "use they/them", policy: "explicit", confidence: 1 }]) ?? "", /Confidence: 1\.00/, "pronoun guidance presentation belongs to the social-context projector"); +let pronounReaderClosed = false; +const pronounGuidance = await readPersonaHumanPronounGuidance({ + ownerActorId: "owner", + ownerFallbackName: "Metacrat", + recentMessages: [{ id: "human-pronouns", authorId: "human", authorName: "Operator", content: "hello", timestamp: "2026-07-15T21:00:00.000Z", isBot: false, channelId: "aquarium" }], + channelSnapshots: [], + openProfiles: async () => ({ + getProfile: async (actorId) => actorId === "human" ? { + actorName: "Operator", + pronounPolicy: "explicit", + resolvedPronounSets: ["they/them"], + resolvedPronounSet: "they/them", + pronounGuidance: "use they/them", + pronounConfidence: 0.95, + pronounEvidence: [ + { pronounSet: "they/them", stance: "prefer", source: "explicit_correction", confidence: 0.9, timestamp: "2026-07-14T20:00:00.000Z", excerpt: "please use they/them" }, + { pronounSet: "they/them", stance: "prefer", source: "ambient_usage", confidence: 1, timestamp: "2026-07-15T20:00:00.000Z", excerpt: "someone said they" }, + ], + } as never : undefined, + close: async () => { pronounReaderClosed = true; }, + }), +}); +assert.match(pronounGuidance[0]?.evidenceExcerpt ?? "", /please use they\/them/, "the social-context source owns pronoun evidence precedence rather than favoring merely newer ambient usage"); +assert.equal(pronounReaderClosed, true, "the social-context source closes its injected interaction-profile reader"); const clarityPressure = renderPersonaHumanClarityPressure({ identity: routedIdentity, recentMessages: [ diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 1eb94de..dba42cf 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -421,3 +421,4 @@ {"ts":"2026-07-16T00:57:11Z","type":"implementation","status":"ok","note":"Persona social context now has one aggregate projector owning freshness, topology, peer openings, relationship-pressure scoring, pronouns, room texture, and clarity from supplied facts; the heartbeat shell no longer owns duplicate social scorers. TypeScript build, aggregate smoke, exact Nibu prompt assembly (153917 chars), dry-run, and operator surface tests passed."} {"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."} diff --git a/state/map.yaml b/state/map.yaml index 535249c..367f91a 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: Move human-pronoun guidance acquisition and evidence ranking out of `scripts/run-repo-face-heartbeats.ts` into a read-only Persona social-context source. Keep interaction-memory storage acquisition separate from the existing pure social projector. + 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.