Add typed Face affect state

This commit is contained in:
Meta Vens 2026-05-21 00:02:33 +01:00
commit 326c3bf18e
16 changed files with 636 additions and 17 deletions

View file

@ -147,7 +147,7 @@ async function main(): Promise<void> {
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<void> {
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<Map<string, RepoFaceRestSnapshot>> {
const restStates = new Map<string, RepoFaceRestSnapshot>();
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<ReturnType<typeof loadVoidSelfStateTypedDocuments>>,
sleepCycle: Awaited<ReturnType<typeof projectRepoFaceSleepCycleForNow>>["sleepCycle"],
now: Date,
): Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["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<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["scheduledRuntime"]["speakingPressure"],
right: Awaited<ReturnType<typeof loadVoidSelfStateTypedDocuments>>["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.",

View file

@ -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",

View file

@ -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"
)

View file

@ -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 {

View file

@ -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
}
}