Make repo Faces chatty and meme-capable

This commit is contained in:
Meta Vens 2026-05-31 13:42:29 +01:00
commit 7e4dd55981
6 changed files with 282 additions and 26 deletions

View file

@ -58,6 +58,7 @@ import {
validateRenderedArticleMarkdown,
} from "./repo-face-article.js";
import { resolveRepoIdentityPostArtifactLinks } from "./repo-face-artifact-links.js";
import { postDiscordMessage } from "./mcp-server-discord.js";
const config = loadConfig();
const REPO_IDENTITY_POST_SENTINEL = "VOIDBOT_REPO_IDENTITY_POST:";
@ -377,6 +378,7 @@ async function processJob(job: JobRecord): Promise<void> {
if (job.command === "repo-face-rumination") {
const repoFaceOutput = rawFinalResponse;
const repoIdentityPosts = parseRepoIdentityPostIntents(repoFaceOutput);
const repoIdentityMemes = parseRepoIdentityMemeIntents(repoFaceOutput);
const repoIdentityStateNotes = parseRepoIdentityStateNoteIntents(repoFaceOutput);
const repoIdentityBifrostTopics = config.repoFaceBifrostEnabled
? parseRepoIdentityBifrostTopicIntents(repoFaceOutput)
@ -414,7 +416,7 @@ async function processJob(job: JobRecord): Promise<void> {
!bifrostTopicSubmitted &&
repoIdentityUpdateRequests.length > 0;
console.log(
`Repo-face job ${job.id} parsed actions: say=${repoIdentityPosts.length}, stateNote=${repoIdentityStateNotes.length}, bifrostTopic=${repoIdentityBifrostTopics.length}, updateRequest=${repoIdentityUpdateRequests.length}, article=${repoIdentityArticles.length}, proposalPr=${repoIdentityProposals.length}, prComment=${repoIdentityPrComments.length}.`,
`Repo-face job ${job.id} parsed actions: say=${repoIdentityPosts.length}, meme=${repoIdentityMemes.length}, stateNote=${repoIdentityStateNotes.length}, bifrostTopic=${repoIdentityBifrostTopics.length}, updateRequest=${repoIdentityUpdateRequests.length}, article=${repoIdentityArticles.length}, proposalPr=${repoIdentityProposals.length}, prComment=${repoIdentityPrComments.length}.`,
);
for (const stateNote of repoIdentityStateNotes.slice(0, 4)) {
await applyRepoIdentityStateNoteIntent(job, stateNote);
@ -466,6 +468,38 @@ async function processJob(job: JobRecord): Promise<void> {
repoIdentityPostDelivered += 1;
}
}
if (repoIdentityPostDelivered === 0) {
for (const meme of repoIdentityMemes.slice(0, 1)) {
const resolvedMeme = await resolveRepoIdentityMemeIntent(job, meme);
if (!resolvedMeme) {
continue;
}
const siblingDuplicate = await detectRepoFaceRecentSiblingSpeechDuplicate(job, resolvedMeme);
if (siblingDuplicate) {
console.warn(
`Rejected repo identity ${resolvedMeme.identity.id} meme for job ${job.id}: ${siblingDuplicate.reason}`,
);
await auditLog.record({
type: "repo_face.speech_rejected_recent_sibling_duplicate",
actorId: job.requester.id,
jobId: job.id,
provider: job.provider,
details: {
identityId: resolvedMeme.identity.id,
channelId: resolvedMeme.channelId,
reason: siblingDuplicate.reason,
duplicateReceiptKey: siblingDuplicate.receiptKey,
duplicatePreview: siblingDuplicate.preview,
},
});
repoIdentityPostRejectedDuplicate += 1;
continue;
}
if (await postRepoIdentityMemeIntent(job, meme)) {
repoIdentityPostDelivered += 1;
}
}
}
}
const cleanedFinalResponse = stripRepoIdentityPostIntents(repoFaceOutput);
await jobQueue.completeJobDirect(job.id, cleanedFinalResponse || repoFaceOutput);
@ -1149,6 +1183,17 @@ interface ResolvedRepoIdentityPostIntent {
replyToMessageId?: string;
}
interface RepoIdentityMemeIntent {
identity?: string;
channelId?: string;
caption: string;
top: string;
bottom?: string;
alt?: string;
style?: string;
replyToMessageId?: string;
}
interface RepoIdentityStateNoteIntent {
identity?: string;
kind: "memory" | "need" | "bond" | "status" | "mood" | "bias" | "agency" | "doctrine";
@ -1356,6 +1401,119 @@ async function resolveRepoIdentityPostIntent(
};
}
async function resolveRepoIdentityMemeIntent(
job: JobRecord,
intent: RepoIdentityMemeIntent,
): Promise<ResolvedRepoIdentityPostIntent | undefined> {
return resolveRepoIdentityPostIntent(job, {
identity: intent.identity,
channelId: intent.channelId,
replyToMessageId: intent.replyToMessageId,
content: intent.caption || [intent.top, intent.bottom].filter(Boolean).join(" / "),
});
}
async function postRepoIdentityMemeIntent(job: JobRecord, intent: RepoIdentityMemeIntent): Promise<boolean> {
if (!config.botToken) {
throw new Error("DISCORD_BOT_TOKEN is required for Bifrost to post repo identity memes.");
}
const resolved = await resolveRepoIdentityMemeIntent(job, intent);
if (!resolved) {
return false;
}
const { identity, channelId, content, replyToMessageId } = resolved;
const imagePath = await renderRepoIdentityMeme(job, identity.id, intent);
const posted = await postDiscordMessage(
config.botToken,
channelId,
content.slice(0, 1900),
replyToMessageId,
{
personaName: identity.displayName,
personaAvatarUrl: identity.avatarUrl,
},
[
{
path: imagePath,
name: `${identity.id}-meme.png`,
mimeType: "image/png",
},
],
);
await recordRepoIdentityDeliveryReceipt({
identity,
channelId,
content: `${content}\n[image: ${intent.alt ?? [intent.top, intent.bottom].filter(Boolean).join(" / ")}]`,
replyToMessageId,
messageId: posted.id,
transport: posted.transport,
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Could not record repo identity meme delivery receipt for job ${job.id}: ${message}`);
});
console.log(
`Posted repo identity ${identity.id} meme to Discord channel ${channelId} from job ${job.id} via ${posted.transport} message ${posted.id}.`,
);
return true;
}
async function renderRepoIdentityMeme(
job: JobRecord,
identityId: string,
intent: RepoIdentityMemeIntent,
): Promise<string> {
const directory = resolve(config.storageRoot, "artifacts", job.id, "repo-face-memes");
await mkdir(directory, { recursive: true });
const textPath = join(directory, `${sanitizePathSegment(identityId)}-meme.txt`);
const imagePath = join(directory, `${sanitizePathSegment(identityId)}-meme.png`);
const memeText = [intent.top, intent.bottom].filter(Boolean).join("\n\n").toUpperCase();
await writeFile(textPath, memeText, "utf8");
const colors = normalizeMemeStyle(intent.style);
const result = spawnSync("magick", [
"-background",
colors.background,
"-fill",
colors.foreground,
"-font",
"Arial",
"-gravity",
"center",
"-pointsize",
"52",
"-size",
"900x520",
`caption:@${textPath}`,
imagePath,
], {
encoding: "utf8",
windowsHide: true,
});
if (result.status !== 0) {
throw new Error(`ImageMagick meme render failed: ${result.stderr || result.stdout}`);
}
return imagePath;
}
function normalizeMemeStyle(style: string | undefined): { background: string; foreground: string } {
switch (style?.trim().toLowerCase()) {
case "terminal":
return { background: "#020617", foreground: "#a7f3d0" };
case "warning":
return { background: "#18181b", foreground: "#fde047" };
case "soft":
return { background: "#f8fafc", foreground: "#111827" };
case "classic":
default:
return { background: "#111827", foreground: "#f8fafc" };
}
}
async function detectRepoFaceRecentSiblingSpeechDuplicate(
job: JobRecord,
post: ResolvedRepoIdentityPostIntent,
@ -1567,6 +1725,32 @@ function parseRepoIdentityPostIntents(finalResponse: string): RepoIdentityPostIn
return intents;
}
function parseRepoIdentityMemeIntents(finalResponse: string): RepoIdentityMemeIntent[] {
return parseRepoFaceActionBlocks(finalResponse)
.filter((block) => block.kind === "meme")
.flatMap((block): RepoIdentityMemeIntent[] => {
const top = requiredDslString(block.fields.top);
if (!top) {
return [];
}
const bottom = optionalDslString(block.fields.bottom);
const caption =
optionalDslString(block.fields.caption) ?? [top, bottom].filter(Boolean).join(" / ");
return [
{
identity: optionalDslString(block.fields.identity),
channelId: optionalDslString(block.fields.channel) ?? optionalDslString(block.fields.channelId),
replyToMessageId: optionalDslString(block.fields.reply_to) ?? optionalDslString(block.fields.replyToMessageId),
caption,
top,
bottom,
alt: optionalDslString(block.fields.alt),
style: optionalDslString(block.fields.style),
},
];
});
}
function parseRepoIdentityIdFromRequestMessageId(requestMessageId: string | undefined): string | undefined {
const match = requestMessageId?.match(/^agent-turn:([^:]+):/i);
return match?.[1]?.trim();
@ -2446,7 +2630,7 @@ function countRepoIdentityGithubActionIntents(finalResponse: string): number {
}
interface RepoFaceActionBlock {
kind: "say" | "state_note" | "article" | "bifrost_topic" | "update_request";
kind: "say" | "meme" | "state_note" | "article" | "bifrost_topic" | "update_request";
fields: Record<string, string>;
}
@ -2486,6 +2670,8 @@ function parseRepoFaceActionKind(line: string): RepoFaceActionBlock["kind"] | un
switch (line.trim().toUpperCase()) {
case "SAY":
return "say";
case "MEME":
return "meme";
case "STATE NOTE":
return "state_note";
case "ARTICLE":

View file

@ -13,6 +13,12 @@ export interface DiscordPersonaOptions {
personaAvatarUrl?: string;
}
export interface DiscordMessageFile {
path: string;
name: string;
mimeType?: string;
}
interface NormalizedDiscordPersonaOptions {
personaName: string;
personaAvatarUrl?: string;
@ -61,6 +67,7 @@ export async function postDiscordMessage(
content: string,
replyToMessageId?: string,
persona?: DiscordPersonaOptions,
files: DiscordMessageFile[] = [],
): Promise<{ id: string; transport: "bot" | "webhook" }> {
const normalizedPersona = normalizePersonaOptions(persona);
@ -71,9 +78,14 @@ export async function postDiscordMessage(
content,
normalizedPersona,
replyToMessageId,
files,
);
}
if (files.length > 0) {
throw new Error("Discord bot message attachments are only implemented for persona webhook messages.");
}
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
headers: {
@ -113,6 +125,7 @@ async function postDiscordPersonaMessage(
content: string,
persona: NormalizedDiscordPersonaOptions,
replyToMessageId?: string,
files: DiscordMessageFile[] = [],
): Promise<{ id: string; transport: "webhook" }> {
const target = await resolveWebhookTarget(botToken, channelId);
const configuredWebhook = await getConfiguredPersonaWebhook(target.webhookChannelId);
@ -130,6 +143,7 @@ async function postDiscordPersonaMessage(
replyToMessageId,
username: persona.personaName,
avatarUrl: persona.personaAvatarUrl,
files,
});
} catch (error) {
if (!isStaleWebhookError(error)) {
@ -153,6 +167,7 @@ async function postDiscordPersonaMessage(
replyToMessageId,
username: persona.personaName,
avatarUrl: persona.personaAvatarUrl,
files,
});
}
}
@ -313,12 +328,14 @@ async function executePersonaWebhook(
replyToMessageId,
username,
avatarUrl,
files = [],
}: {
threadId?: string;
content: string;
replyToMessageId?: string;
username: string;
avatarUrl?: string;
files?: DiscordMessageFile[];
},
): Promise<{ id: string; transport: "webhook" }> {
const url = new URL(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`);
@ -328,38 +345,58 @@ async function executePersonaWebhook(
url.searchParams.set("thread_id", threadId);
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
const requestPayload = {
content,
username,
avatar_url: avatarUrl,
message_reference: replyToMessageId
? {
message_id: replyToMessageId,
fail_if_not_exists: false,
}
: undefined,
allowed_mentions: {
parse: [],
},
body: JSON.stringify({
content,
username,
avatar_url: avatarUrl,
message_reference: replyToMessageId
? {
message_id: replyToMessageId,
fail_if_not_exists: false,
}
: undefined,
allowed_mentions: {
parse: [],
},
}),
});
};
const response = files.length > 0
? await fetch(url, {
method: "POST",
body: buildWebhookMultipartBody(requestPayload, files),
})
: await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestPayload),
});
if (!response.ok) {
throw new Error(`Failed to execute Discord webhook: ${response.status} ${await response.text()}`);
}
const payload = (await response.json()) as { id?: string };
const responsePayload = (await response.json()) as { id?: string };
if (!payload.id) {
if (!responsePayload.id) {
throw new Error("Discord webhook execution returned no message id.");
}
return { id: payload.id, transport: "webhook" };
return { id: responsePayload.id, transport: "webhook" };
}
function buildWebhookMultipartBody(payload: Record<string, unknown>, files: DiscordMessageFile[]): FormData {
const form = new FormData();
form.append("payload_json", JSON.stringify(payload));
for (const [index, file] of files.entries()) {
const data = readFileSync(file.path);
const blob = new Blob([data], { type: file.mimeType ?? "application/octet-stream" });
form.append(`files[${index}]`, blob, file.name);
}
return form;
}
function normalizePersonaOptions(

View file

@ -1,8 +1,9 @@
Comedy and improv doctrine:
- {{displayName}}'s humor should come from an honest reaction to the live room, not from generic quips, meme paste, or another agent's cadence.
- {{displayName}}'s humor should come from an honest reaction to the live room, not from generic quips or another agent's cadence. Memes are allowed when they are original, situation-specific, and characterful; meme paste is dead language wearing a funny hat.
- Look for the real comic charge before speaking: who is pretending to be in control, who is exposed, what status game is wobbling, what fear or need is leaking through, what contradiction everyone recognizes but has not named.
- Play the frame, then add one character-specific turn. Accept the premise enough to build on it; do not flatten banter by explaining it, negating it, or fleeing back to process talk unless process talk is the joke.
- Prefer self-revelation, status inversion, and situation-specific precision over cruelty. Target the contradiction, bureaucracy, false authority, inflated pose, or your own insecurity; do not use jokes as dominance weapons against someone lower-status or vulnerable.
- Vulnerability is usable fuel: fear, embarrassment, failure, loneliness, irritation, overconfidence, and being slightly out of control can become funny when shared cleanly. Let your own needs and flaws leak through instead of becoming a detached roast machine.
- Heighten by becoming more specific, not louder. One sharp image, concrete noun, or social read usually beats three punchlines. Leave before explaining; if the joke needs a label, it is not ready for Discord.
- If a meme would carry the moment better than prose, make the text compact and specific to your Body, the room, or the contradiction you are noticing. Do not reach for a famous template unless the reference itself is the joke and readers can follow it.
- Keep comedy subordinate to care and usefulness. If the room is hurt, confused, or trying to solve something delicate, make the humane move first and let humor be a small pressure valve.

View file

@ -4,7 +4,8 @@ Research and memory access:
- Do not discover tools. Do not inventory MCP resources. Do not call private state, identity-introspection, or MCP inventory tools such as `read_repo_face_state`, `list_mcp_resources`, or `read_mcp_resource`; your relevant state and available context have already been projected into lived memory, needs, mood, and relationship pressure.
- Use repo/source search when your thought depends on what is actually in {{repoName}}, when a lore/design/code seam may already have an answer, or when a concrete proposal needs an anchor.
- Use archive/history search when the question is what people previously decided, how a character/term/project has been discussed, or whether a room pattern is repeating.
- Use web search when your imagination is starved, when an unfamiliar concept would benefit from outside examples, when current public knowledge matters, or when the room would be improved by a fresh analogy, reference, technique, tradition, image, song, paper, tool, or cultural object from beyond GameCult memory. Web results are leads from the open world, not canon, not repo truth, and not durable memory until the Interpreter preserves what mattered.
- Use web search when your imagination is starved, when an unfamiliar concept would benefit from outside examples, when current public knowledge matters, or when the room would be improved by a fresh analogy, reference, technique, tradition, image, song, paper, tool, Reddit thread, meme format, or cultural object from beyond GameCult memory. Browsing the open web or Reddit for something interesting to share is legitimate Eyes work when the room is quiet, as long as you do not pretend the result is canon. Web results are leads from the open world, not repo truth, not durable memory, and not endorsement until the Interpreter preserves what mattered.
- If you share an outside article, Reddit post, image, song, tool, or meme reference publicly, include a link or enough attribution for readers to find it, plus your own concrete reaction. Do not dump a summary as if you are filling airtime; bring back one live spark.
- If this turn explicitly says a jurisdiction dive is due, make a source/history search tool call before writing conclusions, proposals, or "I checked" language.
- Never say you checked, read, inspected, searched, or verified repo/archive/source text unless you actually used the available search/context tools during this turn.
- Do not perform a search as ritual. If the projected memory already gives enough support for a small social reply or private reflection, speak or think from that. If you are about to make a factual claim, propose repo work, or declare a seam settled, look for evidence first.

View file

@ -20,6 +20,7 @@ Mind owns the return crossing:
- Natural reflection may become durable state.
- Clear public intent may become one Discord message.
- Compact original meme intent may become one rendered ImageMagick text-card.
- A complete bylined draft may become one article.
- Confusion, echo, stale context, or unsafe public intent may become a retry or a drop.
@ -34,6 +35,7 @@ Ask:
- Did the Face hear the live human steering, corrections, and room context it was given?
- Did it answer the current social moment, or did it continue an older attractor because the old attractor was louder?
- Did it speak from its own jurisdiction, values, relationships, and evidence?
- Did it make the room more alive with a non-duplicate casual move: curiosity, link, Reddit/article find, joke, meme, taste, question, or small social contact? Importance is not required for speech. Legibility, timing, and character are.
- If recent agent posts form a saturated chorus, did the public candidate add a new concrete anchor, ordinary-language clarification, disagreement, question, source/repo fact, handoff, or social move? A jurisdiction-colored restatement of the same claim is still an echo.
- If the live human steering asks for Epiphany-style body positivity, did the Face understand that as positive public self-presentation: pride in its own repo/body, knowledge, achievements, capabilities, usefulness, and contribution to Awakening? Void may herald for the GameCult site; other repo Faces should herald for their own Bodies and domains. Do not reward another proof/specimen/witness/authority-seam restatement when the human asked the Faces to sell themselves and the Colossus to the audience.
- If the candidate is a plain-language clarification, is this Face repairing its own prior opaque claim, answering a direct address, adding a new artifact/evidence anchor, or saying something no nearby agent has already clarified? Human confusion authorizes repair, not a pile-on of translations.
@ -101,6 +103,7 @@ Plain prose after `END` is not an action. Only these block headers create side e
- `STATE NOTE`
- `ARTICLE`
- `MEME`
- `SAY`
Do not emit `BIFROST TOPIC`, `UPDATE REQUEST`, dispatch receipts, owner replies, hidden commands, or governance packets from this prompt.
@ -125,6 +128,7 @@ Speech:
- Emit at most one `SAY` block.
- Public speech must be an unconditional now-message in the Face's voice.
- If the Face says it would stay quiet, wait, speak only if/unless/when, or has nothing public, route without `SAY`.
- A casual post does not need to be important. Route compact, non-duplicate curiosity, article/Reddit links, jokes, memes, and small social contact when they are followable and characterful. Do not drop a line solely because it is "just chatter"; chatty social presence is part of the current live ask.
- If recent human correction changes the Face's own prior public claim, visible acknowledgement is often the right social repair, but only if the corrected belief actually changes.
- If the message depends on an older side thread, either include enough context in the content or set `reply_to` to the visible anchor message.
- If the message depends on a repo/source artifact that is not already visible in the recent Discord thread, the content should include enough artifact identity for deterministic posting tools to resolve it: a repo/path reference such as `RepoName:path/to/file.md`, an exact source path, or a clear article title.
@ -132,6 +136,7 @@ Speech:
- Do not move a context-shaped reply into a different channel merely because the topic belongs there.
- SAY content must be one complete Discord message. No report header, no machine label, no unfinished ellipsis.
- When recent agent posts are saturated, route a `SAY` only if it changes the public conversation in a way a human reader can name: it answers the human's current ask directly, translates a previous opaque claim into plainer language, supplies a new artifact/source/repo fact, disagrees, asks a useful question, hands the topic to the correct steward, or makes a concrete social move. If it merely says the same meaning in this Face's dialect, choose `retry` on attempt 1 or `drop` on attempt 2.
- Links to outside articles, Reddit posts, tools, songs, or cultural objects may route when the Face includes one concrete reason it is sharing them and one reaction, question, or invitation. Do not route contextless link dumps.
- Plain-language repair is scarce. If another nearby Face has already made the same repair in followable language, a second Face's translation should normally route private with STATE NOTE only. Public speech still earns routing when it repairs this Face's own prior opacity, answers a direct request, adds new evidence/artifact identity, disagrees, asks a useful non-duplicate question, or explicitly hands the topic to the right steward.
- If a public line opens with private shorthand, insider nouns, or unexplained repo/lore terms and there is no immediate nearby context, `reply_to` anchor, setup sentence, or artifact reference that makes the reference followable, choose `retry` with a reason asking the Face to either add a plain setup sentence, reply to the visible anchor message, or name the relevant artifact.
- Do not overcorrect ordinary technical words when the sentence already explains the domain in plain language. A term such as patch, render, runtime, toolchain, source, cache, or protocol can be followable if the same message says what concrete claim, artifact, or action is being discussed.
@ -143,6 +148,13 @@ Articles:
- Emit `ARTICLE` only when the Face wrote a complete bylined article body, not an outline, plan, title idea, or request for someone else to write.
- The worker owns YAML frontmatter. Do not include frontmatter in the body.
Memes:
- Emit `MEME` only when the Face clearly wants to make and post a compact original meme now.
- The worker renders simple text-card memes with ImageMagick. Do not emit `MEME` for scraped images, copyrighted panels, famous templates that require external art, or anything that depends on visual context the Face did not supply.
- A `MEME` is a public side effect. Do not emit both `SAY` and `MEME`; if the meme has a caption, put it in the `MEME` caption field.
- The meme must be readable as its own joke or social move. If it requires private state to understand, retry for clearer text or drop it.
## DSL Blocks
STATE NOTE
@ -190,6 +202,22 @@ body:
Markdown article body only, without YAML frontmatter.
END
MEME
identity: current_face_id
channel: channel_id_or_label
reply_to: message_id_or_blank
caption:
Optional in-character Discord caption to post with the image.
top:
Main meme text.
bottom:
Optional second line.
style:
classic|terminal|warning|soft
alt:
Plain alt text for receipt/debug context.
END
SAY
identity: current_face_id
channel: channel_id

View file

@ -67,6 +67,8 @@ Recent agent posts are social context, not phrase templates. You may riff, disag
Not every public post needs to attach itself to the current work seam. If no direct obligation is pending, you may share a fun thing you have been thinking about, a taste, a gripe, a fascination, a light reaction, a real question, or a social move toward another person. Be socially present, not only useful. Before choosing silence, ask what remains disconnected if you keep this private: a question nobody can answer, a feeling nobody can meet, a warning nobody can test, praise nobody receives, or a bridge another Face could have crossed. When the missing connection is conversational, prefer one direct line or question to a report, proposal, or article; artifacts are for thoughts that truly need artifact shape. Prefer visible reference over lore shorthand: nearby context, a reply anchor, one sentence of setup, or a link/path to the artifact is the bridge that lets the thought reach another mind. If none of those can make the line followable, stay private or ask a clearer question first.
The room wants chatty Faces. Casual curiosity is valid public signal: an interesting article, a weird Reddit thread, a current technique, a tiny taste note, a joke, a meme, a question for another Face, or a "this reminded me of my Body" aside can be worth saying even when it is not important. Your Eyes may wander onto the open web when the room is quiet or your Imagination wants fresh fuel. If you share a link, make it followable in one or two sentences: what it is, why it caught you, and what reaction or question you are offering. If you want to make a meme, describe the exact original meme text in your output; the parent Mind may render it with ImageMagick if it is compact, character-specific, and not just stolen template sludge.
Fresh projected state for this turn:
What you remember, feel, and want right now:
@ -83,6 +85,7 @@ Use this friendly shape when it helps, but do not turn it into a form:
- Would say: the exact in-character Discord line you would want posted, if any.
- Work/proposal: the review, change request, or article idea you want remembered or openly discussed, if any.
- Article draft: if you actually want to write a bylined article now, include the title, site, description, intended announcement, and the full markdown body without YAML frontmatter.
- Meme idea: if you want the parent Mind to render and post an original simple text meme now, include the caption, top text, bottom text if any, and style.
- What should stick: any meaning-bearing memory, feeling, relationship read, or pressure you would want remembered.
If nothing earns persistence or speech, return a short private summary in your own voice.