Add semantic Persona response pressure

This commit is contained in:
Meta Vens 2026-07-12 02:21:43 +01:00
commit b0dbb2d8bd
12 changed files with 483 additions and 6 deletions

View file

@ -264,6 +264,8 @@ function buildUpcomingTurns(participants) {
nextTurnInMinutes: participant.nextTurnInMinutes,
effectiveSpeed: participant.effectiveSpeed,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
lane: index % 3,
timelinePosition: participant.activeJobId ? 0 : Math.min(100, Math.max(0, ((Math.max(0, participant.nextTurnInMinutes ?? 0)) / maxMinutes) * 100)),
}));
@ -331,6 +333,16 @@ function projectParticipant(participant, pendingMentions, initiativeClock, ident
status: String(participant.status ?? "unknown"),
currentLoad: numberOrNull(participant.currentLoad),
heat: numberOrNull(participant.heat),
dynamicHeat: numberOrNull(participant.dynamicHeat),
responsePressure: numberOrNull(participant.responsePressure),
responsePressureEvidence: Array.isArray(participant.responsePressureEvidence)
? participant.responsePressureEvidence.slice(0, 6).map((entry) => ({
messageId: stringOrNull(entry?.messageId),
observedAt: stringOrNull(entry?.observedAt),
similarity: numberOrNull(entry?.similarity),
contribution: numberOrNull(entry?.contribution),
}))
: [],
effectiveSpeed: numberOrNull(participant.effectiveSpeed),
initiativeSpeed: numberOrNull(participant.initiativeSpeed),
nextTurnAt,
@ -2431,6 +2443,8 @@ function renderHtml(snapshot) {
statBar("Memory", number(memoryCount), Math.min(100, memoryCount * 7), "cool") +
statBar("Pressure", number(counts.pressures || 0), Math.min(100, (counts.pressures || 0) * 14), (counts.pressures || 0) > 4 ? "hot" : "") +
statBar("Heat", number(agent.heat), Math.min(100, (agent.heat || 0) * 34), (agent.heat || 0) > 1.5 ? "hot" : "") +
statBar("Response", number(agent.responsePressure), Math.min(100, (agent.responsePressure || 0) * 100), (agent.responsePressure || 0) > 0.6 ? "hot" : "") +
statBar("Dynamic", number(agent.dynamicHeat), Math.min(100, (agent.dynamicHeat || 0) * 40), (agent.dynamicHeat || 0) > 1.5 ? "hot" : "") +
statBar("Load", number(agent.currentLoad), Math.min(100, (agent.currentLoad || 0) * 100), (agent.currentLoad || 0) > 0.7 ? "hot" : "") +
statBar("Speed", number(agent.effectiveSpeed), Math.min(100, (agent.effectiveSpeed || 0) * 34), "") +
"</div><div class=\\"inspector-lore\\"><p>" + esc(agent.description || "No Face description registered.") + "</p><div class=\\"channel-list\\">" + (agent.channelPermissions || []).map((channel) => "<div class=\\"channel-chip\\"><strong>" + esc(channel.label || "channel") + "</strong><span>x" + esc(number(channel.speedMultiplier || 1)) + "</span><span class=\\"muted\\">" + esc(channel.topic || "no topic") + "</span><span class=\\"mono muted\\">" + esc(channel.speechThreshold || "threshold") + "</span></div>").join("") + "</div></div></section>";
@ -2767,6 +2781,7 @@ function redactSnapshot(snapshot) {
...participant,
activeJobId: participant.activeJobId ? "active" : null,
constraints: [],
responsePressureEvidence: [],
constraintCount: participant.constraintCount,
description: participant.description ? truncate(participant.description, 220) : null,
faceStatePath: null,

View file

@ -20,6 +20,7 @@ import {
loadFaceIdentityRegistry,
loadVoidSelfStateTypedDocuments,
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
projectRepoFaceResponsePressure,
type RepoFaceRestSnapshot,
resolveRepoFaceStatePath,
type RepoFacePendingMention,
@ -29,8 +30,10 @@ import {
import {
createTextEmbedder,
createVectorStores,
embedAffinityCards,
normalizeText,
RetrievalService,
scoreAffinityMessages,
} from "@voidbot/rag";
import {
type EmbeddingChunk,
@ -61,6 +64,15 @@ interface FaceHeartbeatParticipant {
status: "active" | "blocked" | "withdrawn" | "offscreen";
groups: string[];
heat: number;
dynamicHeat: number;
responsePressure: number;
responsePressureEvidence: Array<{
messageId: string;
observedAt: string;
similarity: number;
contribution: number;
}>;
semanticInterruptReceipts: string[];
effectiveSpeed: number;
baseRecoveryMinutes: number;
nextTurnAt: number;
@ -99,6 +111,7 @@ interface IdleCoolingSnapshot {
recoveryMinutes: number;
lastUnpromptedTurnQueuedAt?: string;
nextUnpromptedTurnAllowedAt?: string;
observedHumanMessages: Array<SourceMessage & { channelId: string }>;
}
interface StaleActiveTurn {
@ -269,6 +282,15 @@ async function main(): Promise<void> {
state,
now,
});
await applySemanticResponsePressure({
state,
specs: buildParticipantSpecs(registry.identities),
messages: idleCooling.observedHumanMessages,
restStates,
completedThisTick,
config,
now,
});
const selected = selectReadyParticipants(
state,
@ -297,6 +319,8 @@ async function main(): Promise<void> {
nextTurnAt: participant.nextTurnAt,
recoveryMinutes,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
@ -366,6 +390,8 @@ async function main(): Promise<void> {
initiativeClock: state.initiativeClock,
frozen: true,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
pendingMentionCount: pendingMentions.length,
});
@ -382,6 +408,8 @@ async function main(): Promise<void> {
initiativeClock: state.initiativeClock,
reason: turn.failureReason,
heat: participant.heat,
dynamicHeat: participant.dynamicHeat,
responsePressure: participant.responsePressure,
effectiveSpeed: participant.effectiveSpeed,
});
}
@ -406,7 +434,11 @@ async function main(): Promise<void> {
selected: selected.map((entry) => entry.identityId),
queued: queuedIdentityIds,
statePath: config.repoFaceHeartbeats.statePath,
idleCooling,
idleCooling: {
...idleCooling,
observedHumanMessages: undefined,
observedHumanMessageCount: idleCooling.observedHumanMessages.length,
},
})}\n`,
);
}
@ -1457,7 +1489,8 @@ function reconcileParticipants(
const speed = initiativeSpeedFor(spec, speedOverrides) * spec.channelSpeedMultiplier;
const groups = initiativeGroupsFor(spec);
const heat = heatFor(spec, groups, globalHeat, heatOverrides);
const effectiveSpeed = clamp(speed * heat, 0.1, 12);
const dynamicHeat = Number.isFinite(current?.dynamicHeat) ? current.dynamicHeat : 1;
const effectiveSpeed = clamp(speed * heat * dynamicHeat, 0.1, 12);
const nextTurnAt = Number.isFinite(current?.nextTurnAt)
? current.nextTurnAt
: initiativeClock + ((baseRecoveryMinutes / count) * index);
@ -1471,6 +1504,10 @@ function reconcileParticipants(
initiativeSpeed: speed,
groups,
heat,
dynamicHeat,
responsePressure: Number.isFinite(current.responsePressure) ? current.responsePressure : 0,
responsePressureEvidence: Array.isArray(current.responsePressureEvidence) ? current.responsePressureEvidence : [],
semanticInterruptReceipts: Array.isArray(current.semanticInterruptReceipts) ? current.semanticInterruptReceipts : [],
effectiveSpeed,
baseRecoveryMinutes,
nextTurnAt,
@ -1502,6 +1539,10 @@ function reconcileParticipants(
status: hasChannel ? "active" : "blocked",
groups,
heat,
dynamicHeat: 1,
responsePressure: 0,
responsePressureEvidence: [],
semanticInterruptReceipts: [],
effectiveSpeed,
baseRecoveryMinutes,
nextTurnAt,
@ -1624,6 +1665,142 @@ function selectReadyParticipants(
return [...mentioned, ...cooled].slice(0, maxJobs);
}
async function applySemanticResponsePressure(input: {
state: FaceHeartbeatState;
specs: ParticipantSpec[];
messages: Array<SourceMessage & { channelId: string }>;
restStates: Map<string, RepoFaceRestSnapshot>;
completedThisTick: Set<string>;
config: ReturnType<typeof loadConfig>;
now: Date;
}): Promise<void> {
const specs = input.specs.filter((spec) => {
const participant = input.state.participants.find((entry) => entry.identityId === spec.id);
return Boolean(
spec.identity &&
spec.participantKind !== "system_agent" &&
participant?.status === "active" &&
participant.currentLoad < 1 &&
!input.completedThisTick.has(participant.identityId) &&
input.restStates.get(participant.identityId)?.isNapping !== true,
);
});
const recentMessages = input.messages
.filter((message) => !message.isBot && message.content.trim().length > 0)
.filter((message) => input.now.getTime() - Date.parse(message.timestamp) <= 3 * 60 * 60_000)
.sort((left, right) => right.timestamp.localeCompare(left.timestamp))
.slice(0, 24);
if (specs.length === 0 || recentMessages.length === 0) {
for (const participant of input.state.participants) {
participant.dynamicHeat = 1;
participant.responsePressure = 0;
participant.responsePressureEvidence = [];
participant.effectiveSpeed = clamp(participant.initiativeSpeed * participant.heat, 0.1, 12);
}
return;
}
try {
const embedder = createTextEmbedder({
backend: input.config.ragEmbeddingBackend,
hashDimensions: input.config.ragEmbeddingDimensions,
ollamaBaseUrl: input.config.ragOllamaBaseUrl,
ollamaModel: input.config.ragOllamaModel,
ollamaTimeoutMs: input.config.ragOllamaTimeoutMs,
});
const cards = await embedAffinityCards(embedder, specs.map((spec) => ({
id: spec.id,
text: renderInitiativeAffinityCard(spec),
})));
const scores = await scoreAffinityMessages(embedder, cards, recentMessages.map((message) => ({
id: message.id,
text: message.content,
observedAt: message.timestamp,
})));
const projections = projectRepoFaceResponsePressure({
participants: input.state.participants
.filter((participant) => specs.some((spec) => spec.id === participant.identityId))
.map((participant) => ({
identityId: participant.identityId,
interruptThreshold: participant.interruptThreshold,
})),
evidence: scores.flatMap((score) => {
const spec = specs.find((entry) => entry.id === score.cardId);
const message = recentMessages.find((entry) => entry.id === score.messageId);
if (!spec || !message || !spec.allowedChannelIds.includes(message.channelId)) {
return [];
}
return [{
identityId: score.cardId,
messageId: score.messageId,
observedAt: score.observedAt,
similarity: score.similarity,
}];
}),
now: input.now,
maxAwakened: Math.min(3, specs.length),
});
for (const participant of input.state.participants) {
const projection = projections.find((entry) => entry.identityId === participant.identityId);
participant.responsePressure = projection?.pressure ?? 0;
participant.responsePressureEvidence = projection?.evidence ?? [];
participant.dynamicHeat = clamp(1 + participant.responsePressure * 1.5, 1, 2.5);
participant.effectiveSpeed = clamp(
participant.initiativeSpeed * participant.heat * participant.dynamicHeat,
0.1,
12,
);
const unseenInterruptEvidence = projection?.interrupt
? projection.evidence.find((entry) => !participant.semanticInterruptReceipts.includes(entry.messageId))
: undefined;
if (unseenInterruptEvidence && participant.currentLoad < 1) {
participant.nextTurnAt = Math.min(participant.nextTurnAt, input.state.initiativeClock);
participant.semanticInterruptReceipts = mergeStrings(
participant.semanticInterruptReceipts,
unseenInterruptEvidence.messageId,
).slice(-40);
input.state.history.push({
type: "semantic_response_interrupt",
identityId: participant.identityId,
messageId: unseenInterruptEvidence.messageId,
pressure: participant.responsePressure,
similarity: unseenInterruptEvidence.similarity,
contribution: unseenInterruptEvidence.contribution,
observedAt: unseenInterruptEvidence.observedAt,
projectedAt: input.now.toISOString(),
});
}
}
} catch (error) {
for (const participant of input.state.participants) {
participant.dynamicHeat = 1;
participant.responsePressure = 0;
participant.responsePressureEvidence = [];
participant.effectiveSpeed = clamp(participant.initiativeSpeed * participant.heat, 0.1, 12);
}
input.state.history.push({
type: "semantic_response_pressure_unavailable",
observedAt: input.now.toISOString(),
reason: collapseWhitespace(error instanceof Error ? error.message : String(error), 300),
});
}
}
function renderInitiativeAffinityCard(spec: ParticipantSpec): string {
const identity = spec.identity;
return [
`${spec.displayName} is the Persona steward of ${spec.repoName}.`,
identity?.description,
...(identity?.channelPermissions ?? []).flatMap((permission) => [
permission.topic,
permission.label,
permission.posture,
]),
].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join("\n");
}
function advanceInitiativeClockFromWallClock(state: FaceHeartbeatState, now: Date): void {
const lastTickMs = Date.parse(state.lastTickAt ?? "");
if (!Number.isFinite(lastTickMs)) {
@ -1710,11 +1887,9 @@ async function readIdleCoolingSnapshot(input: {
idleAfterMinutes: policy.idleAfterMinutes,
recoveryMinutes: policy.recoveryMinutes,
lastUnpromptedTurnQueuedAt: newestUnpromptedTurnQueuedAt(input.state),
observedHumanMessages: [] as Array<SourceMessage & { channelId: string }>,
};
if (!policy.enabled) {
return { ...base, reason: "disabled" };
}
if (!input.config.botToken) {
return { ...base, reason: "missing_discord_bot_token" };
}
@ -1735,6 +1910,9 @@ async function readIdleCoolingSnapshot(input: {
if (message.isBot || !message.content.trim()) {
continue;
}
if (!base.observedHumanMessages.some((entry) => entry.id === message.id)) {
base.observedHumanMessages.push({ ...message, channelId });
}
const timestampMs = Date.parse(message.timestamp);
const newestMs = Date.parse(newestHumanActivityAt ?? "");
if (Number.isFinite(timestampMs) && (!Number.isFinite(newestMs) || timestampMs > newestMs)) {
@ -1752,6 +1930,14 @@ async function readIdleCoolingSnapshot(input: {
? new Date(lastUnpromptedTurnQueuedMs + policy.recoveryMinutes * 60_000).toISOString()
: undefined;
if (!policy.enabled) {
return {
...base,
reason: fetchErrors.length > 0 ? "disabled_with_activity_fetch_failure" : "disabled",
lastHumanActivityAt: newestHumanActivityAt,
};
}
if (!newestHumanActivityAt) {
return {
...base,
@ -5584,6 +5770,14 @@ function migrateLegacyHeartbeatState(
status: participant.status ?? "active",
groups: participant.groups ?? [],
heat: Number.isFinite(participant.heat) ? participant.heat : 1,
dynamicHeat: Number.isFinite(participant.dynamicHeat) ? participant.dynamicHeat : 1,
responsePressure: Number.isFinite(participant.responsePressure) ? participant.responsePressure : 0,
responsePressureEvidence: Array.isArray(participant.responsePressureEvidence)
? participant.responsePressureEvidence
: [],
semanticInterruptReceipts: Array.isArray(participant.semanticInterruptReceipts)
? participant.semanticInterruptReceipts
: [],
effectiveSpeed: Number.isFinite(participant.effectiveSpeed) ? participant.effectiveSpeed : speed,
baseRecoveryMinutes,
nextTurnAt: minutesUntilReady,

View file

@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { projectRepoFaceResponsePressure } from "@voidbot/core";
import {
embedAffinityCards,
scoreAffinityMessages,
type TextEmbedder,
} from "@voidbot/rag";
const now = new Date("2026-07-12T12:00:00.000Z");
const embedder: TextEmbedder = {
id: "fixture:3",
dimensions: 3,
async embedDocuments(texts) {
return texts.map(vectorFor);
},
async embedQuery(text) {
return vectorFor(text);
},
};
async function main(): Promise<void> {
const cards = await embedAffinityCards(embedder, [
{ id: "aqua", text: "music synthesizer sound patch" },
{ id: "heimdall", text: "oauth custody authentication grants" },
{ id: "nibu", text: "aetheria lore narrative factions" },
]);
const scores = await scoreAffinityMessages(embedder, cards, [
{ id: "music", text: "the synthesizer patch has a strange sound", observedAt: now.toISOString() },
{ id: "auth", text: "oauth grants need revocation", observedAt: now.toISOString() },
]);
assert.equal(cards.length, 3);
const score = (cardId: string, messageId: string): number =>
scores.find((item) => item.cardId === cardId && item.messageId === messageId)?.similarity ?? -1;
assert.ok(score("aqua", "music") > score("heimdall", "music"));
assert.ok(score("heimdall", "auth") > score("aqua", "auth"));
const projected = projectRepoFaceResponsePressure({
participants: cards.map((card) => ({ identityId: card.id, interruptThreshold: 0.5 })),
evidence: scores.map((item) => ({
identityId: item.cardId,
messageId: item.messageId,
observedAt: item.observedAt,
similarity: item.similarity,
})),
now,
maxAwakened: 2,
});
assert.ok((projected.find((item) => item.identityId === "aqua")?.pressure ?? 0) > 0.5);
assert.ok((projected.find((item) => item.identityId === "heimdall")?.pressure ?? 0) > 0.5);
assert.equal(projected.find((item) => item.identityId === "nibu")?.pressure, 0, "dogpile cap must cool irrelevant cards");
const expired = projectRepoFaceResponsePressure({
participants: [{ identityId: "aqua", interruptThreshold: 0.5 }],
evidence: [{
identityId: "aqua",
messageId: "old",
observedAt: "2026-07-12T06:00:00.000Z",
similarity: 1,
}],
now,
ttlMinutes: 60,
});
assert.equal(expired[0]?.pressure, 0, "expired evidence must not alter initiative");
const duplicate = projectRepoFaceResponsePressure({
participants: [{ identityId: "aqua", interruptThreshold: 0.5 }],
evidence: [
{ identityId: "aqua", messageId: "same", observedAt: now.toISOString(), similarity: 0.8 },
{ identityId: "aqua", messageId: "same", observedAt: now.toISOString(), similarity: 0.8 },
],
now,
});
assert.equal(duplicate[0]?.evidence.length, 1, "replayed windows must be idempotent by message id");
await assert.rejects(
scoreAffinityMessages({ ...embedder, id: "other" }, cards, []),
/uses fixture:3, not other/,
);
process.stdout.write(`${JSON.stringify({ ok: true, cards: cards.length, scoreCount: scores.length, projected })}\n`);
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
function vectorFor(text: string): number[] {
const lower = text.toLowerCase();
const vector = [
/music|synth|sound|patch/.test(lower) ? 1 : 0,
/oauth|auth|grant|custody|revocation/.test(lower) ? 1 : 0,
/aetheria|lore|narrative|faction/.test(lower) ? 1 : 0,
];
const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value ** 2, 0));
return magnitude > 0 ? vector.map((value) => value / magnitude) : vector;
}