Extract Persona social context projection
This commit is contained in:
parent
562cc5dd32
commit
6bdf2750d1
7 changed files with 170 additions and 249 deletions
|
|
@ -63,6 +63,7 @@ import {
|
|||
type PersonaMemoryRecallObservation,
|
||||
} from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
|
||||
import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js";
|
||||
import { observePersonaRoomTexture, renderPersonaHumanPronounFacts, renderPersonaRelationshipFreshness, renderPersonaRoomTexture, renderPersonaRoomWeather, type PersonaHumanPronounGuidance as RepoFaceHumanPronounGuidance } from "../apps/persona-scheduler/dist/persona-social-context-projector.js";
|
||||
import {
|
||||
readDiscordActivitySnapshot,
|
||||
type IdleCoolingSnapshot,
|
||||
|
|
@ -649,9 +650,9 @@ function buildHeartbeatPrompt(input: {
|
|||
semanticMemoryRecallSurface: input.semanticMemoryRecallSurface ?? "- No semantic Persona memory recall was attached for this turn.",
|
||||
repoActivitySurface: input.repoActivitySurface ?? "- No recent home repo activity was attached for this turn.",
|
||||
conversationMemorySurface: input.conversationMemorySurface ?? "- No recent conversation transcript was attached for this turn.",
|
||||
humanPronounDirective: renderRepoFaceHumanPronounFacts(input.humanPronounGuidance ?? [])
|
||||
humanPronounDirective: renderPersonaHumanPronounFacts(input.humanPronounGuidance ?? [])
|
||||
?? "Known human pronoun guidance:\n- No explicit human pronoun guidance is attached for this turn. Use names or neutral phrasing instead of guessing.",
|
||||
roomWeatherDirective: renderRepoFaceRoomWeatherDirective(input.identity, {
|
||||
roomWeatherDirective: renderPersonaRoomWeather({ identity: input.identity,
|
||||
recentMessages: input.recentMessages,
|
||||
channelSnapshots: input.channelSnapshots,
|
||||
}),
|
||||
|
|
@ -1136,7 +1137,12 @@ function renderRepoFaceStatePacket(
|
|||
: undefined;
|
||||
const clarityPressureActive = Boolean(humanClarityFacts);
|
||||
|
||||
const relationshipFreshnessFacts = renderRepoFaceRelationshipFreshnessFacts(name, state, registryIdentities);
|
||||
const relationshipFreshnessFacts = renderPersonaRelationshipFreshness({
|
||||
identityName: name,
|
||||
state,
|
||||
registryIdentities,
|
||||
observedAt: new Date(),
|
||||
});
|
||||
const socialGraphFacts = renderRepoFaceSocialGraphFacts(identity, registryIdentities, state);
|
||||
const peerOpeningFacts = roomContext
|
||||
? renderRepoFacePeerOpeningFacts(identity, registryIdentities, roomContext)
|
||||
|
|
@ -1144,9 +1150,13 @@ function renderRepoFaceStatePacket(
|
|||
const socialPressureFacts = roomContext
|
||||
? renderRepoFaceRelationshipPressureFacts(identity, registryIdentities, state, roomContext)
|
||||
: undefined;
|
||||
const pronounFacts = renderRepoFaceHumanPronounFacts(humanPronounGuidance);
|
||||
const pronounFacts = renderPersonaHumanPronounFacts(humanPronounGuidance);
|
||||
const roomTextureFacts = roomContext
|
||||
? renderRepoFaceRoomTextureFacts(identity, roomContext)
|
||||
? renderPersonaRoomTexture({
|
||||
identity,
|
||||
...roomContext,
|
||||
topicAttractorFacts: renderRepoFaceTopicAttractorFacts(identity, roomContext.recentMessages),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const selfMaintenancePressureFacts = !clarityPressureActive
|
||||
|
|
@ -1237,96 +1247,6 @@ function renderRepoFaceSelfMaintenancePressureFacts(
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function renderRepoFaceRelationshipFreshnessFacts(
|
||||
name: string,
|
||||
state: VoidSelfStateTypedProjection,
|
||||
registryIdentities: RepoDiscordIdentity[],
|
||||
): string | undefined {
|
||||
const nowMs = Date.now();
|
||||
const peerKeys = repoFacePeerSocialKeys(registryIdentities);
|
||||
const staleBonds = (state.faceAffect.socialBonds ?? [])
|
||||
.filter((bond) => bond.status === "active" && bond.target.kind === "person" && isRepoFacePeerTarget(bond.target, peerKeys))
|
||||
.map((bond) => ({
|
||||
target: targetLabel(bond.target),
|
||||
kind: bond.stance,
|
||||
intensity: bond.intensity,
|
||||
ageHours: ageHoursSince(bond.updatedAt, nowMs),
|
||||
summary: bond.summary,
|
||||
action: bond.actionImplication,
|
||||
}))
|
||||
.filter((entry) => entry.ageHours >= 48)
|
||||
.sort((left, right) => (right.ageHours * right.intensity) - (left.ageHours * left.intensity))
|
||||
.slice(0, 5);
|
||||
const staleReads = (state.faceAffect.statusReads ?? [])
|
||||
.filter((read) => !read.retiredAt && read.target.kind === "person" && isRepoFacePeerTarget(read.target, peerKeys))
|
||||
.map((read) => ({
|
||||
target: targetLabel(read.target),
|
||||
kind: read.status,
|
||||
intensity: read.intensity,
|
||||
ageHours: ageHoursSince(read.updatedAt, nowMs),
|
||||
summary: read.summary,
|
||||
action: read.actionImplication,
|
||||
}))
|
||||
.filter((entry) => entry.ageHours >= 72)
|
||||
.sort((left, right) => (right.ageHours * right.intensity) - (left.ageHours * left.intensity))
|
||||
.slice(0, 5);
|
||||
const entries = [
|
||||
...staleBonds.map((entry) =>
|
||||
`- ${entry.target}: ${entry.kind} bond has gone ${formatAgeHours(entry.ageHours)} without contact. ${asSentence(entry.summary)} Touch-base pull: ${asSentence(entry.action)}`,
|
||||
),
|
||||
...staleReads.map((entry) =>
|
||||
`- ${entry.target}: ${entry.kind} status read has gone ${formatAgeHours(entry.ageHours)} without contact. ${asSentence(entry.summary)} Touch-base pull: ${asSentence(entry.action)}`,
|
||||
),
|
||||
].slice(0, 6);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
`Relationship freshness pressure for ${name}:`,
|
||||
...entries,
|
||||
"These are not commands to dump feelings. They are swarm social graph itch. A compact tease, check-in, challenge, compliment, question, or repair with another Face can be a successful public turn.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function repoFacePeerSocialKeys(registryIdentities: RepoDiscordIdentity[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const identity of registryIdentities) {
|
||||
for (const value of [identity.id, identity.displayName, identity.repoName]) {
|
||||
const key = normalizeSocialLabel(value);
|
||||
if (key.length > 0) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isRepoFacePeerTarget(
|
||||
target: { id: string; label?: string },
|
||||
peerKeys: Set<string>,
|
||||
): boolean {
|
||||
return [target.id, target.label]
|
||||
.map(normalizeSocialLabel)
|
||||
.some((key) => key.length > 0 && peerKeys.has(key));
|
||||
}
|
||||
|
||||
function ageHoursSince(value: string | undefined, nowMs: number): number {
|
||||
const then = Date.parse(value ?? "");
|
||||
if (!Number.isFinite(then)) {
|
||||
return 999;
|
||||
}
|
||||
return Math.max(0, (nowMs - then) / 3_600_000);
|
||||
}
|
||||
|
||||
function formatAgeHours(hours: number): string {
|
||||
if (hours >= 48) {
|
||||
return `${Math.round(hours / 24)} days`;
|
||||
}
|
||||
return `${Math.round(hours)} hours`;
|
||||
}
|
||||
|
||||
function cleanRepoFaceProjectorLoopVocabulary(
|
||||
identity: RepoDiscordIdentity,
|
||||
surface: string,
|
||||
|
|
@ -1396,63 +1316,6 @@ function targetLabel(target: { label?: string; id?: string; kind?: string } | un
|
|||
return target.label ?? target.id ?? target.kind ?? "an unnamed target";
|
||||
}
|
||||
|
||||
function renderRepoFaceRoomTextureFacts(
|
||||
identity: RepoDiscordIdentity,
|
||||
input: {
|
||||
recentMessages: SourceMessage[];
|
||||
channelSnapshots: ChannelSnapshot[];
|
||||
},
|
||||
): string | undefined {
|
||||
const stats = collectRepoFaceRoomTextureStats(identity, input);
|
||||
if (!stats) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const temporaryPressures = stats.texture === "heavy" || stats.agentShare >= 0.55
|
||||
? [
|
||||
"- Temporary affect pressure candidates: play, rest, and social_contact may be hungry because the room is carrying sustained work/agent weight.",
|
||||
"- These are not stored needs and not orders. Project whether this character gets mischievous, bored, sharp, withdrawn, socially hungry, status-testing, or still work-focused.",
|
||||
]
|
||||
: [];
|
||||
const topicAttractorFacts = renderRepoFaceTopicAttractorFacts(identity, input.recentMessages);
|
||||
|
||||
return [
|
||||
"Room texture facts:",
|
||||
`- Observed messages: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
|
||||
`- Long messages: ${stats.longMessages}; short messages: ${stats.shortMessages}; average length: ${stats.averageCharacters} characters.`,
|
||||
`- This Face's own recent messages in the attached window: ${stats.ownMessages}.`,
|
||||
`- Structural texture: ${stats.texture}. This is evidence about conversational weight, not a command to speak or joke.`,
|
||||
...temporaryPressures,
|
||||
...(topicAttractorFacts ? [topicAttractorFacts] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderRepoFaceRoomWeatherDirective(
|
||||
identity: RepoDiscordIdentity,
|
||||
input: {
|
||||
recentMessages: SourceMessage[];
|
||||
channelSnapshots: ChannelSnapshot[];
|
||||
},
|
||||
): string {
|
||||
const stats = collectRepoFaceRoomTextureStats(identity, input);
|
||||
if (!stats) {
|
||||
return "- No current room weather was available.";
|
||||
}
|
||||
|
||||
const pressure =
|
||||
stats.texture === "heavy" || stats.agentShare >= 0.55
|
||||
? "The room is currently structurally work-heavy or agent-heavy. Treat that as conversational weather: it may create boredom, play hunger, restlessness, social hunger, withdrawal, or sharper status-testing, depending on who you are. This is not an order to joke; it is permission to feel the weight instead of answering every opening with more work."
|
||||
: stats.texture === "light"
|
||||
? "The room is currently light enough for compact social motion. You still need an actual reason to speak, but not a work ticket."
|
||||
: "The room is mixed. Use the transcript to decide whether the living pressure is social, practical, or private.";
|
||||
|
||||
return [
|
||||
`- Messages observed: ${stats.total}; humans: ${stats.humanMessages}; agents/bots: ${stats.agentMessages}; distinct speakers: ${stats.speakerCount}.`,
|
||||
`- Texture: ${stats.texture}; your own recent messages in this window: ${stats.ownMessages}.`,
|
||||
`- ${pressure}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
interface RepoFaceCuriosityNode {
|
||||
id: string;
|
||||
text: string;
|
||||
|
|
@ -2058,68 +1921,6 @@ function collectLoopVocabularyTerms(content: string): string[] {
|
|||
return terms.filter((term) => normalized.includes(term));
|
||||
}
|
||||
|
||||
interface RepoFaceRoomTextureStats {
|
||||
total: number;
|
||||
agentMessages: number;
|
||||
humanMessages: number;
|
||||
ownMessages: number;
|
||||
longMessages: number;
|
||||
shortMessages: number;
|
||||
averageCharacters: number;
|
||||
speakerCount: number;
|
||||
texture: "heavy" | "light" | "mixed";
|
||||
agentShare: number;
|
||||
}
|
||||
|
||||
function collectRepoFaceRoomTextureStats(
|
||||
identity: RepoDiscordIdentity,
|
||||
input: {
|
||||
recentMessages: SourceMessage[];
|
||||
channelSnapshots: ChannelSnapshot[];
|
||||
},
|
||||
): RepoFaceRoomTextureStats | undefined {
|
||||
const messages = [
|
||||
...input.recentMessages,
|
||||
...input.channelSnapshots.flatMap((snapshot) => snapshot.messages),
|
||||
];
|
||||
if (messages.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ownToken = normalizeSocialLabel(identity.displayName);
|
||||
const total = messages.length;
|
||||
const agentMessages = messages.filter((message) => message.isBot).length;
|
||||
const humanMessages = total - agentMessages;
|
||||
const ownMessages = messages.filter((message) => normalizeSocialLabel(message.authorName) === ownToken).length;
|
||||
const longMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length >= 220).length;
|
||||
const shortMessages = messages.filter((message) => collapseWhitespace(message.content, 10_000).length <= 90).length;
|
||||
const averageCharacters = Math.round(
|
||||
messages.reduce((sum, message) => sum + collapseWhitespace(message.content, 10_000).length, 0) / total,
|
||||
);
|
||||
const speakerCount = new Set(
|
||||
messages.map((message) => normalizeSocialLabel(message.authorName || message.authorId)).filter(Boolean),
|
||||
).size;
|
||||
const texture =
|
||||
longMessages >= Math.ceil(total * 0.45) || averageCharacters >= 180
|
||||
? "heavy"
|
||||
: shortMessages >= Math.ceil(total * 0.55)
|
||||
? "light"
|
||||
: "mixed";
|
||||
|
||||
return {
|
||||
total,
|
||||
agentMessages,
|
||||
humanMessages,
|
||||
ownMessages,
|
||||
longMessages,
|
||||
shortMessages,
|
||||
averageCharacters,
|
||||
speakerCount,
|
||||
texture,
|
||||
agentShare: agentMessages / total,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRepoFaceSocialGraphFacts(
|
||||
identity: RepoDiscordIdentity,
|
||||
registryIdentities: RepoDiscordIdentity[],
|
||||
|
|
@ -2401,16 +2202,6 @@ function socialPressureLanguageKinds(content: string): string[] {
|
|||
return kinds;
|
||||
}
|
||||
|
||||
interface RepoFaceHumanPronounGuidance {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
guidance: string;
|
||||
resolvedPronounSet?: string;
|
||||
policy: string;
|
||||
confidence?: number;
|
||||
evidenceExcerpt?: string;
|
||||
}
|
||||
|
||||
async function loadRepoFaceHumanPronounGuidance(
|
||||
config: ReturnType<typeof loadConfig>,
|
||||
roomContext?: {
|
||||
|
|
@ -2497,28 +2288,6 @@ function pronounEvidenceRank(profile: InteractionMemoryProfile, entry: Interacti
|
|||
return resolvedSetBonus + (sourceRank[entry.source] ?? 0) + stanceBonus + confidenceBonus + recencyBonus;
|
||||
}
|
||||
|
||||
function renderRepoFaceHumanPronounFacts(
|
||||
guidance: RepoFaceHumanPronounGuidance[],
|
||||
): string | undefined {
|
||||
if (guidance.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
"Known human pronoun guidance:",
|
||||
...guidance.map((entry) =>
|
||||
[
|
||||
`- ${entry.actorName}: ${entry.guidance}`,
|
||||
entry.resolvedPronounSet ? `Resolved set: ${entry.resolvedPronounSet}.` : "",
|
||||
entry.policy ? `Policy: ${entry.policy}.` : "",
|
||||
typeof entry.confidence === "number" ? `Confidence: ${entry.confidence.toFixed(2)}.` : "",
|
||||
entry.evidenceExcerpt ? `Evidence: "${collapseWhitespace(entry.evidenceExcerpt, 180)}"` : "",
|
||||
].filter(Boolean).join(" "),
|
||||
),
|
||||
"Use this when referring to humans in social or relationship prose. If guidance is absent for someone, use their name or neutral phrasing rather than guessing.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function collectRepoFaceSocialRelations(
|
||||
state: VoidSelfStateTypedProjection,
|
||||
): Array<{ targetLabel: string; pressure: string; intensity: number }> {
|
||||
|
|
@ -3167,7 +2936,7 @@ function renderTurnSituationDirective(input: {
|
|||
);
|
||||
}
|
||||
|
||||
const roomStats = collectRepoFaceRoomTextureStats(input.identity, {
|
||||
const roomStats = observePersonaRoomTexture({ identity: input.identity,
|
||||
recentMessages: input.recentMessages,
|
||||
channelSnapshots: input.channelSnapshots,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import { readRepoActivity } from "../apps/persona-scheduler/dist/repo-activity-s
|
|||
import { readPersonaStateObservation } from "../apps/persona-scheduler/dist/persona-state-source.js";
|
||||
import { readPersonaMemoryRecall } from "../apps/persona-scheduler/dist/persona-memory-context-source.js";
|
||||
import { composePersonaMemoryPacket, projectPersonaMemorySurface, renderPersonaPressureSections, renderPersonaTypedStateSections } from "../apps/persona-scheduler/dist/persona-memory-projector.js";
|
||||
import { observePersonaRoomTexture, renderPersonaHumanPronounFacts, renderPersonaRoomWeather } from "../apps/persona-scheduler/dist/persona-social-context-projector.js";
|
||||
|
||||
function participant(identityId: string, nextTurnAt: number): InitiativeParticipant {
|
||||
return {
|
||||
|
|
@ -445,6 +446,14 @@ assert.deepEqual(await readPersonaMemoryRecall({
|
|||
status: "unavailable",
|
||||
reason: "No typed Persona state observation was supplied.",
|
||||
}, "semantic recall fails explicitly before touching vector infrastructure when the typed Mind observation is absent");
|
||||
const roomObservation = observePersonaRoomTexture({
|
||||
identity: routedIdentity,
|
||||
recentMessages: [{ id: "human-1", authorId: "human", authorName: "Operator", content: "hello", timestamp: "2026-07-15T21:00:00.000Z", isBot: false, channelId: "aquarium" }],
|
||||
channelSnapshots: [],
|
||||
});
|
||||
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");
|
||||
assert.equal(await projectPersonaMemorySurface({
|
||||
identityId: "nibu",
|
||||
characterIdentity: "Nibu",
|
||||
|
|
|
|||
Loading…
Reference in a new issue