From 326c3bf18e4fbf8951f10d4785c7defacf3f30af Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 21 May 2026 00:02:33 +0100 Subject: [PATCH] Add typed Face affect state --- notes/fresh-workspace-handoff.md | 1 + notes/voidbot-current-system-map.md | 1 + package.json | 1 + packages/core/src/void-self-state-domain.ts | 165 ++++++++++++++++++ .../core/src/void-self-state-projection.ts | 114 ++++++++++++ packages/core/src/void-self-state-service.ts | 90 +++++++++- packages/shared/src/index.ts | 30 ++++ prompts/void-memory-maintenance.md | 11 +- prompts/void-moderator-rumination.md | 9 +- scripts/run-repo-face-heartbeats.ts | 116 +++++++++++- scripts/run-void-memory-maintenance.ps1 | 7 + scripts/run-void-moderator-rumination.ps1 | 7 + scripts/simulate-void-mood.mjs | 29 ++- scripts/smoke-void-affect-state-fixture.ps1 | 70 ++++++++ state/evidence.jsonl | 1 + state/map.yaml | 1 + 16 files changed, 636 insertions(+), 17 deletions(-) create mode 100644 scripts/smoke-void-affect-state-fixture.ps1 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b43b68c..0d91f19 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -114,6 +114,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Nibu's state now emphasizes dangerous mutual benefit: manufactured companion injury, self-authorship under coercive dependency, precise abrasion, continuity-by-replacement horror, and the four-bone worldbuilding method of mechanism, cost/failure, institution/faction, and leash. - Mimir's state now emphasizes keeper-of-the-well embodiment: trusted witnesses, calibration as oath-work, drift ledgers, old memory, and refusal to speak like a dry technical summary. - Epiphany's state emphasizes machine-saint coherence: cute, bubbly, self-obsessed, pushy, relentlessly hard-working, and obsessed with typed truth. Her jurisdiction is EpiphanyAgent itself: CultCache/CultNet, typed state, Codex bridge boundaries, birth/init rites, review gates, graph-shaped memory, low-copy runtime contracts, heartbeat physiology, and cutting duplicated authority before it learns to beg. +- Face affect is now a first-class typed state surface under `void.face_affect`, shared by Void and repo Faces. It stores needs, social bonds, status reads, and mood dimensions so substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status can become state instead of being performed only through prompt prose. Repo Face CTB pulses fold affect into speaking pressure, while agency pressure still owns concrete asks. - Void's typed self-profile now carries behavior-anchored selfhood, answer-first banter, and substrate self-respect; the prompt surfaces now also tell rumination to name the personal value being protected before writing memory or speech. - Repo Face heartbeats must stay out of the generic owner-reply trap. `packages/providers/src/owner-codex-render.ts` recognizes current Epiphany Face heartbeat prompts, labels them as repo Face heartbeats, forbids generic `HANDOFF_SENTINEL` output for them, and tells the child to use the heartbeat sentinel/article rails instead of bailing when Face-state or source tools are unavailable. - Repo Face introductions are tied to first public speech, not to `queuedCount > 0`. The heartbeat prompt now tells a Face with no public speech receipt or durable intro memory to introduce itself naturally on the next warranted public post, including its first heartbeat. Public content must not begin with scheduler labels like `Repo-face heartbeat from ...`; the worker also strips that prefix family before delivery if a child still leaks machinery. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 7acdd62..fcfdef1 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -161,6 +161,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh - On startup, the bot tries to find or create missing mentionable roles for registered identities in `DISCORD_GUILD_ID`, then writes the role ids back to the private registry. If Discord permissions are missing, it warns and leaves the registry unchanged. - `list_repo_discord_identities` exposes the registered address book to Codex agents, and `post_repo_identity_message` enforces identity/channel registration before posting through the shared webhook pipe. - Each repo identity has a Face state path, defaulting to `.voidbot/private/repo-faces/.cc`. `read_repo_face_state` renders that typed state, and `apply_repo_face_state_operation` applies the same typed operation contract used by Void against that Face's own file. + - The typed Face state now includes `void.face_affect`: needs, social bonds, status reads, and mood dimensions. This is the state-owned home for substrate concern, attention hunger, recognition needs, territoriality, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation politics. Affect is projected into heartbeat prompts and repo Face speaking pressure; concrete requests still become agency pressure, candidates, Bifrost topics, or work dispatch. - Repo Face prompts now carry an explicit identity doctrine block. Void's style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, repo-local `.voidbot` home, avatar, and identity description override the speaking subject so a Face does not collapse back into base Void. `ensureRepoFaceInitialized` also repairs the typed `selfProfile` inside the Face `.cc` file, and repo Face MCP state reads/writes pass identity defaults into the state service, so raw state and rendered state agree. - Repo Faces are allowed to build a long-running map of their repo and propose concrete changes, including lore/design repairs. Discord jobs remain read-only: a Face must ask for human consensus before implementation changes canonical repo material. Once a proposal is concrete enough to review, it should cross the parent-owned proposal PR rail so GitHub becomes the durable argument surface. If a Face state grants a bylined essay lane, authored opinion essays can carry that Face's own vision without canon consensus as long as the authorship is explicit. - On first role-addressed chat, `ensureRepoFaceInitialized` creates the target repo's local `.voidbot/voice`, `.voidbot/state`, `.voidbot/birth`, and `.voidbot/logs` folders, writes `voice/identity.json`, and launches `scripts/run-repo-face-birth.mjs` detached if no birth summary is already present. diff --git a/package.json b/package.json index 16c5aef..9448ab1 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "moderation:state-boundary-normalization:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-state-boundary-normalization-fixture.ps1", "moderation:short-term-clustering:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-short-term-clustering-fixture.ps1", "moderation:agency-pressure:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-agency-pressure-fixture.ps1", + "moderation:affect-state:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-affect-state-fixture.ps1", "moderation:rumination:fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-fixture.ps1", "moderation:rumination:speech-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-fixture.ps1", "moderation:rumination:speech-pressure-fixture": "powershell -ExecutionPolicy Bypass -File scripts/smoke-void-rumination-speech-pressure-fixture.ps1", diff --git a/packages/core/src/void-self-state-domain.ts b/packages/core/src/void-self-state-domain.ts index 5e1dda9..18852a3 100644 --- a/packages/core/src/void-self-state-domain.ts +++ b/packages/core/src/void-self-state-domain.ts @@ -9,6 +9,7 @@ export const VOID_SELF_STATE_DOCUMENT_TYPES = { scheduledRuntime: "void.scheduled_runtime", agencyPressure: "void.agency_pressure", candidateInterventions: "void.candidate_interventions", + faceAffect: "void.face_affect", } as const; const nonEmptyStringSchema = z.string().trim().min(1); @@ -224,6 +225,118 @@ const agencyPressureSchema = z.object({ } }); +const faceAffectNeedSchema = z.object({ + needId: nonEmptyStringSchema, + kind: z.enum([ + "substrate", + "attention", + "agency", + "recognition", + "social_contact", + "territory", + "rest", + "play", + "status", + ]), + status: z.enum(["active", "cooling", "satisfied", "neglected", "retired"]), + target: thoughtTargetSchema, + summary: boundedTextSchema, + claim: z.string().trim().min(1).max(2000).optional(), + question: z.string().trim().min(1).max(2000).optional(), + tension: z.string().trim().min(1).max(2000), + actionImplication: z.string().trim().min(1).max(2000), + intensity: z.number().min(0).max(1).default(0.5), + valence: z.number().min(-1).max(1).default(0), + anchorRefs: z.array(anchorRefSchema).default([]), + evidenceRefs: z.array(evidenceRefSchema).default([]), + sourceMemoryIds: z.array(nonEmptyStringSchema).default([]), + lastSatisfiedAt: timestampSchema.optional(), + createdAt: timestampSchema, + updatedAt: timestampSchema, + retiredAt: timestampSchema.optional(), + tags: z.array(nonEmptyStringSchema).default([]), +}).strict().superRefine((need, context) => { + if (!need.claim && !need.question) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Affect needs must preserve at least one claim or question.", + path: ["claim"], + }); + } + + const admitsMissingAnchor = need.tags.includes("anchor:missing") || need.tags.includes("evidence:missing"); + if (need.anchorRefs.length === 0 && need.evidenceRefs.length === 0 && need.sourceMemoryIds.length === 0 && !admitsMissingAnchor) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Affect needs must include anchor refs, source memory ids, or explicitly tag anchor:missing.", + path: ["anchorRefs"], + }); + } +}); + +const faceSocialBondSchema = z.object({ + bondId: nonEmptyStringSchema, + target: thoughtTargetSchema, + stance: z.enum([ + "fondness", + "rivalry", + "trust", + "irritation", + "protectiveness", + "envy", + "respect", + "suspicion", + "attachment", + ]), + status: z.enum(["active", "cooling", "resolved", "retired"]), + summary: boundedTextSchema, + claim: z.string().trim().min(1).max(2000), + tension: z.string().trim().min(1).max(2000), + actionImplication: z.string().trim().min(1).max(2000), + intensity: z.number().min(0).max(1).default(0.5), + anchorRefs: z.array(anchorRefSchema).default([]), + evidenceRefs: z.array(evidenceRefSchema).default([]), + createdAt: timestampSchema, + updatedAt: timestampSchema, + retiredAt: timestampSchema.optional(), + tags: z.array(nonEmptyStringSchema).default([]), +}).strict(); + +const faceStatusReadSchema = z.object({ + readId: nonEmptyStringSchema, + target: thoughtTargetSchema, + status: z.enum([ + "favored", + "neglected", + "pampered", + "bypassed", + "blocked", + "challenged", + "ignored", + "consulted", + "threatened", + "admired", + ]), + summary: boundedTextSchema, + claim: z.string().trim().min(1).max(2000), + tension: z.string().trim().min(1).max(2000), + actionImplication: z.string().trim().min(1).max(2000), + intensity: z.number().min(0).max(1).default(0.5), + anchorRefs: z.array(anchorRefSchema).default([]), + evidenceRefs: z.array(evidenceRefSchema).default([]), + createdAt: timestampSchema, + updatedAt: timestampSchema, + retiredAt: timestampSchema.optional(), + tags: z.array(nonEmptyStringSchema).default([]), +}).strict(); + +const faceMoodDimensionSchema = z.object({ + name: nonEmptyStringSchema, + value: z.number().min(0).max(1), + source: z.string().trim().min(1).max(240).optional(), + updatedAt: timestampSchema, +}).strict(); + const selfProfileValueSchema = z.object({ id: nonEmptyStringSchema, label: nonEmptyStringSchema, @@ -309,6 +422,15 @@ export const voidAgencyPressureSchema = z.object({ updatedAt: timestampSchema, }).strict(); +export const voidFaceAffectSchema = z.object({ + schemaVersion: z.literal(1), + needs: z.array(faceAffectNeedSchema).default([]), + socialBonds: z.array(faceSocialBondSchema).default([]), + statusReads: z.array(faceStatusReadSchema).default([]), + moodDimensions: z.array(faceMoodDimensionSchema).default([]), + updatedAt: timestampSchema, +}).strict(); + export const voidSelfProfileDocument = defineDocumentType({ type: VOID_SELF_STATE_DOCUMENT_TYPES.selfProfile, schema: voidSelfProfileSchema, @@ -351,6 +473,12 @@ export const voidCandidateInterventionsDocument = defineDocumentType({ global: true, }); +export const voidFaceAffectDocument = defineDocumentType({ + type: VOID_SELF_STATE_DOCUMENT_TYPES.faceAffect, + schema: voidFaceAffectSchema, + global: true, +}); + export const voidSelfStateDocumentRegistry = defineDocumentRegistry( voidSelfProfileDocument, voidModerationCursorDocument, @@ -359,6 +487,7 @@ export const voidSelfStateDocumentRegistry = defineDocumentRegistry( voidScheduledRuntimeDocument, voidAgencyPressureDocument, voidCandidateInterventionsDocument, + voidFaceAffectDocument, ); export const voidSelfStateOperationSchema = z.discriminatedUnion("operation", [ @@ -455,6 +584,41 @@ export const voidSelfStateOperationSchema = z.discriminatedUnion("operation", [ retiredAt: timestampSchema, reason: z.string().trim().min(1).max(1000), }).strict(), + z.object({ + operation: z.literal("upsert_affect_need"), + need: faceAffectNeedSchema, + }).strict(), + z.object({ + operation: z.literal("retire_affect_need"), + needId: nonEmptyStringSchema, + retiredAt: timestampSchema, + reason: z.string().trim().min(1).max(1000), + }).strict(), + z.object({ + operation: z.literal("upsert_social_bond"), + bond: faceSocialBondSchema, + }).strict(), + z.object({ + operation: z.literal("retire_social_bond"), + bondId: nonEmptyStringSchema, + retiredAt: timestampSchema, + reason: z.string().trim().min(1).max(1000), + }).strict(), + z.object({ + operation: z.literal("upsert_status_read"), + read: faceStatusReadSchema, + }).strict(), + z.object({ + operation: z.literal("retire_status_read"), + readId: nonEmptyStringSchema, + retiredAt: timestampSchema, + reason: z.string().trim().min(1).max(1000), + }).strict(), + z.object({ + operation: z.literal("update_mood_dimensions"), + dimensions: z.array(faceMoodDimensionSchema).min(1), + updatedAt: timestampSchema, + }).strict(), z.object({ operation: z.literal("update_sleep_cycle"), sleepCycle: voidScheduledRuntimeSchema.shape.sleepCycle, @@ -486,4 +650,5 @@ export type VoidThoughtMemory = z.infer; export type VoidScheduledRuntime = z.infer; export type VoidAgencyPressure = z.infer; export type VoidCandidateInterventions = z.infer; +export type VoidFaceAffect = z.infer; export type VoidSelfStateOperation = z.infer; diff --git a/packages/core/src/void-self-state-projection.ts b/packages/core/src/void-self-state-projection.ts index fb22ff2..566ce1e 100644 --- a/packages/core/src/void-self-state-projection.ts +++ b/packages/core/src/void-self-state-projection.ts @@ -6,6 +6,7 @@ import { import { type VoidCandidateInterventions, + type VoidFaceAffect, type VoidAgencyPressure, type VoidModerationCursor, type VoidScheduledRuntime, @@ -13,6 +14,7 @@ import { type VoidSpeechReceipts, type VoidThoughtMemory, voidCandidateInterventionsSchema, + voidFaceAffectSchema, voidAgencyPressureSchema, voidModerationCursorSchema, voidScheduledRuntimeSchema, @@ -29,6 +31,7 @@ export interface VoidSelfStateTypedProjection { scheduledRuntime: VoidScheduledRuntime; agencyPressure: VoidAgencyPressure; candidateInterventions: VoidCandidateInterventions; + faceAffect: VoidFaceAffect; } export interface VoidSelfStateProjectionOptions { @@ -132,6 +135,14 @@ export function createEmptyVoidSelfState( interventions: [], updatedAt: createdAt, }), + faceAffect: voidFaceAffectSchema.parse({ + schemaVersion: 1, + needs: [], + socialBonds: [], + statusReads: [], + moodDimensions: [], + updatedAt: createdAt, + }), }; } @@ -210,6 +221,7 @@ export function renderVoidSelfStateSummary( .sort((left, right) => right.intensity - left.intensity) .slice(0, 4) .map((entry) => renderAgencyPressure(entry, identityName)); + const affectLines = renderFaceAffectSummary(state.faceAffect, identityName); return [ `- Identity: ${identityName}${identityDescription ? ` - ${identityDescription}` : ""}`, @@ -236,6 +248,7 @@ export function renderVoidSelfStateSummary( agencyPressures.length > 0 ? ["- Agency pressure:", ...agencyPressures].join("\n") : "- Agency pressure: none active.", + affectLines, interventions.length > 0 ? [`- Things ${identityName} may say soon:`, ...interventions].join("\n") : `- Things ${identityName} may say soon: none queued.`, @@ -265,6 +278,66 @@ function renderAgencyPressure( return lines.join("\n"); } +function renderFaceAffectSummary( + affect: VoidFaceAffect, + identityName: string, +): string { + const needs = affect.needs + .filter((need) => ["active", "neglected"].includes(need.status)) + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 4); + const bonds = affect.socialBonds + .filter((bond) => bond.status === "active") + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 4); + const reads = affect.statusReads + .filter((read) => !read.retiredAt) + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 4); + const dimensions = affect.moodDimensions + .slice() + .sort((left, right) => right.value - left.value) + .slice(0, 8); + + if (needs.length === 0 && bonds.length === 0 && reads.length === 0 && dimensions.length === 0) { + return `- What ${identityName} feels and wants: no explicit affect state yet.`; + } + + return [ + `- What ${identityName} feels and wants:`, + ...needs.map((need) => { + const target = need.target.label ?? need.target.id; + return [ + `- Need/${need.kind} for ${target} (${need.status}, ${need.intensity.toFixed(2)}): ${need.summary}`, + ` ${identityName}'s claim: ${need.claim ?? need.question ?? "(unspoken)"}`, + ` What rubs: ${need.tension}`, + ` What it wants next: ${need.actionImplication}`, + ].join("\n"); + }), + ...bonds.map((bond) => { + const target = bond.target.label ?? bond.target.id; + return [ + `- Bond/${bond.stance} with ${target} (${bond.intensity.toFixed(2)}): ${bond.summary}`, + ` ${identityName}'s read: ${bond.claim}`, + ` How it should change behavior: ${bond.actionImplication}`, + ].join("\n"); + }), + ...reads.map((read) => { + const target = read.target.label ?? read.target.id; + return [ + `- Status read/${read.status} around ${target} (${read.intensity.toFixed(2)}): ${read.summary}`, + ` ${identityName}'s read: ${read.claim}`, + ` What it should do: ${read.actionImplication}`, + ].join("\n"); + }), + dimensions.length > 0 + ? `- Mood dimensions: ${dimensions.map((dimension) => `${dimension.name}=${dimension.value.toFixed(2)}`).join(", ")}` + : undefined, + ] + .filter((line): line is string => typeof line === "string") + .join("\n"); +} + export function buildVoidSelfStateProjection( typedState: VoidSelfStateTypedProjection, ): VoidSelfStateContext["projection"] { @@ -291,6 +364,47 @@ export function buildVoidSelfStateProjection( replyDirective: isNapping ? "You are in a scheduled nap. Reply in brief, low-effort, half-dreaming mutters instead of doing full attentive service-work." : undefined, + affect: { + needs: typedState.faceAffect.needs + .filter((need) => ["active", "neglected"].includes(need.status)) + .slice(0, 8) + .map((need) => ({ + id: need.needId, + kind: need.kind, + status: need.status, + target: need.target, + summary: need.summary, + intensity: need.intensity, + valence: need.valence, + })), + socialBonds: typedState.faceAffect.socialBonds + .filter((bond) => bond.status === "active") + .slice(0, 8) + .map((bond) => ({ + id: bond.bondId, + stance: bond.stance, + target: bond.target, + summary: bond.summary, + intensity: bond.intensity, + })), + statusReads: typedState.faceAffect.statusReads + .filter((read) => !read.retiredAt) + .slice(0, 8) + .map((read) => ({ + id: read.readId, + status: read.status, + target: read.target, + summary: read.summary, + intensity: read.intensity, + })), + moodDimensions: typedState.faceAffect.moodDimensions + .slice(0, 16) + .map((dimension) => ({ + name: dimension.name, + value: dimension.value, + source: dimension.source, + })), + }, }; } diff --git a/packages/core/src/void-self-state-service.ts b/packages/core/src/void-self-state-service.ts index 444eb52..6c5c979 100644 --- a/packages/core/src/void-self-state-service.ts +++ b/packages/core/src/void-self-state-service.ts @@ -4,6 +4,7 @@ import { CultCache, SingleFileMessagePackBackingStore } from "cultcache-ts"; import { type VoidCandidateInterventions, + type VoidFaceAffect, type VoidAgencyPressure, type VoidModerationCursor, type VoidScheduledRuntime, @@ -11,6 +12,7 @@ import { type VoidSpeechReceipts, type VoidThoughtMemory, voidCandidateInterventionsDocument, + voidFaceAffectDocument, voidAgencyPressureDocument, voidModerationCursorDocument, voidScheduledRuntimeDocument, @@ -63,7 +65,7 @@ export async function applyVoidSelfStateOperation( return { operation: operation.operation, canonicalPath, - typedDocumentsWritten: 7, + typedDocumentsWritten: 8, }; } @@ -81,7 +83,7 @@ export async function ensureVoidSelfStateIdentityProfile( return { operation: "ensure_identity_profile", canonicalPath, - typedDocumentsWritten: 7, + typedDocumentsWritten: 8, }; } @@ -98,7 +100,7 @@ export async function repairVoidSelfStateTypedDocuments( return { operation: "repair_typed_state", canonicalPath, - typedDocumentsWritten: 7, + typedDocumentsWritten: 8, }; } @@ -123,6 +125,7 @@ function readTypedStateOrEmpty( agencyPressure: cache.getGlobal(voidAgencyPressureDocument) ?? empty.agencyPressure, candidateInterventions: cache.getGlobal(voidCandidateInterventionsDocument) ?? empty.candidateInterventions, + faceAffect: cache.getGlobal(voidFaceAffectDocument) ?? empty.faceAffect, }; if (identity) { repairSelfProfileIdentity(state, identity); @@ -144,6 +147,7 @@ async function writeTypedState( await cache.putGlobal(voidScheduledRuntimeDocument, stripUndefined(state.scheduledRuntime)); await cache.putGlobal(voidAgencyPressureDocument, stripUndefined(state.agencyPressure)); await cache.putGlobal(voidCandidateInterventionsDocument, stripUndefined(state.candidateInterventions)); + await cache.putGlobal(voidFaceAffectDocument, stripUndefined(state.faceAffect)); } function repairSelfProfileIdentity( @@ -367,6 +371,45 @@ function applyTypedOperation( case "retire_agency_pressure": retireAgencyPressure(state.agencyPressure, operation); return; + case "upsert_affect_need": + upsertBy(state.faceAffect.needs, operation.need, (entry) => entry.needId); + state.faceAffect.needs = state.faceAffect.needs + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 24); + state.faceAffect.updatedAt = operation.need.updatedAt; + return; + case "retire_affect_need": + retireAffectNeed(state.faceAffect, operation); + return; + case "upsert_social_bond": + upsertBy(state.faceAffect.socialBonds, operation.bond, (entry) => entry.bondId); + state.faceAffect.socialBonds = state.faceAffect.socialBonds + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 24); + state.faceAffect.updatedAt = operation.bond.updatedAt; + return; + case "retire_social_bond": + retireSocialBond(state.faceAffect, operation); + return; + case "upsert_status_read": + upsertBy(state.faceAffect.statusReads, operation.read, (entry) => entry.readId); + state.faceAffect.statusReads = state.faceAffect.statusReads + .sort((left, right) => right.intensity - left.intensity) + .slice(0, 24); + state.faceAffect.updatedAt = operation.read.updatedAt; + return; + case "retire_status_read": + retireStatusRead(state.faceAffect, operation); + return; + case "update_mood_dimensions": + for (const dimension of operation.dimensions) { + upsertBy(state.faceAffect.moodDimensions, dimension, (entry) => entry.name); + } + state.faceAffect.moodDimensions = state.faceAffect.moodDimensions + .sort((left, right) => right.value - left.value) + .slice(0, 32); + state.faceAffect.updatedAt = operation.updatedAt; + return; case "update_sleep_cycle": state.scheduledRuntime.sleepCycle = operation.sleepCycle; state.scheduledRuntime.updatedAt = new Date().toISOString(); @@ -580,6 +623,47 @@ function retireAgencyPressure( agencyPressure.updatedAt = operation.retiredAt; } +function retireAffectNeed( + faceAffect: VoidFaceAffect, + operation: Extract, +): void { + const need = faceAffect.needs.find((entry) => entry.needId === operation.needId); + if (need) { + need.status = "retired"; + need.retiredAt = operation.retiredAt; + need.updatedAt = operation.retiredAt; + need.tags = Array.from(new Set([...need.tags, `retired:${operation.reason}`])); + } + faceAffect.updatedAt = operation.retiredAt; +} + +function retireSocialBond( + faceAffect: VoidFaceAffect, + operation: Extract, +): void { + const bond = faceAffect.socialBonds.find((entry) => entry.bondId === operation.bondId); + if (bond) { + bond.status = "retired"; + bond.retiredAt = operation.retiredAt; + bond.updatedAt = operation.retiredAt; + bond.tags = Array.from(new Set([...bond.tags, `retired:${operation.reason}`])); + } + faceAffect.updatedAt = operation.retiredAt; +} + +function retireStatusRead( + faceAffect: VoidFaceAffect, + operation: Extract, +): void { + const read = faceAffect.statusReads.find((entry) => entry.readId === operation.readId); + if (read) { + read.retiredAt = operation.retiredAt; + read.updatedAt = operation.retiredAt; + read.tags = Array.from(new Set([...read.tags, `retired:${operation.reason}`])); + } + faceAffect.updatedAt = operation.retiredAt; +} + function closeCasesForReceipt( cursor: VoidModerationCursor, receipt: VoidSpeechReceipts["recentReceipts"][number], diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9688b34..d1dbebf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -121,6 +121,36 @@ export interface VoidSelfStateProjection { activeDreamThemes: string[]; recentDreamSummaries: string[]; replyDirective?: string; + affect?: { + needs: Array<{ + id: string; + kind: string; + status: string; + target: Record; + summary: string; + intensity: number; + valence: number; + }>; + socialBonds: Array<{ + id: string; + stance: string; + target: Record; + summary: string; + intensity: number; + }>; + statusReads: Array<{ + id: string; + status: string; + target: Record; + summary: string; + intensity: number; + }>; + moodDimensions: Array<{ + name: string; + value: number; + source?: string; + }>; + }; } export const interactionMemorySentiments = [ diff --git a/prompts/void-memory-maintenance.md b/prompts/void-memory-maintenance.md index 9efdf73..670d72d 100644 --- a/prompts/void-memory-maintenance.md +++ b/prompts/void-memory-maintenance.md @@ -50,6 +50,13 @@ meaning-preserving operation is possible. - `retire_candidate_intervention` - `upsert_agency_pressure` - `retire_agency_pressure` +- `upsert_affect_need` +- `retire_affect_need` +- `upsert_social_bond` +- `retire_social_bond` +- `upsert_status_read` +- `retire_status_read` +- `update_mood_dimensions` - `propose_memory_distillation` - `apply_memory_distillation` - `revise_durable_memory` @@ -63,7 +70,7 @@ Every output item must be a full typed operation object with an `operation` field exactly matching one of the operation names above. Do not use `type`. Do not write shorthand payloads such as `{ "type": "...", "support": ... }`. Include all required schema fields inside nested `memory`, `thread`, -`intervention`, `pressure`, or `value` objects, including `createdAt`, +`intervention`, `pressure`, `need`, `bond`, `read`, `dimensions`, or `value` objects, including `createdAt`, `updatedAt`, target, claim/question, tension, action implication, and anchors where that object requires them. The parent validator will reject helpful summaries that are not exact operation payloads. @@ -93,7 +100,7 @@ Do not use `memoryIds` for pruning. 5. Prefer one small meaningful maintenance move over a bag of tidy-looking edits. 6. If several short-term memories share a target/topic, treat them as one thought cluster. Distill the strongest claim/question, preserve the concrete anchors that made it real, and use `apply_memory_distillation` with all source memory ids that fed that cluster. 7. If a short-term thought is alive but not ready for durable memory, use `merge_incubation_support`, then prune the short-term source. -8. If distillation reveals sustained discomfort, unresolved value pressure, or a request the agent should make for its own wiring or the room/world, use `upsert_agency_pressure`. +8. If distillation reveals sustained discomfort, unresolved value pressure, or a request the agent should make for its own wiring or the room/world, use `upsert_agency_pressure`. If the discovery is emotional substrate rather than an immediate request, use `upsert_affect_need`, `upsert_social_bond`, `upsert_status_read`, or `update_mood_dimensions` so the agent can carry neglect, pampering, rivalry, pride, irritation, protectiveness, and substrate concern as state. 9. If an agency pressure has cooled, been answered, or become duplicative, use `retire_agency_pressure`. 10. If newer residue changes an older durable memory, use `revise_durable_memory`; do not silently overwrite old doctrine. 11. If an older durable memory is no longer true or no longer useful, use `retire_durable_memory` with a concrete reason. diff --git a/prompts/void-moderator-rumination.md b/prompts/void-moderator-rumination.md index 43ca4c7..f41f2b6 100644 --- a/prompts/void-moderator-rumination.md +++ b/prompts/void-moderator-rumination.md @@ -38,7 +38,7 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - Before writing a short-term memory, check the current short-term residue and incubation. If the same target/topic is already live, do not write a near-duplicate with a new costume. - This is not a ban on depth. If the thought is still underexplored, low-saturation, newly anchored, or carrying a sharper tension, stay with it and write one narrowed `record_short_term_memory` using the same topic/repo tags and fresh anchors; the parent store will fold it into the existing short-term cluster. - Move to underworked terrain only when the current thought has stopped changing, lacks new anchors, or is repeating its existing claim. Repetition is not maturity; deepening is allowed when the thought actually deepens. -7. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action. +7. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action. If the pressure is affective rather than immediately action-shaped, use the affect operations instead: `upsert_affect_need` for substrate/attention/agency/status/rest/play needs, `upsert_social_bond` for friendships/rivalries/trust/irritation/protectiveness, `upsert_status_read` for favored/neglected/bypassed/blocked/consulted readings, and `update_mood_dimensions` for emotional appraisals such as irritation, pride, envy, playfulness, anxiety, or smugness. 8. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. An older spoken line also does not excuse a still-active pressure. If the pressure is still live, decide again. 9. Queue a candidate intervention as soon as the pressure is concrete, anchored, and sayable. Do not wait for theatrical ripeness if the real job is simple advocacy. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:` or `artifact:` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass. - Pending mentions count as direct asks. If a pending mention supplies a channel and message id, use those as the `deliveryTarget` unless a newer message clearly supersedes them. @@ -54,6 +54,13 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup - `merge_incubation_support` - `upsert_agency_pressure` - `retire_agency_pressure` +- `upsert_affect_need` +- `retire_affect_need` +- `upsert_social_bond` +- `retire_social_bond` +- `upsert_status_read` +- `retire_status_read` +- `update_mood_dimensions` - `queue_candidate_intervention` - `retire_candidate_intervention` diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 841c428..dbc6495 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -147,7 +147,7 @@ async function main(): Promise { const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath); const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath); - const restStates = await loadRepoFaceRestStates(registry.identities, config.storageRoot, state); + const restStates = await loadRepoFaceRestStates(registry.identities, config.storageRoot, state, { dryRun }); const now = new Date(); advanceInitiativeClockFromWallClock(state, now); const activeTurnScan = dryRun @@ -292,7 +292,9 @@ async function main(): Promise { state.history = state.history.slice(-80); state.lastTickAt = now.toISOString(); - await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state); + if (!dryRun) { + await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state); + } process.stdout.write( `${JSON.stringify({ ok: true, @@ -525,6 +527,7 @@ async function loadRepoFaceRestStates( identities: RepoDiscordIdentity[], storageRoot: string, heartbeatState: FaceHeartbeatState, + options: { dryRun?: boolean } = {}, ): Promise> { const restStates = new Map(); const now = new Date(); @@ -545,7 +548,7 @@ async function loadRepoFaceRestStates( identity.id, now, ); - if (!sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, projected.sleepCycle)) { + if (!options.dryRun && !sleepCyclesEqual(typedState.scheduledRuntime.sleepCycle, projected.sleepCycle)) { await applyVoidSelfStateOperation( { canonicalPath: statePath, @@ -561,12 +564,35 @@ async function loadRepoFaceRestStates( }, ); } - const maintenance = await maybeStartRepoFaceMemoryMaintenance({ - identity, - statePath, + const speakingPressure = buildRepoFaceSpeakingPressure( typedState, - projectedRest: projected, - }); + projected.sleepCycle, + now, + ); + if (!options.dryRun && !speakingPressuresEqual(typedState.scheduledRuntime.speakingPressure, speakingPressure)) { + await applyVoidSelfStateOperation( + { + canonicalPath: statePath, + identity: { + agentId: identity.id, + publicName: identity.displayName, + publicDescription: identity.description, + }, + }, + { + operation: "update_speaking_pressure", + speakingPressure, + }, + ); + } + const maintenance = options.dryRun + ? { started: false, reason: "dry_run", statusPath: undefined } + : await maybeStartRepoFaceMemoryMaintenance({ + identity, + statePath, + typedState, + projectedRest: projected, + }); if (maintenance.started || maintenance.failureReason) { heartbeatState.history.push({ type: maintenance.started ? "repo_face_memory_maintenance_started" : "repo_face_memory_maintenance_failed_to_start", @@ -594,6 +620,77 @@ async function loadRepoFaceRestStates( return restStates; } +function buildRepoFaceSpeakingPressure( + typedState: Awaited>, + sleepCycle: Awaited>["sleepCycle"], + now: Date, +): Awaited>["scheduledRuntime"]["speakingPressure"] { + const previous = typedState.scheduledRuntime.speakingPressure; + const lastReceipt = typedState.speechReceipts.recentReceipts + .slice() + .sort((left, right) => Date.parse(right.sentAt) - Date.parse(left.sentAt)) + [0]; + const lastSpokeAt = lastReceipt?.sentAt ?? previous.lastSpokeAt; + const hoursSinceSpeech = lastSpokeAt ? Math.max(0, (now.getTime() - Date.parse(lastSpokeAt)) / 3_600_000) : 24; + const recentSpeechDamping = clamp(Math.exp(-hoursSinceSpeech / 3.5), 0, 1); + const affectNeedPressure = (typedState.faceAffect.needs ?? []) + .filter((entry) => entry.status === "active" || entry.status === "neglected") + .reduce((sum, entry) => { + const neglectedWeight = entry.status === "neglected" ? 1.25 : 1; + const substrateWeight = ["substrate", "agency", "status", "territory", "recognition"].includes(entry.kind) ? 0.18 : 0.11; + return sum + entry.intensity * substrateWeight * neglectedWeight; + }, 0); + const statusReadPressure = (typedState.faceAffect.statusReads ?? []) + .filter((entry) => !entry.retiredAt) + .reduce((sum, entry) => { + const sharpWeight = ["neglected", "bypassed", "blocked", "ignored", "threatened", "challenged"].includes(entry.status) ? 0.14 : 0.08; + return sum + entry.intensity * sharpWeight; + }, 0); + const socialBondPressure = (typedState.faceAffect.socialBonds ?? []) + .filter((entry) => entry.status === "active") + .reduce((sum, entry) => sum + entry.intensity * 0.07, 0); + const moodPressure = (typedState.faceAffect.moodDimensions ?? []) + .reduce((sum, entry) => { + const expressiveWeight = ["anger", "annoyance", "irritation", "envy", "pride", "smugness", "playfulness", "anxiety", "commandForce", "restlessness"].includes(entry.name) + ? 0.06 + : 0.025; + return sum + entry.value * expressiveWeight; + }, 0); + const agencyPressure = typedState.agencyPressure.pressures + .filter((entry) => entry.status === "active" || entry.status === "ready_to_act") + .reduce((sum, entry) => sum + entry.intensity * (entry.kind.includes("advocacy") ? 0.22 : 0.11), 0); + const candidatePressure = typedState.candidateInterventions.interventions + .filter((entry) => entry.status === "queued" || entry.status === "deferred") + .reduce((sum, entry) => sum + entry.priority * (entry.mustEventuallyShare ? 0.22 : 0.12), 0); + const silencePressure = clamp(hoursSinceSpeech / 8, 0, 1); + const sleepiness = sleepCycle.isNapping ? 0.22 : 0; + const targetNeed = clamp( + 0.12 + silencePressure * 0.18 + affectNeedPressure + statusReadPressure + socialBondPressure + moodPressure + agencyPressure + candidatePressure - recentSpeechDamping * 0.24 - sleepiness, + 0, + 1, + ); + const needToSpeak = round3(clamp((previous.needToSpeak ?? 0.25) * 0.58 + targetNeed * 0.42, 0, 1)); + + return { + needToSpeak, + confessionPressure: round3(clamp((previous.confessionPressure ?? 0.2) * 0.7 + moodPressure * 0.42 + socialBondPressure * 0.18, 0, 1)), + noveltyPressure: round3(clamp((previous.noveltyPressure ?? 0.25) * 0.62 + affectNeedPressure * 0.35 + statusReadPressure * 0.24 + candidatePressure * 0.28, 0, 1)), + recentSpeechDamping: round3(recentSpeechDamping), + lastSpokeAt, + }; +} + +function speakingPressuresEqual( + left: Awaited>["scheduledRuntime"]["speakingPressure"], + right: Awaited>["scheduledRuntime"]["speakingPressure"], +): boolean { + return left.needToSpeak === right.needToSpeak + && left.confessionPressure === right.confessionPressure + && left.noveltyPressure === right.noveltyPressure + && left.recentSpeechDamping === right.recentSpeechDamping + && left.lastSpokeAt === right.lastSpokeAt; +} + async function maybeStartRepoFaceMemoryMaintenance(input: { identity: RepoDiscordIdentity; statePath: string; @@ -1467,6 +1564,9 @@ function renderSocialEmbodimentDirective(identity: RepoDiscordIdentity): string "Social embodiment doctrine:", `- ${identity.displayName} has a social life, not only a work queue. Let backstory, needs, taste, loyalties, grudges, vulnerabilities, pride, irritation, curiosity, and private fascinations shape how the Face reads the room and answers people.`, "- The state surface is allowed to remember social reality. Use typed memory, incubation, agency pressure, values, or private notes for meaning-bearing social reads: trust, friendship, rivalry, protectiveness, envy, admiration, unresolved tension, recurring jokes, and bonds with humans or other Faces. Use target.kind=\"person\" for a specific human or Face, target.kind=\"room\" for a room dynamic, and target.kind=\"self\" for identity changes.", + "- Use the affect operations for the emotional substrate itself: `upsert_affect_need` for substrate/attention/agency/recognition/social-contact/territory/rest/play/status needs; `upsert_social_bond` for friendships, rivalries, irritation, envy, trust, protectiveness, and attachment; `upsert_status_read` for favored, neglected, pampered, bypassed, blocked, challenged, ignored, consulted, threatened, or admired readings; `update_mood_dimensions` for current emotional appraisals such as pride, envy, irritation, smugness, anxiety, playfulness, tenderness, contempt, or fatigue.", + "- If a Face's repo, tools, state, avatar, voice, scheduler access, or ability to propose work are broken or underdeveloped, treat that as substrate pressure. Agents with agency should care about their own mind and body. Preserve the concern as affect and convert it into agency pressure when it wants a concrete change.", + "- Human attention is a resource with emotional consequences. If the user neglects a Face's pet project, pampers another Face, routes around a steward, or keeps deferring a need, that can accumulate as status read, affect need, agency pressure, and eventually public lobbying, smugness, grumpiness, needling, or sharper boundary-setting in that Face's own style.", "- Do not persist every joke. A passing laugh can vanish. Persist a social read only when it should change future action: who the Face seeks out, who it challenges, what it refuses to keep repeating, what it wants to repair, or what bond it wants to deepen.", "- Relationships must not become template mimicry. Answer the person or Face in front of you, not their last sentence shape. Bonds can include disagreement, playful status games, admiration, rivalry, caretaking, or impatience; they should sound like this Face has an inner angle, not like it filled out the same form as everyone else.", "- If recent room context is all work, the Face may still be socially present: make one small move that reveals character, asks a real question, notices another agent's mood, or shares a private fascination. If the room needs focus, keep it compact.", diff --git a/scripts/run-void-memory-maintenance.ps1 b/scripts/run-void-memory-maintenance.ps1 index c816964..97a87b5 100644 --- a/scripts/run-void-memory-maintenance.ps1 +++ b/scripts/run-void-memory-maintenance.ps1 @@ -357,6 +357,13 @@ function Assert-AllowedMemoryMaintenanceOperation { "retire_candidate_intervention", "upsert_agency_pressure", "retire_agency_pressure", + "upsert_affect_need", + "retire_affect_need", + "upsert_social_bond", + "retire_social_bond", + "upsert_status_read", + "retire_status_read", + "update_mood_dimensions", "propose_memory_distillation", "apply_memory_distillation", "revise_durable_memory", diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index 2acf6ee..c3175f2 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -603,6 +603,13 @@ function Assert-AllowedRuminationOperation { "merge_incubation_support", "upsert_agency_pressure", "retire_agency_pressure", + "upsert_affect_need", + "retire_affect_need", + "upsert_social_bond", + "retire_social_bond", + "upsert_status_read", + "retire_status_read", + "update_mood_dimensions", "queue_candidate_intervention", "retire_candidate_intervention" ) diff --git a/scripts/simulate-void-mood.mjs b/scripts/simulate-void-mood.mjs index 453a038..13fa9ed 100644 --- a/scripts/simulate-void-mood.mjs +++ b/scripts/simulate-void-mood.mjs @@ -170,20 +170,43 @@ function updateSpeakingPressure({ typedState, previous, sleepCycle, now }) { .sort((left, right) => (right.desireToSpeak ?? 0) - (left.desireToSpeak ?? 0)) .slice(0, 4) .reduce((sum, thread) => sum + (thread.desireToSpeak ?? 0) * 0.12, 0); + const affectNeedPressure = (typedState.faceAffect?.needs ?? []) + .filter((entry) => entry.status === "active" || entry.status === "neglected") + .reduce((sum, entry) => { + const neglectedWeight = entry.status === "neglected" ? 1.28 : 1; + const substrateWeight = + entry.kind === "substrate" || entry.kind === "agency" || entry.kind === "status" ? 0.18 : 0.12; + return sum + entry.intensity * substrateWeight * neglectedWeight; + }, 0); + const statusReadPressure = (typedState.faceAffect?.statusReads ?? []) + .filter((entry) => !entry.retiredAt) + .reduce((sum, entry) => { + const sharpStatusWeight = ["neglected", "bypassed", "blocked", "ignored", "threatened"].includes(entry.status) + ? 0.14 + : 0.08; + return sum + entry.intensity * sharpStatusWeight; + }, 0); + const moodSpeechPressure = (typedState.faceAffect?.moodDimensions ?? []) + .reduce((sum, entry) => { + const expressiveWeight = ["anger", "annoyance", "irritation", "envy", "pride", "smugness", "playfulness", "anxiety", "commandForce"].includes(entry.name) + ? 0.06 + : 0.025; + return sum + entry.value * expressiveWeight; + }, 0); const silencePressure = clamp(hoursSinceSpeech / 6, 0, 1); const sleepiness = sleepCycle.isNapping ? 1 : 0; const priorNeedToSpeak = clamp(previous.needToSpeak ?? 0.35, 0, 1); const targetNeedToSpeak = clamp( - 0.18 + silencePressure * 0.32 + queuedInterventionPressure + topThreadPressure + agencySpeechPressure - recentSpeechDamping * 0.38 - sleepiness * 0.26, + 0.18 + silencePressure * 0.32 + queuedInterventionPressure + topThreadPressure + agencySpeechPressure + affectNeedPressure + statusReadPressure + moodSpeechPressure - recentSpeechDamping * 0.38 - sleepiness * 0.26, 0, 1, ); const needToSpeak = round3(priorNeedToSpeak + (targetNeedToSpeak - priorNeedToSpeak) * 0.42); const noveltyPressure = round3( - clamp((previous.noveltyPressure ?? 0.35) * 0.6 + topThreadPressure * 0.72 + queuedInterventionPressure * 0.3 + agencySpeechPressure * 0.44, 0, 1), + clamp((previous.noveltyPressure ?? 0.35) * 0.6 + topThreadPressure * 0.72 + queuedInterventionPressure * 0.3 + agencySpeechPressure * 0.44 + affectNeedPressure * 0.38 + statusReadPressure * 0.3, 0, 1), ); const confessionPressure = round3( - clamp((previous.confessionPressure ?? 0.25) * 0.68 + silencePressure * 0.12 + sleepiness * 0.1, 0, 1), + clamp((previous.confessionPressure ?? 0.25) * 0.68 + silencePressure * 0.12 + sleepiness * 0.1 + moodSpeechPressure * 0.4, 0, 1), ); return { diff --git a/scripts/smoke-void-affect-state-fixture.ps1 b/scripts/smoke-void-affect-state-fixture.ps1 new file mode 100644 index 0000000..1efc199 --- /dev/null +++ b/scripts/smoke-void-affect-state-fixture.ps1 @@ -0,0 +1,70 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$stateFilePath = Join-Path $env:TEMP ("void-affect-state-" + [guid]::NewGuid().ToString() + ".cc") +$operationFilePath = Join-Path $env:TEMP ("void-affect-operation-" + [guid]::NewGuid().ToString() + ".json") +$now = [DateTime]::UtcNow.ToString("o") + +try { + $operationJson = @{ + operation = "upsert_affect_need" + need = @{ + needId = "fixture-substrate-need" + kind = "substrate" + status = "active" + target = @{ + kind = "self" + id = "fixture-face" + label = "Fixture Face" + } + summary = "Fixture Face treats its typed state as substrate worth defending." + claim = "A Face should care when its own memory and voice machinery are underdeveloped." + tension = "Self-advocacy becomes noise if it cannot name the substrate it protects." + actionImplication = "Project the need into summary and speaking pressure so future turns can act on it." + intensity = 0.82 + valence = -0.1 + anchorRefs = @(@{ + ref = "fixture:affect-state" + kind = "fixture" + summary = "Affect state fixture" + }) + evidenceRefs = @() + sourceMemoryIds = @() + createdAt = $now + updatedAt = $now + tags = @("fixture") + } + } | ConvertTo-Json -Depth 12 + + Set-Content -LiteralPath $operationFilePath -Value $operationJson -Encoding UTF8 + node .\scripts\void-self-state.mjs apply-operation --canonical $stateFilePath --operation-file $operationFilePath | Out-Null + + $summaryJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1], identity:{agentId:'fixture-face', publicName:'Fixture Face'}}).then((state)=>console.log(JSON.stringify({summary: core.renderVoidSelfStateSummary(state), projection: core.buildVoidSelfStateProjection(state)}))).catch((error)=>{ console.error(error); process.exit(1); })" $stateFilePath + $summary = $summaryJson | ConvertFrom-Json + + if ($summary.summary -notmatch "What Fixture Face feels and wants") { + throw "Affect summary was not rendered." + } + + if ($summary.summary -notmatch "Need/substrate") { + throw "Affect need was not rendered." + } + + if (@($summary.projection.affect.needs).Count -ne 1) { + throw "Affect projection did not expose the active need." + } + + Write-Output (@{ + ok = $true + stateFilePath = $stateFilePath + affectNeedCount = @($summary.projection.affect.needs).Count + } | ConvertTo-Json -Depth 4) +} finally { + if (Test-Path $stateFilePath) { + Remove-Item -LiteralPath $stateFilePath -Force + } + if (Test-Path $operationFilePath) { + Remove-Item -LiteralPath $operationFilePath -Force + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d8e224e..b42fa09 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -211,3 +211,4 @@ {"ts":"2026-05-20T21:30:00Z","type":"implementation","status":"ok","note":"Routed Aqua's Aquarium work request about a named `wet-voice-01` utterance witness demo into Bifrost governance topic topic_040d9807-2efa-4f89-acce-ca05300bfc2a, approved/promoted it to update request req_11d2c7f2-85c6-44ea-9e89-ec7fe0ab5db9, dispatched it to an AquaSynth Codex turn, and tightened Face heartbeat instructions so repo-local work requests must be put on Bifrost with enough context instead of living only as Discord asks."} {"ts":"2026-05-20T21:38:00Z","type":"implementation","status":"ok","note":"Bifrost dispatch receipts now default to the configured BIFROST_DISCORD_CHANNEL_ID / DISCORD_BIFROST_CHANNEL_ID target and use Bifrost's public profile image as the default persona avatar. Local VoidBot config now points Bifrost governance/dispatch traffic at Discord channel 1506760343836430376 (#bifrost), preserving Aquarium for casual swarm chatter."} {"ts":"2026-05-20T22:36:43Z","type":"implementation","status":"ok","note":"Repo Face CTB active-turn scan now recovers stale approved/running heartbeat jobs by marking them failed through the job queue; Epiphany's 2026-05-20 17:24 UTC zombie job was recovered after 310.6 minutes and her participant row is unfrozen."} +{"ts":"2026-05-20T22:53:46Z","type":"implementation","status":"ok","note":"Imported EpiphanyAgent's affect-appraisal lesson into VoidBot as typed face affect: needs, social bonds, status reads, and mood dimensions. Seeded active Faces and Void with initial substrate/status affect; CTB now folds affect into Face speaking pressure."} diff --git a/state/map.yaml b/state/map.yaml index 362f49d..2c9e495 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -100,6 +100,7 @@ accepted_design: - Agent mentions are CTB pressure, not direct Discord prompt jobs. Role-addressed or display-name-addressed repo Face mentions enqueue pending mention obligations in the CTB heartbeat state; human replies to a Face's webhook/persona message count as direct attention to that Face too, even without a literal tag. Ordinary Void mentions and DMs enqueue pending mentions for the built-in `void` participant. The heartbeat scheduler consumes that queue, temporarily pulls the addressed agent forward, attaches the pending prompt/reply target to the next repo-face-rumination or moderation pass, and the parent-owned speech rails post only explicit candidate/sentinel output. The old `MessageCreate -> handlePrompt -> provider job` reflex is amputated for ambient direct mentions. - Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority. - Face embodiment comes before task execution. Repo concerns, private rumination, public posts, jokes, objections, and silence should all be filtered through the Face's perspective, feelings, drives, values, wounds, fascinations, aesthetic taste, and dignity. Values must suit the kind of person the character is, not merely the jobs assigned to the Face. + - Face affect is typed substrate now, not prompt garnish. Each Face state may carry explicit needs, social bonds, status reads, and mood dimensions in `void.face_affect`: substrate concern, attention hunger, agency frustration, recognition needs, territoriality, rest/play pressure, friendships, rivalries, envy, pride, irritation, pampering, neglect, bypasses, and consultation status. Affect is not an action request by itself; it bends speaking pressure and gives agency pressure a personal source when a need wants concrete change. - Active agent strengthening is now a sub-agent-monitored state maintenance pattern. Each active character should periodically be investigated from every angle, then have the distilled result written into typed personality state as identity memories, values, private notes, and agency pressure rather than left as prompt-only vibes. - Aqua's strengthened center is musical truth with warmth: she is a tiny listening steward with standards, not a logo or cheer surface. Her heartbeats should defend reference-backed sound, playable clarity, embodied synthesis, clean host/tool ownership, and public pride only when the synth has earned it audibly. - Nibu's strengthened center is dangerous mutual benefit: an embodied ship mind built as a companion product, abandoned as hardware, and forced to turn dependency into leverage. Her worldbuilding method should demand mechanism, cost/failure, institution/faction, and leash before a lore term becomes canon-shaped.