diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index 15bfeea..1318252 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -2201,17 +2201,23 @@ async function renderRepoFaceSocialOpportunitySurface(input: { const typedState = await loadVoidSelfStateTypedDocuments({ canonicalPath: statePath }); const relations = collectRepoFaceSocialRelations(typedState); const unmappedPeers = collectUnmappedSocialPeers(input.identity, input.registryIdentities, relations); + const messages = collectSocialOpportunityMessages(input); + const unmappedPeerHooks = collectUnmappedPeerSocialHooks({ + identity: input.identity, + unmappedPeers, + messages, + }); if (relations.length === 0) { return [ "No existing person-bond or person-status read matched this turn.", "Use the transcript as evidence only when someone gives a real hook for trust, irritation, protection, rivalry, respect, suspicion, deference, or a careful question.", + ...formatUnmappedPeerHookLines(unmappedPeerHooks), unmappedPeers.length > 0 ? `Unmapped registered peers: ${formatUnmappedPeers(unmappedPeers)}. Do not greet the whole roster at once.` : "No peer roster was available for comparison this turn.", ].join("\n"); } - const messages = collectSocialOpportunityMessages(input); const hookedRelations = relations .map((relation) => ({ relation, @@ -2224,6 +2230,7 @@ async function renderRepoFaceSocialOpportunitySurface(input: { return [ "Existing person-bonds/status reads have no matching speaker in the attached recent messages.", "Treat the transcript itself as possible fresh social evidence, not as an obligation to invent a relationship.", + ...formatUnmappedPeerHookLines(unmappedPeerHooks), unmappedPeers.length > 0 ? `Still-unmapped peers: ${formatUnmappedPeers(unmappedPeers)}. If one of them appears in the transcript, there is evidence available for a possible first read.` : "No unmapped registered peers were found for this turn.", @@ -2239,6 +2246,7 @@ async function renderRepoFaceSocialOpportunitySurface(input: { } if (unmappedPeers.length > 0) { + lines.push(...formatUnmappedPeerHookLines(unmappedPeerHooks)); lines.push(`Unmapped registered peers: ${formatUnmappedPeers(unmappedPeers)}.`); } @@ -2324,6 +2332,59 @@ function formatUnmappedPeers(peers: RepoDiscordIdentity[]): string { return peers.map((peer) => `${peer.displayName}/${peer.repoName}`).join(", "); } +function collectUnmappedPeerSocialHooks(input: { + identity: RepoDiscordIdentity; + unmappedPeers: RepoDiscordIdentity[]; + messages: Array<{ label: string; message: SourceMessage }>; +}): Array<{ peer: RepoDiscordIdentity; hooks: Array<{ label: string; message: SourceMessage }> }> { + const selfTokens = new Set(socialTargetTokens(input.identity.displayName, input.identity.id, input.identity.repoName)); + const peersByToken = new Map(); + for (const peer of input.unmappedPeers) { + for (const token of socialTargetTokens(peer.displayName, peer.id, peer.repoName)) { + if (!selfTokens.has(token)) { + peersByToken.set(token, peer); + } + } + } + + const byPeer = new Map }>(); + for (const entry of input.messages) { + if (!entry.message.isBot || !entry.message.content.trim()) { + continue; + } + const peer = peersByToken.get(normalizeSocialLabel(entry.message.authorName)); + if (!peer) { + continue; + } + const bucket = byPeer.get(peer.id) ?? { peer, hooks: [] }; + bucket.hooks.push(entry); + byPeer.set(peer.id, bucket); + } + + return [...byPeer.values()] + .map((entry) => ({ + peer: entry.peer, + hooks: entry.hooks.slice(-2), + })) + .sort((left, right) => left.peer.displayName.localeCompare(right.peer.displayName)) + .slice(0, 5); +} + +function formatUnmappedPeerHookLines( + peerHooks: Array<{ peer: RepoDiscordIdentity; hooks: Array<{ label: string; message: SourceMessage }> }>, +): string[] { + if (peerHooks.length === 0) { + return []; + } + return [ + "Possible first-read hooks from unmapped peers who actually spoke nearby:", + ...peerHooks.map((entry) => + `- ${entry.peer.displayName}: ${entry.hooks.map(formatSocialOpportunityMessage).join(" | ")}`, + ), + "Use one of these only if it sparks a real first read, question, tease, alliance, irritation, or rivalry. Do not manufacture intimacy from a single quote.", + ]; +} + function collectSocialOpportunityMessages(input: { channelPlan: RepoFaceChannelPlan; recentMessages: SourceMessage[]; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 149ae6a..8f9991e 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -269,3 +269,4 @@ {"ts":"2026-05-22T14:09:25Z","type":"implementation","status":"ok","note":"Expanded repo Face state projection input and projector contract so volatile state is not crushed before the child sees it. The state packet now exposes all non-retired affect needs, bonds, status reads, mood dimensions, agency pressure, activation profile, runtime speaking/rest pressure, durable memories, short-term residue, incubations, candidate speech pressure, and recent speech residue in character-facing terms. Nibu projected memory grew from ~231 words to ~1094 words, and a mock dry run passed with a more specific witness-as-caste Aetheria turn."} {"ts":"2026-05-22T14:59:26Z","type":"implementation","status":"ok","note":"Repo Face state projection now imports existing interaction-memory pronoun guidance for visible humans and the owner profile, preventing projectors from guessing gender in relationship prose; Nibu projection smoke guards Metacrat she/her."} {"ts":"2026-05-22T15:48:15Z","type":"implementation","status":"ok","note":"Repo Face article publishing now crosses the parent Interpreter as an ARTICLE DSL block instead of the legacy VOIDBOT_REPO_IDENTITY_ARTICLE JSON sentinel. Deterministic worker code renders and validates YAML frontmatter, strips model-supplied body frontmatter, routes Aetheria articles to Aetheria/Articles and non-Aetheria blog posts to gamecult-site GameCult/Blog, and smoke coverage verifies the contract."} +{"ts":"2026-05-22T15:59:05Z","type":"implementation","status":"ok","note":"Repo Face social affordances now include recent message hooks from unmapped registered peers who actually spoke nearby, instead of only listing peer names. A Libby-only dry run after the change routed cleanly and improved from zero social signal to five, with the Face forming live reads about Aqua/Epiphany rather than only repeating CultLib work doctrine."}