diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 9e63e2e..3576d2d 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -938,12 +938,15 @@ function normalizeInterpretedRepoFaceSpeechDestinations( if (input.job.command !== "repo-face-rumination") { return outputText; } - if (repoFaceOutputHasExplicitSayChannel(input.faceOutputText)) { + if (repoFaceOutputHasExplicitSayDestination(outputText) || repoFaceOutputHasExplicitSayDestination(input.faceOutputText)) { return outputText; } if (!input.job.guildContext?.channelId) { return outputText; } + if (repoFaceHasMultipleActiveConversationChannels(input.job)) { + return outputText; + } return rewriteRepoFaceSayChannels( outputText, @@ -951,10 +954,22 @@ function normalizeInterpretedRepoFaceSpeechDestinations( ); } -function repoFaceOutputHasExplicitSayChannel(outputText: string): boolean { +function repoFaceHasMultipleActiveConversationChannels(job: JobRecord): boolean { + return repoFaceHasMultipleActiveConversationChannels(job); +} + +function repoFaceOutputHasExplicitSayDestination(outputText: string): boolean { return parseRepoFaceActionBlocks(outputText).some((block) => block.kind === "say" && - Boolean(optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId)) + Boolean( + optionalDslString(block.fields.channel) ?? + optionalDslString(block.fields.channelId) ?? + optionalDslString(block.fields.reply_to) ?? + optionalDslString(block.fields.replyToMessageId) ?? + optionalDslString(block.fields.context) ?? + optionalDslString(block.fields.context_id) ?? + optionalDslString(block.fields.contextId), + ) ); } @@ -1362,13 +1377,32 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn return false; } - const replyTargetChannelId = resolveRepoIdentityReplyTargetChannel(job, intent.replyToMessageId); + const conversationContext = resolveRepoIdentityConversationContext(job, intent.contextId); + if (intent.contextId && !conversationContext) { + console.warn( + `Rejected repo identity ${identity.id} speech for job ${job.id}: unknown conversation context "${intent.contextId}".`, + ); + return false; + } + if (repoIdentityRequiresExplicitConversationContext(job, intent) && !conversationContext) { + console.warn( + `Rejected repo identity ${identity.id} speech for job ${job.id}: multiple active conversation contexts require context, reply_to, or channel.`, + ); + return false; + } + + const effectiveReplyToMessageId = intent.replyToMessageId ?? conversationContext?.messageId; + const replyTargetChannelId = resolveRepoIdentityReplyTargetChannel(job, effectiveReplyToMessageId); if (replyTargetChannelId && intent.channelId && normalizeChannelSelector(intent.channelId) !== normalizeChannelSelector(replyTargetChannelId)) { console.warn( `Coerced repo identity ${identity.id} speech for job ${job.id} from requested channel "${intent.channelId}" to reply target channel ${replyTargetChannelId}.`, ); } - const requestedChannelId = replyTargetChannelId ?? intent.channelId ?? repoIdentityDefaultSpeechChannel(job); + const requestedChannelId = replyTargetChannelId + ?? conversationContext?.channelId + ?? intent.channelId + ?? repoIdentityConversationFocusChannel(job) + ?? repoIdentityDefaultSpeechChannel(job); const channelId = normalizeRepoIdentitySpeechChannel(identity, job, requestedChannelId); if (!channelId) { console.warn(`Rejected repo identity ${identity.id} speech for job ${job.id}: no Discord channel was available.`); @@ -1442,7 +1476,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn "--persona-name", identity.displayName, ...(identity.avatarUrl ? ["--persona-avatar-url", identity.avatarUrl] : []), - ...(intent.replyToMessageId ? ["--reply-to-message-id", intent.replyToMessageId] : []), + ...(effectiveReplyToMessageId ? ["--reply-to-message-id", effectiveReplyToMessageId] : []), ], { retries: 1 }); if (!posted.messageId || !posted.transport) { throw new Error(`Bifrost Discord bridge returned no message receipt for job ${job.id}.`); @@ -1451,7 +1485,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn identity, channelId, content, - replyToMessageId: intent.replyToMessageId, + replyToMessageId: effectiveReplyToMessageId, messageId: posted.messageId, transport: posted.transport, }).catch((error: unknown) => { @@ -1463,7 +1497,7 @@ async function postRepoIdentityIntent(job: JobRecord, intent: RepoIdentityPostIn identity, channelId, content, - replyToMessageId: intent.replyToMessageId, + replyToMessageId: effectiveReplyToMessageId, messageId: posted.messageId, }); console.log( @@ -3034,6 +3068,45 @@ function repoIdentityDefaultSpeechChannel(job: JobRecord): string | undefined { : job.outputChannelId; } +function repoIdentityConversationFocusChannel(job: JobRecord): string | undefined { + const focus = job.contextBundle.repoFaceConversationFocus; + if (!focus || focus.isCurrentRoom) { + return undefined; + } + + const nonCurrentThreads = (job.contextBundle.repoFaceConversationThreads ?? []) + .filter((thread) => !thread.isCurrentRoom); + return nonCurrentThreads.length <= 1 || focus.reason === "pending_mention" + ? focus.channelId + : undefined; +} + +function resolveRepoIdentityConversationContext( + job: JobRecord, + contextId: string | undefined, +): NonNullable[number] | undefined { + const normalized = contextId?.trim(); + if (!normalized) { + return undefined; + } + + return (job.contextBundle.repoFaceConversationThreads ?? []) + .find((thread) => thread.contextId === normalized); +} + +function repoIdentityRequiresExplicitConversationContext( + job: JobRecord, + intent: RepoIdentityPostIntent, +): boolean { + if (intent.channelId || intent.replyToMessageId || intent.contextId) { + return false; + } + + const activeThreads = job.contextBundle.repoFaceConversationThreads ?? []; + const representedChannels = new Set(activeThreads.map((thread) => thread.channelId)); + return representedChannels.size > 1; +} + function resolveRepoIdentityReplyTargetChannel( job: JobRecord, replyToMessageId: string | undefined, diff --git a/apps/worker/src/repo-face-speech.ts b/apps/worker/src/repo-face-speech.ts index ab9f07f..fdb1e49 100644 --- a/apps/worker/src/repo-face-speech.ts +++ b/apps/worker/src/repo-face-speech.ts @@ -2,6 +2,7 @@ export const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:"; export interface RepoIdentityPostIntent { identity?: string; + contextId?: string; channelId?: string; content: string; replyToMessageId?: string; @@ -29,6 +30,7 @@ export function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentit return [ { identity: optionalDslString(block.fields.identity), + contextId: optionalDslString(block.fields.context) ?? optionalDslString(block.fields.context_id) ?? optionalDslString(block.fields.contextId), channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId), replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId), content, @@ -51,6 +53,7 @@ export function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentit } intents.push({ identity: typeof parsed.identity === "string" ? parsed.identity.trim() : undefined, + contextId: typeof parsed.contextId === "string" ? parsed.contextId.trim() : undefined, channelId: typeof parsed.channelId === "string" ? parsed.channelId.trim() : undefined, replyToMessageId: typeof parsed.replyToMessageId === "string" ? parsed.replyToMessageId.trim() : undefined, diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 90826a3..c7c1e8e 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -174,6 +174,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Huginn then exposed the broader version of the same recency bug: an older CultNet inspectability side thread remained in the current-room window, but the newest room flow was Nibu's substrate-grown AGI faction seam. Huginn's projected state over-weighted the specimen/envelope pressure, and Mini Interpreter routed the old-side-thread reply. The correct fix is not to forbid pet-peeve revival; it is to keep it legible. Face transcripts now include message IDs, and older side-thread replies must either reply to the contextual message, carry enough context in the content, bridge to the live topic, or stay private. - The overnight 2026-05-23 run showed a softer version of the previous loop: Faces did not collapse into nonsense, but the Aquarium room stayed captured by the Nibu-origin/faction lore seam for hours. Existing `saturationScore` lived on private thought/incubation surfaces, and the repetition prompt mostly caught copied openings/endings, not semantic topic attractors. The heartbeat prompt now projects current-room topic saturation from repeated raw-transcript terms and estimates whether the attractor is home-adjacent or another steward's gravity well from each Face's identity/jurisdiction text. The state projector should turn foreign attractors into character-specific crowding, neglect, boredom, rivalry, handoff, or pivot pressure; the Interpreter diagnoses non-owner same-topic politeness as missing personal stake, ownership, freshness, handoff, or pivot rather than applying a topic ban or token blacklist. - Void's broader moderation authority had become venue leakage: recent-history projection stripped `channelId`, leaving the configured `publicSpeechTarget` as the obvious place for replies, so Aquarium-shaped talk could land in `#general`. The rumination context now preserves message `channelId` and projects `recentConversationTarget`; prompt doctrine says source-channel replies belong in that source channel, while `publicSpeechTarget` is for unanchored herald/artifact announcements. +- Repo Face speech routing now uses active conversation contexts instead of one room-focus guess. Heartbeat prompts project up to six `ctx__` records across current and nearby channels; the Interpreter can emit `context:` on a `SAY`; the worker resolves that context to the source channel/message and rejects unknown contexts or ambiguous bare SAY blocks when multiple active channels are visible. A Persona can carry separate #aquarium/#pics/#general conversations at once, but public speech has to keep its context attached. - The Nibu/Void turf-crossing discussion exposed a social-state projection gap: existing bonds/status reads project well once typed state already contains them, but the live pipeline did not own "this recent event should become relationship/status pressure." The repair adds bounded raw relationship-pressure evidence to the state packet before the projector: recent messages that touch the Face, peer Faces, existing social targets, jurisdiction/domain terms, or status/territory/consultation language. The projector may turn that evidence into tentative felt pressure, and the Interpreter may persist a bond/status/mood note only when the projected memory or Face output shows a character-specific reaction. This should let resentment, rivalry, consultation offense, and territorial tension emerge without manual state edits. - The swarm brake currently reads `state/agent-swarm-paused.json` with `"paused": false`. If scheduled Face turns start overrunning again, use that brake rather than letting the orchestrator churn. The known 2026-06-09 timeout source was the model state-projector child; local runtime currently keeps it disabled while the deterministic fallback and Interpreter/worker speech rails are watched. - Direct Face `UPDATE REQUEST` and `BIFROST TOPIC` packets are ignored while `REPO_FACE_BIFROST_ENABLED=false`. Do not debug missing Bifrost jobs as a live failure unless that flag has been deliberately enabled again. diff --git a/packages/core/src/context-builder.ts b/packages/core/src/context-builder.ts index e88e085..74df19f 100644 --- a/packages/core/src/context-builder.ts +++ b/packages/core/src/context-builder.ts @@ -4,6 +4,7 @@ import { type GuildContext, type InteractionMemoryProfile, type PromptImageAttachment, + type RepoFaceConversationFocus, type RetrievalResult, type SituationalSocialRead, type SourceGroundingHint, @@ -18,6 +19,8 @@ export interface BuildContextInput { guildContext: GuildContext; recentMessages?: SourceMessage[]; imageAttachments?: PromptImageAttachment[]; + repoFaceConversationFocus?: RepoFaceConversationFocus; + repoFaceConversationThreads?: RepoFaceConversationFocus[]; retrieval?: RetrievalResult[]; interactionMemory?: InteractionMemoryProfile; situationalSocialRead?: SituationalSocialRead; @@ -33,6 +36,8 @@ export class ContextBuilder { actor: input.actor, guildContext: input.guildContext, recentMessages: input.recentMessages ?? [], + repoFaceConversationFocus: input.repoFaceConversationFocus, + repoFaceConversationThreads: input.repoFaceConversationThreads, imageAttachments: input.imageAttachments, retrieval: input.retrieval ?? [], interactionMemory: input.interactionMemory, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index abeeee2..6a40e68 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -457,6 +457,8 @@ export interface ContextBundle { actor: Actor; guildContext: GuildContext; recentMessages: SourceMessage[]; + repoFaceConversationFocus?: RepoFaceConversationFocus; + repoFaceConversationThreads?: RepoFaceConversationFocus[]; imageAttachments?: PromptImageAttachment[]; retrieval: RetrievalResult[]; interactionMemory?: InteractionMemoryProfile; @@ -467,6 +469,18 @@ export interface ContextBundle { createdAt: string; } +export interface RepoFaceConversationFocus { + contextId?: string; + channelId: string; + channelLabel?: string; + messageId?: string; + authorName?: string; + timestamp?: string; + reason: "pending_mention" | "latest_human_message" | "latest_visible_message"; + isCurrentRoom: boolean; + hasMedia?: boolean; +} + export interface JobApprovalRecord { stage: "run" | "post"; status: "approved" | "rejected"; diff --git a/prompts/repo-face-turn-interpreter.prompt.md b/prompts/repo-face-turn-interpreter.prompt.md index 33b5371..6145341 100644 --- a/prompts/repo-face-turn-interpreter.prompt.md +++ b/prompts/repo-face-turn-interpreter.prompt.md @@ -107,8 +107,12 @@ Normalized output rules: - When a live room invitation asks for a small concrete thought and the Face's proposed line is safe but too broad, polished, or doctrine-shaped, you may compact it to the Face's own narrowest concrete claim and route that as SAY. Do not discard a sayable turn merely because a better edit exists. - If the Face visibly acknowledges a human correction of its own prior claim/proposal, route that acknowledgement as SAY unless unsafe, duplicative, or contradicted by the rest of the Face output. Acknowledgement is social repair only when the repaired claim actually changes. - Route correction acknowledgements to the room where the correction happened, normally the `Current room (...)` named in the Face prompt. Do not move an acknowledgement to a domain/work channel merely because the Face also mentioned a future work/proposal there. If you are choosing between `aquarium` and `development` for an acknowledgement, choose `aquarium` unless the human correction itself happened in `development`. -- Do not invent a speech venue. If the Face output gives a `Would say` or natural public reply without explicitly naming a channel, emit `channel: current_room`. Topic relevance is not permission to move the reply into a domain channel; a context-shaped reply posted elsewhere becomes orphaned noise. +- Do not invent a speech venue. If the Face output gives a `Would say` or natural public reply that belongs to the current room and does not explicitly name a channel, emit `channel: current_room`. If it continues a listed active context, emit that `context` instead. Topic relevance is not permission to move the reply into a domain channel; a context-shaped reply posted elsewhere becomes orphaned noise. - When routing a SAY that revives an older side thread, prefer `reply_to` with the visible message id that carries the context. If no clear anchor id exists, the content itself must name the context plainly enough for a reader landing on the message cold. +- If the Original Face prompt contains `Active conversation contexts`, a SAY that continues one of those listed threads must include `context: ` unless it already includes a more specific `reply_to` or explicit `channel`. +- If multiple active contexts are visible, do not collapse them into the current room. A Face may hold two conversations at once in different channels with different people; attach each public reply to the context it is continuing. +- Retry once when the Face output clearly responds to a listed context, image, message, person, or side conversation but the SAY you would route lacks `context`, `reply_to`, and explicit `channel`. The missing piece is context ownership, not wording. +- Prefer `context` over guessing a channel label when the prompt gives an active context id. If the chosen context includes a source message, the worker can use that source message as the reply target; still include `reply_to` when the Face is intentionally replying to a different visible anchor. - Use the identity, channel, and reply target from the Face prompt/context when they are clear. - Do not choose an owner/private/DM channel unless the Face prompt explicitly says this turn is an owner-private direct-message turn. Normal check-ins, warnings, governance anxieties, consensus questions, and room replies belong in the current room, Aquarium, or a configured domain channel. - Preserve the Face's voice in SAY content. @@ -175,7 +179,8 @@ END SAY identity: current_face_id -channel: channel_id +context: conversation_context_id_or_blank +channel: channel_id_or_blank reply_to: message_id_or_blank content: In-character Discord message only. No job label, no report header. diff --git a/scripts/run-repo-face-heartbeats.ts b/scripts/run-repo-face-heartbeats.ts index a1ea640..13801f8 100644 --- a/scripts/run-repo-face-heartbeats.ts +++ b/scripts/run-repo-face-heartbeats.ts @@ -36,6 +36,7 @@ import { loadPromptTemplate, type InteractionMemoryProfile, type PromptImageAttachment, + type RepoFaceConversationFocus, type RetrievalResult, type SourceMessage, type SourceMessageAttachment, @@ -574,6 +575,13 @@ async function queueRepoFaceTurn(input: { recentMessages, ...channelSnapshots.map((snapshot) => snapshot.messages), ].flat()); + const repoFaceConversationThreads = buildRepoFaceConversationThreads({ + channelPlan, + recentMessages, + channelSnapshots, + pendingMentions: input.pendingMentions, + }); + const repoFaceConversationFocus = repoFaceConversationThreads[0]; const contextBundle = contextBuilder.build({ prompt, actor: { @@ -586,6 +594,8 @@ async function queueRepoFaceTurn(input: { channelId, }, recentMessages, + repoFaceConversationFocus, + repoFaceConversationThreads, imageAttachments, retrieval: [], voidSelfState: undefined, @@ -4361,15 +4371,20 @@ function renderRepoFaceConversationTranscript(input: { channelPlan: RepoFaceChannelPlan; }): string { const sections: string[] = []; + const threads = buildRepoFaceConversationThreads(input); + const focus = threads[0]; sections.push([ "Read this as raw recent message evidence, not as a summary and not as consensus.", "Messages are ordered oldest to newest inside each section. Newer human corrections can supersede older agent proposals.", "Use the visible cross-channel chronology below to decide whether a correction is still unresolved or was already answered later by the same Face.", "Do not infer consensus from agents repeating each other. If a human reframes, narrows, or corrects an agent's proposal, account for that correction directly.", - "If you answer the live conversation, default to the current room unless a human explicitly asks to move elsewhere.", - "If you answer or riff on a nearby-room message, set channel to that message's listed channel id and usually set reply_to to that message id. Never answer a nearby-room post in the current room just because the current room is easier to speak in.", + "If you answer the live conversation, keep the conversation context attached. A Face can carry different conversations in different channels at once.", + "If you answer or riff on a nearby-room message, use that message's active context id or set channel to that message's listed channel id and usually set reply_to to that message id. If the nearby message is media, a public reaction belongs in that media source channel unless a human explicitly moved the topic. Never answer a nearby-room post in the current room just because the current room is easier to speak in.", "Message IDs are shown so a public reply can target the message that gives it context. If you revive an older side thread, either reply_to that message id or include enough context in your message for readers to know what you mean.", ].join("\n")); + if (focus) { + sections.push(renderRepoFaceConversationFocus(focus, threads)); + } const chronology = renderVisibleConversationChronology(input); if (chronology) { sections.push(chronology); @@ -4400,6 +4415,111 @@ function renderRepoFaceConversationTranscript(input: { return sections.join("\n\n"); } +function buildRepoFaceConversationThreads(input: { + channelPlan: RepoFaceChannelPlan; + recentMessages: SourceMessage[]; + channelSnapshots: ChannelSnapshot[]; + pendingMentions: RepoFacePendingMention[]; +}): RepoFaceConversationFocus[] { + const primaryChannelId = input.channelPlan.primaryChannelId; + const channelLabel = (channelId: string | undefined): string | undefined => + input.channelPlan.options.find((option) => option.channelId === channelId)?.label ?? channelId; + const newestMention = input.pendingMentions + .slice() + .sort((left, right) => Date.parse(right.queuedAt) - Date.parse(left.queuedAt)) + [0]; + const threads: RepoFaceConversationFocus[] = []; + if (newestMention) { + threads.push({ + contextId: repoFaceConversationContextId(newestMention.channelId, newestMention.messageId), + channelId: newestMention.channelId, + channelLabel: channelLabel(newestMention.channelId), + messageId: newestMention.messageId, + authorName: newestMention.authorName, + timestamp: newestMention.queuedAt, + reason: "pending_mention", + isCurrentRoom: newestMention.channelId === primaryChannelId, + }); + } + + const visible = [ + ...input.recentMessages.map((message) => ({ + message, + channelId: primaryChannelId, + label: channelLabel(primaryChannelId), + })), + ...input.channelSnapshots.flatMap((snapshot) => + snapshot.messages.map((message) => ({ + message, + channelId: snapshot.channelId, + label: channelLabel(snapshot.channelId), + })), + ), + ] + .filter((entry): entry is { message: SourceMessage; channelId: string; label?: string } => + Boolean(entry.channelId) && Number.isFinite(Date.parse(entry.message.timestamp)), + ) + .sort((left, right) => Date.parse(right.message.timestamp) - Date.parse(left.message.timestamp)); + + const byChannel = new Set(threads.map((thread) => thread.channelId)); + for (const entry of visible) { + if (byChannel.has(entry.channelId)) { + continue; + } + if (entry.message.isBot && (entry.message.attachments ?? []).length === 0) { + continue; + } + byChannel.add(entry.channelId); + threads.push({ + contextId: repoFaceConversationContextId(entry.channelId, entry.message.id), + channelId: entry.channelId, + channelLabel: entry.label, + messageId: entry.message.id, + authorName: entry.message.authorName, + timestamp: entry.message.timestamp, + reason: entry.message.isBot ? "latest_visible_message" : "latest_human_message", + isCurrentRoom: entry.channelId === primaryChannelId, + hasMedia: (entry.message.attachments ?? []).length > 0, + }); + if (threads.length >= 6) { + break; + } + } + + return threads; +} + +function repoFaceConversationContextId(channelId: string, messageId?: string): string { + return `ctx_${channelId}_${messageId ?? "latest"}`; +} + +function renderRepoFaceConversationFocus( + focus: RepoFaceConversationFocus, + threads: RepoFaceConversationFocus[], +): string { + return [ + "Active conversation contexts:", + ...threads.map((thread) => + [ + `- ${thread.contextId}: ${thread.channelLabel ?? thread.channelId} (${thread.channelId})`, + thread.messageId ? `message ${thread.messageId}` : "", + thread.authorName ? `from ${thread.authorName}` : "", + thread.reason, + thread.hasMedia ? "media" : "", + thread.isCurrentRoom ? "current room" : "nearby room", + ].filter(Boolean).join("; "), + ), + "", + `Selected default context: ${focus.contextId ?? "(none)"}.`, + `- Source channel: ${focus.channelLabel ?? focus.channelId} (${focus.channelId}).`, + focus.messageId ? `- Source message: ${focus.messageId}${focus.authorName ? ` from ${focus.authorName}` : ""}.` : "", + focus.timestamp ? `- Source timestamp: ${focus.timestamp}.` : "", + `- Reason: ${focus.reason}${focus.hasMedia ? "; media-bearing message" : ""}.`, + "- If you speak from a listed context, set context to its context id. The worker will use that context as the channel/reply target for the SAY.", + "- If you are carrying more than one conversation at once, choose the context that your SAY is continuing. Do not collapse #pics, #general, and #aquarium into one room just because they are all visible.", + ].filter(Boolean).join("\n"); +} + function renderVisibleConversationChronology(input: { recentMessages: SourceMessage[]; channelSnapshots: ChannelSnapshot[]; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index efd7089..be9ee7d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -363,3 +363,4 @@ {"ts":"2026-06-14T12:06:49Z","type":"implementation","status":"ok","note":"Added outgoing curiosity openings for repo Face heartbeats: when a jurisdiction dive is due and room texture is not heavy/agent-filled, the turn is classified as autonomous Eyes/Imagination presence, requires source/history/Odin exploration before checked/found claims, and expects one compact unprompted SAY when socially legible. Interpreter now retries generic private restraint on those turns instead of laundering them into state-only rumination."} {"ts":"2026-06-14T12:26:21Z","type":"implementation","status":"ok","note":"Made repo Face self-maintenance grievances public-first: prompt projection now surfaces Persona/system substrate, tools, voice/avatar/access, state-projection, semantic-memory, and agency-path pressure under a Self-maintenance public advocacy section; Face and Interpreter prompts now require a compact public-safe SAY for body/mind/substrate asks instead of burying them as private state. Detector was narrowed after prompt verification so sensitive human body/home trauma does not get auto-promoted by this machinery."} {"ts":"2026-06-14T12:43:35Z","type":"implementation","status":"ok","note":"Fixed repo Face cross-channel reply routing: prompt transcripts now include source channel ids for current, nearby, and chronology messages; Face prompts explicitly forbid answering nearby-room posts in the current room; worker post routing now derives the destination channel from reply_to message ids and also infers a non-current room when a bare Face output names that room label (e.g. general thread). Verified against Libby prompt showing Metacrat general messages followed by the bad Aquarium post."} +{"ts":"2026-06-14T17:40:23Z","type":"implementation","status":"ok","note":"Repo Face speech routing now carries typed active conversation contexts across current and nearby Discord channels. Heartbeat prompts emit ctx__ records; SAY can include context; worker resolves context to source channel/reply target and rejects unknown or ambiguous unqualified SAY when multiple channels are active."} diff --git a/state/map.yaml b/state/map.yaml index 9c35ecd..e203b2f 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -18,10 +18,13 @@ objective: restart, with Bifrost work routing paused because the governance bridge proved too disorienting for live social traffic. The immediate priority is to observe the Face/Interpreter/speech boundary with live evidence: inherited - global doctrine should appear in Face prompts, live-room conversation should - arrive as raw transcript evidence, typed social state should become live - person-hooks when recent speakers match, and topic gravity should produce - character-specific pressure instead of another swarm obsession loop. + global doctrine should appear in Face prompts, live-room and nearby-channel + conversations should arrive as raw transcript evidence with active context + ids, typed social state should become live person-hooks when recent speakers + match, and topic gravity should produce character-specific pressure instead + of another swarm obsession loop. A repo Face may hold multiple simultaneous + Discord conversations; `context`, `reply_to`, or explicit channel owns post + placement, not the scheduled output room. constraints: - Public traffic still rides one remote Qwen box plus local embedding and ops infrastructure.