Attach repo Face speech to conversation contexts

This commit is contained in:
Meta Vens 2026-06-14 18:42:33 +01:00
commit b76f6c54a7
9 changed files with 241 additions and 16 deletions

View file

@ -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<JobRecord["contextBundle"]["repoFaceConversationThreads"]>[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,

View file

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

View file

@ -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_<channel>_<message>` 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.

View file

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

View file

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

View file

@ -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: <context id>` 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.

View file

@ -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[];

View file

@ -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_<channel>_<message> 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."}

View file

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