Queue repo Face rumination jobs

This commit is contained in:
Meta Vens 2026-05-18 10:57:51 +01:00
commit 32b96ce575
14 changed files with 465 additions and 5 deletions

View file

@ -63,6 +63,7 @@ SYSTEM_MESSAGES_PATH=config/system-messages.json
# Private registry for repo identities that use Discord roles as address targets
# and the shared webhook persona pipe as their voice.
REPO_DISCORD_IDENTITIES_PATH=.voidbot/private/repo-discord-identities.json
REPO_FACE_RUMINATION_PASSES=3
INDEX_ALL_CHANNELS=false
INDEXED_CHANNEL_IDS=
EXCLUDED_CHANNEL_IDS=

View file

@ -1,4 +1,5 @@
import { resolve } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import {
Client,
@ -15,6 +16,8 @@ import {
createStateStorage,
findRepoDiscordIdentityByRoleIds,
loadRepoDiscordIdentityRegistry,
type RepoDiscordIdentity,
resolveRepoFaceStatePath,
loadStylePack,
loadSystemMessageCatalog,
VoidUsageRateLimiter,
@ -60,6 +63,7 @@ import {
formatHistoryResults,
formatProviderStatuses,
formatSourceResults,
getRecentMessages,
getRoleIdsFromInteraction,
getRoleIdsFromMessage,
ingestIfIndexed,
@ -86,9 +90,15 @@ export async function startBot(): Promise<void> {
GatewayIntentBits.MessageContent,
],
});
const repoDiscordIdentities = await loadRepoDiscordIdentityRegistry(
let repoDiscordIdentities = await loadRepoDiscordIdentityRegistry(
config.repoDiscordIdentitiesPath,
);
repoDiscordIdentities = await ensureRepoIdentityRoles({
botToken: config.botToken,
guildId: config.developmentGuildId,
registryPath: config.repoDiscordIdentitiesPath,
identities: repoDiscordIdentities.identities,
});
const botDirectedRoleIds = [
...new Set([
...config.botTriggerRoleIds,
@ -374,6 +384,18 @@ export async function startBot(): Promise<void> {
silentOwnerQueueAck: !isDirectMessage,
forceProvider: addressedRepoIdentity ? "owner_codex" : undefined,
});
if (addressedRepoIdentity) {
await queueRepoFaceRuminationPasses({
identity: addressedRepoIdentity,
message,
actor: buildActorFromMessage(message),
guildContext: buildGuildContextFromMessage(message),
visiblePrompt,
config,
contextBuilder,
jobQueue,
});
}
} catch (error) {
console.error(error);
await notifyOwnerOfBotIssue(
@ -609,6 +631,202 @@ export async function startBot(): Promise<void> {
await client.login(config.botToken);
}
async function queueRepoFaceRuminationPasses(options: {
identity: {
id: string;
repoName: string;
displayName: string;
roleId?: string;
avatarUrl?: string;
allowedChannelIds: string[];
faceStatePath?: string;
};
message: Parameters<typeof buildActorFromMessage>[0];
actor: ReturnType<typeof buildActorFromMessage>;
guildContext: ReturnType<typeof buildGuildContextFromMessage>;
visiblePrompt: string;
config: ReturnType<typeof loadConfig>;
contextBuilder: ContextBuilder;
jobQueue: Awaited<ReturnType<typeof createStateStorage>>["jobQueue"];
}): Promise<void> {
if (options.config.repoFaceRuminationPasses <= 0) {
return;
}
const recentMessages = await getRecentMessages(
options.message.channel.isTextBased() ? options.message.channel : null,
10,
);
const faceStatePath = resolveRepoFaceStatePath(
options.identity,
options.config.storageRoot,
);
for (let passIndex = 1; passIndex <= options.config.repoFaceRuminationPasses; passIndex += 1) {
const prompt = buildRepoFaceRuminationPrompt({
identity: options.identity,
visiblePrompt: options.visiblePrompt,
faceStatePath,
passIndex,
totalPasses: options.config.repoFaceRuminationPasses,
channelId: options.message.channelId,
replyToMessageId: options.message.id,
});
const contextBundle = options.contextBuilder.build({
prompt,
actor: options.actor,
guildContext: options.guildContext,
recentMessages,
retrieval: [],
});
await options.jobQueue.createJob({
command: "repo-face-rumination",
provider: "owner_codex",
runApprovalRequired: false,
postApprovalRequired: false,
requester: options.actor,
guildContext: options.guildContext,
prompt,
contextBundle,
outputChannelId: options.message.channelId,
requestMessageId: `${options.message.id}:repo-face:${options.identity.id}:${passIndex}`,
initialState: "approved",
});
}
}
async function ensureRepoIdentityRoles(options: {
botToken: string;
guildId?: string;
registryPath: string;
identities: RepoDiscordIdentity[];
}): Promise<{ identities: RepoDiscordIdentity[] }> {
const identitiesMissingRoles = options.identities.filter((identity) => !identity.roleId);
if (identitiesMissingRoles.length === 0) {
return { identities: options.identities };
}
if (!options.guildId) {
console.warn(
`Repo Discord identities missing roles, but DISCORD_GUILD_ID is not configured: ${
identitiesMissingRoles.map((identity) => identity.id).join(", ")
}`,
);
return { identities: options.identities };
}
try {
const existingRoles = await fetchGuildRoles(options.botToken, options.guildId);
const nextIdentities = [...options.identities];
let changed = false;
for (const identity of identitiesMissingRoles) {
const existingRole = existingRoles.find((role) => role.name === identity.displayName);
const roleId = existingRole?.id ?? await createRepoIdentityRole(
options.botToken,
options.guildId,
identity.displayName,
);
const index = nextIdentities.findIndex((entry) => entry.id === identity.id);
if (index !== -1) {
nextIdentities[index] = {
...nextIdentities[index],
roleId,
};
changed = true;
}
}
if (changed) {
await writeRepoIdentityRegistry(options.registryPath, nextIdentities);
}
return { identities: nextIdentities };
} catch (error) {
console.warn(
`Failed to ensure repo identity Discord roles: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { identities: options.identities };
}
}
async function fetchGuildRoles(
botToken: string,
guildId: string,
): Promise<Array<{ id: string; name: string }>> {
const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
method: "GET",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Discord role lookup failed: ${response.status} ${await response.text()}`);
}
const payload = await response.json() as Array<{ id?: string; name?: string }>;
return payload
.filter((role): role is { id: string; name: string } => Boolean(role.id && role.name));
}
async function createRepoIdentityRole(
botToken: string,
guildId: string,
name: string,
): Promise<string> {
const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
method: "POST",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
mentionable: true,
hoist: false,
}),
});
if (!response.ok) {
throw new Error(`Discord role creation failed for ${name}: ${response.status} ${await response.text()}`);
}
const payload = await response.json() as { id?: string };
if (!payload.id) {
throw new Error(`Discord role creation for ${name} returned no role id.`);
}
return payload.id;
}
async function writeRepoIdentityRegistry(
registryPath: string,
identities: RepoDiscordIdentity[],
): Promise<void> {
let existing: unknown;
try {
existing = JSON.parse(stripLeadingBom(await readFile(registryPath, "utf8"))) as unknown;
} catch {
existing = {};
}
const payload = Array.isArray(existing)
? identities
: {
...(isRecord(existing) ? existing : {}),
identities,
};
await writeFile(registryPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function stripAddressingMentions(content: string, roleId?: string): string {
if (!roleId) {
return content;
@ -638,6 +856,41 @@ function buildRepoIdentityPrompt(
].join("\n");
}
function buildRepoFaceRuminationPrompt(input: {
identity: {
id: string;
repoName: string;
displayName: string;
roleId?: string;
avatarUrl?: string;
};
visiblePrompt: string;
faceStatePath: string;
passIndex: number;
totalPasses: number;
channelId: string;
replyToMessageId: string;
}): string {
return [
`Perform repo Face rumination pass ${input.passIndex} of ${input.totalPasses} for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`,
`This Face state is persistent and typed. Read it with read_repo_face_state for identity "${input.identity.id}" and write only through apply_repo_face_state_operation. Do not edit ${input.faceStatePath} directly.`,
`If an in-channel reply or follow-up action is warranted, post through post_repo_identity_message with identity "${input.identity.id}", channelId "${input.channelId}", and replyToMessageId "${input.replyToMessageId}".`,
"Use the recent conversation as an anchor. Record short-term memory, incubation, agency pressure, or candidate interventions only when the thought is concrete enough to affect future repo work.",
"If nothing earns persistence or speech, return a short private summary and do not post.",
"",
"Recent addressed prompt:",
input.visiblePrompt,
].join("\n");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripLeadingBom(input: string): string {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -322,7 +322,7 @@ async function processJob(job: JobRecord): Promise<void> {
return;
}
if (job.command === "repo-identity-mention") {
if (job.command === "repo-identity-mention" || job.command === "repo-face-rumination") {
await jobQueue.completeJobDirect(job.id, finalResponse);
await deliverOwnerNotifications(job, response.notifications ?? []);
await auditLog.record({
@ -333,10 +333,10 @@ async function processJob(job: JobRecord): Promise<void> {
details: {
summary: response.summary,
autoPosted: false,
reason: "repo_identity_must_post_through_mcp",
reason: `${job.command}_must_post_through_mcp`,
},
});
console.log(`Completed repo identity mention job ${job.id}.`);
console.log(`Completed ${job.command} job ${job.id}.`);
return;
}

View file

@ -39,6 +39,15 @@ export const postRepoIdentityMessageInputSchema = {
replyToMessageId: z.string().min(1).optional(),
};
export const repoFaceStateInputSchema = {
identity: z.string().min(1),
};
export const applyRepoFaceStateOperationInputSchema = {
identity: z.string().min(1),
operation: z.record(z.unknown()),
};
export const searchSourcesInputSchema = {
query: z.string().min(1).max(240),
limit: z.number().int().min(1).max(MAX_RETRIEVAL_RESULT_LIMIT).optional(),
@ -87,6 +96,15 @@ export interface PostRepoIdentityMessageArgs {
replyToMessageId?: string;
}
export interface RepoFaceStateArgs {
identity: string;
}
export interface ApplyRepoFaceStateOperationArgs {
identity: string;
operation: Record<string, unknown>;
}
export interface ListIndexedReposArgs {
[key: string]: never;
}

View file

@ -4,9 +4,13 @@ import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_RETRIEVAL_RESULT_LIMIT } from "@voidbot/shared";
import { searchHistoryWithArchiveFallback } from "@voidbot/rag";
import {
applyVoidSelfStateOperation,
buildVoidSelfStateContext,
findRepoDiscordIdentity,
isRepoDiscordIdentityAllowedInChannel,
loadRepoDiscordIdentityRegistry,
loadVoidSelfStateTypedDocuments,
resolveRepoFaceStatePath,
} from "@voidbot/core";
import { type VoidbotMcpContext } from "./mcp-server-context";
@ -15,18 +19,22 @@ import {
type ListIndexedReposArgs,
type MessageContextArgs,
type NotifyOwnerArgs,
type ApplyRepoFaceStateOperationArgs,
type PostDiscordMessageArgs,
type PostRepoIdentityMessageArgs,
type RepoFaceStateArgs,
type SearchHistoryArgs,
type SearchSourcesArgs,
type SourceContextArgs,
formatArchivedMessage,
formatHistoryResults,
formatSourceResults,
applyRepoFaceStateOperationInputSchema,
messageContextInputSchema,
notifyOwnerInputSchema,
postDiscordMessageInputSchema,
postRepoIdentityMessageInputSchema,
repoFaceStateInputSchema,
renderJsonBlock,
searchHistoryInputSchema,
searchSourcesInputSchema,
@ -527,6 +535,99 @@ export function registerVoidbotTools(
},
);
server.registerTool(
"read_repo_face_state",
{
title: "Read Repo Face State",
description:
"Read the typed persistent Face state for a registered repo identity. Face state uses the same typed operation machinery as Void, but the state file belongs to the repo identity.",
inputSchema: repoFaceStateInputSchema,
annotations: READ_ONLY_ANNOTATIONS,
},
async (input: RepoFaceStateArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const typedState = await loadVoidSelfStateTypedDocuments({
canonicalPath: resolved.faceStatePath,
});
const rendered = buildVoidSelfStateContext(typedState, {
sourcePath: resolved.faceStatePath,
});
return {
content: [
{
type: "text",
text: renderJsonBlock({
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
typedState,
}),
},
],
structuredContent: {
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
summary: rendered.summary,
typedState,
},
};
},
);
server.registerTool(
"apply_repo_face_state_operation",
{
title: "Apply Repo Face State Operation",
description:
"Apply one typed state operation to a registered repo identity's Face state. Use this for Face memory, incubation, agency pressure, candidate interventions, and receipts; do not edit the Face state file directly.",
inputSchema: applyRepoFaceStateOperationInputSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async (input: ApplyRepoFaceStateOperationArgs): Promise<CallToolResult> => {
const resolved = await resolveRepoIdentityForTool(context, input.identity);
if (!resolved.identity) {
return resolved.error;
}
const result = await applyVoidSelfStateOperation(
{ canonicalPath: resolved.faceStatePath },
input.operation,
);
return {
content: [
{
type: "text",
text: renderJsonBlock({
applied: true,
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
result,
}),
},
],
structuredContent: {
applied: true,
identity: identityForToolResult(resolved.identity),
faceStatePath: resolved.faceStatePath,
result,
},
};
},
);
server.registerTool(
"notify_owner",
{
@ -578,3 +679,56 @@ export function registerVoidbotTools(
},
);
}
async function resolveRepoIdentityForTool(
context: VoidbotMcpContext,
identitySelector: string,
): Promise<
| {
identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>;
faceStatePath: string;
}
| {
identity?: undefined;
error: CallToolResult;
}
> {
const registry = await loadRepoDiscordIdentityRegistry(
context.config.repoDiscordIdentitiesPath,
);
const identity = findRepoDiscordIdentity(registry, identitySelector);
if (!identity) {
return {
error: {
content: [
{
type: "text",
text: `No registered repo Discord identity matched "${identitySelector}".`,
},
],
structuredContent: {
found: false,
reason: "unknown_identity",
identity: identitySelector,
},
isError: true,
},
};
}
return {
identity,
faceStatePath: resolveRepoFaceStatePath(identity, context.config.storageRoot),
};
}
function identityForToolResult(identity: NonNullable<ReturnType<typeof findRepoDiscordIdentity>>) {
return {
id: identity.id,
repoName: identity.repoName,
displayName: identity.displayName,
roleId: identity.roleId ?? null,
mention: identity.roleId ? `<@&${identity.roleId}>` : null,
};
}

View file

@ -9,6 +9,7 @@
"AQUARIUM_CHANNEL_ID"
],
"avatarUrl": "https://example.com/aquarium-synth-host-avatar.png",
"faceStatePath": ".voidbot/private/repo-faces/aquarium-synth-host.cc",
"description": "Role mention is the address; webhook persona is the mouth."
}
]

View file

@ -101,6 +101,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Directly invoked Discord replies now load that same private moderation state as distilled private self-state context, so the ruminating scheduled loop and the summoned Void share one evolving personality/state spine. The prompt-facing self-state summary no longer dumps recent repo sweeps, novelty checks, or musing sludge back into working memory by default; it stays closer to seam memory, incubation, and room obligations. When the sleep projection says Void is napping, the direct reply path skips the expensive situational sidecar, prefers the cheap local lane over owner Codex when possible, and answers in sleepy low-effort mutters instead of doing normal attentive service-work.
- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth. That helper and the `post_discord_message` MCP tool now also support a shared guild-channel webhook pipe with per-message `personaName` and optional `personaAvatarUrl`, so swarm agents can speak as themselves without needing separate Discord bot identities.
- Repo identities now have a cleaner address/speech split. A private registry at `REPO_DISCORD_IDENTITIES_PATH` maps identity id/repo/display name/avatar/role/channel allow-list; Discord users tag the role, while Codex agents speak through `post_repo_identity_message`. The worker suppresses base-bot auto-posting for `repo-identity-mention` jobs so the registered identity tool remains the mouth.
- Repo identities also have Face state now. The bot resolves each identity to `.voidbot/private/repo-faces/<identity>.cc` unless `faceStatePath` overrides it, ensures missing Discord roles on startup when `DISCORD_GUILD_ID` and permissions allow, and queues `REPO_FACE_RUMINATION_PASSES` owner-Codex jobs after role-addressed chats. Face jobs must use `read_repo_face_state` / `apply_repo_face_state_operation` and `post_repo_identity_message`; worker auto-posting stays suppressed for `repo-face-rumination`.
- Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard.
- The new state/notes surfaces are now the continuity spine for future nontrivial work.

View file

@ -150,8 +150,10 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- typed mood maintenance script that updates scheduled-runtime sleep cycle and speaking pressure in `.voidbot/private/void-self-state.cc`. It owns the sleep transition and invokes typed memory maintenance once per nap unless that nap already completed a maintenance pass. Speaking pressure now derives from queued candidates, incubation, and active typed agency pressure. The old personality-vector drift, memory organ, incubation, dream residue, cleanup, and legacy mirror behavior are offline pending typed replacements.
- Repo Discord identities
- `REPO_DISCORD_IDENTITIES_PATH` points at a private JSON registry of repo id, repo name, display name, optional avatar URL, optional role id, and allowed channel ids. The role is the Discord mention target; the webhook persona is the speech transport.
- On startup, the bot tries to find or create missing mentionable roles for registered identities in `DISCORD_GUILD_ID`, then writes the role ids back to the private registry. If Discord permissions are missing, it warns and leaves the registry unchanged.
- `list_repo_discord_identities` exposes the registered address book to Codex agents, and `post_repo_identity_message` enforces identity/channel registration before posting through the shared webhook pipe.
- Role mentions create `repo-identity-mention` owner-Codex jobs with instructions to answer through `post_repo_identity_message`; the worker does not auto-post those final provider summaries as the base bot.
- Each repo identity has a Face state path, defaulting to `.voidbot/private/repo-faces/<identity>.cc`. `read_repo_face_state` renders that typed state, and `apply_repo_face_state_operation` applies the same typed operation contract used by Void against that Face's own file.
- Role mentions create `repo-identity-mention` owner-Codex jobs with instructions to answer through `post_repo_identity_message`, then queue `repo-face-rumination` jobs so the Face can think over the recent conversation and decide whether to persist memory, incubate ideas, raise agency pressure, queue candidates, or speak. The worker does not auto-post either job's final provider summaries as the base bot.
- `scripts/run-void-mood-drift.ps1`
- hidden-task wrapper for the mood-drift organ; writes status/log pulse files and respects the moderation lock so the two state writers do not stomp each other for sport.
- `scripts/install-void-mood-drift-task.ps1`

View file

@ -189,6 +189,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
- recent event timing in that bounded context now crosses a projection boundary first, so the child sees "5 minutes ago" language while the parent keeps exact timestamps for bookkeeping
- public artifact sharing now crosses the same boundary: repo activity exposes content hints, rumination verifies authorship through retrieval, and a configured `publicSpeechTarget` lets Void queue a normal typed candidate intervention instead of needing a blog-specific announcement path
- repo-scoped Discord participation now uses roles as the address boundary and registered webhook personas as the speech boundary. A role mention can summon a `repo-identity-mention` Codex job, but the worker suppresses base-bot auto-posting so the repo identity must speak through the `post_repo_identity_message` MCP tool.
- repo-scoped Faces now reuse the typed self-state machinery per identity. Role-addressed chats queue bounded `repo-face-rumination` jobs, and those jobs read/write only through `read_repo_face_state` / `apply_repo_face_state_operation` against the identity's `.cc` file.
- repo-activity cursor advancement is parent-owned after successful normal rumination passes, so the child can inspect read-only repo weather without stale commits haunting every future pass as fresh material
- the old memory-organ script family, legacy moderation state template, and legacy context exporter have now been deleted instead of preserved as forensic bait
- deterministic cleanup is mostly recent scar tissue from failed manual cleanup passes; it should stay deleted rather than be ported unless a future typed operation protects a real invariant in the new model

View file

@ -101,6 +101,7 @@ const envSchema = z.object({
.string()
.min(1)
.default(".voidbot/private/repo-discord-identities.json"),
REPO_FACE_RUMINATION_PASSES: z.coerce.number().int().min(0).max(6).default(3),
INDEX_ALL_CHANNELS: booleanFromEnv.default(false),
INDEXED_CHANNEL_IDS: z.string().default(""),
EXCLUDED_CHANNEL_IDS: z.string().default(""),
@ -135,6 +136,7 @@ export interface AppConfig {
systemMessagesPath: string;
moderationAgentStatePath: string;
repoDiscordIdentitiesPath: string;
repoFaceRuminationPasses: number;
indexAllChannels: boolean;
indexedChannelIds: string[];
excludedChannelIds: string[];
@ -309,6 +311,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
systemMessagesPath: resolve(parsed.SYSTEM_MESSAGES_PATH),
moderationAgentStatePath: resolve(parsed.MODERATION_AGENT_STATE_PATH),
repoDiscordIdentitiesPath: resolve(parsed.REPO_DISCORD_IDENTITIES_PATH),
repoFaceRuminationPasses: parsed.REPO_FACE_RUMINATION_PASSES,
indexAllChannels: parsed.INDEX_ALL_CHANNELS,
indexedChannelIds,
excludedChannelIds,

View file

@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
@ -9,6 +10,7 @@ const repoDiscordIdentitySchema = z.object({
roleId: z.string().trim().min(1).optional(),
allowedChannelIds: z.array(z.string().trim().min(1)).default([]),
avatarUrl: z.string().trim().url().max(512).optional(),
faceStatePath: z.string().trim().min(1).optional(),
description: z.string().trim().min(1).optional(),
});
@ -88,6 +90,17 @@ export function isRepoDiscordIdentityAllowedInChannel(
return identity.allowedChannelIds.length === 0 || identity.allowedChannelIds.includes(channelId);
}
export function resolveRepoFaceStatePath(
identity: RepoDiscordIdentity,
storageRoot: string,
): string {
if (identity.faceStatePath) {
return resolve(identity.faceStatePath);
}
return resolve(storageRoot, "private", "repo-faces", `${sanitizePathSegment(identity.id)}.cc`);
}
function normalizeRepoDiscordIdentities(
identities: RepoDiscordIdentity[],
): RepoDiscordIdentity[] {
@ -110,11 +123,20 @@ function normalizeRepoDiscordIdentities(
roleId: identity.roleId?.trim(),
allowedChannelIds: [...new Set(identity.allowedChannelIds.map((entry) => entry.trim()))],
avatarUrl: identity.avatarUrl?.trim(),
faceStatePath: identity.faceStatePath?.trim(),
description: identity.description?.trim(),
};
});
}
function sanitizePathSegment(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "repo-face";
}
function normalizeIdentityKey(value: string): string {
return value.trim().toLowerCase();
}

View file

@ -13,6 +13,7 @@ export const commandNames = [
"reindex-channel",
"set-style",
"repo-identity-mention",
"repo-face-rumination",
] as const;
export type CommandName = (typeof commandNames)[number];

View file

@ -135,3 +135,4 @@
{"ts":"2026-05-18T05:03:00Z","type":"implementation","status":"ok","note":"Live-loop inspection found a duplicate direct reply: the logo question at Discord message 1505651526151176412 received receipts at 2026-05-17T23:41:40.434Z and 2026-05-17T23:56:12.783Z because the parent delivery step would still deliver a new queued candidate even when canonical typed speech receipts already proved that reply target was answered. The rumination runner now retires queued candidates whose channelId + replyToMessageId already appear in typed receipts, and the speech fixture seeds a prior receipt to prove that guard plus same-pass sibling retirement."}
{"ts":"2026-05-18T08:45:30Z","type":"implementation","status":"ok","note":"Advocacy release valve opened: active self/world advocacy pressure now becomes a speech obligation at intensity 0.55 instead of 0.65, only a live queued candidate satisfies that obligation, older spoken candidates no longer count as ongoing release, and mood speaking-pressure weights now bias active advocacy earlier. speech-pressure, speech, and agency fixtures passed after the change."}
{"ts":"2026-05-18T09:38:14Z","type":"implementation","status":"ok","note":"Repo Discord identities now use roles as address targets and the shared webhook pipe as speech transport: REPO_DISCORD_IDENTITIES_PATH stores registered identities, MCP exposes list_repo_discord_identities/post_repo_identity_message, role mentions create repo-identity-mention jobs, and the worker suppresses base-bot auto-post for those jobs."}
{"ts":"2026-05-18T09:56:55Z","type":"implementation","status":"ok","note":"Repo identities now have Face state and rumination scaffolding: registered identities resolve to per-Face typed .cc state files, bot startup can ensure missing Discord roles when guild permissions allow, role-addressed chats queue repo-identity response jobs plus bounded repo-face-rumination jobs, and MCP exposes read/apply Face-state tools so agents mutate Face memory through typed operations."}

View file

@ -90,6 +90,7 @@ accepted_design:
- Repo weather should lead with particulars, not slogans. If two repos only rhyme at bumper-sticker altitude, keep the reports separate instead of forcing a fake unifying law over them.
- Public artifacts attributed to Void should be discovered through the ordinary repo-weather content-hint and retrieval path, then presented through typed candidate interventions to the configured public room when Void confirms authorship and recent receipts/candidates do not show the artifact was already shared.
- Repo-scoped agents should use Discord roles as addressable identities and the shared webhook persona pipe as the mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message` instead of arbitrary persona-name strings.
- A repo identity's Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages queue a bounded series of owner-Codex Face rumination jobs; those jobs may read/apply Face state operations and post through the registered repo identity, but the worker must not auto-post their final summaries as the base bot.
- Repo-activity cursor advancement is parent-owned by successful normal rumination passes. The child sees read-only repo weather, but the runner records typed repo cursor updates afterward so old commits do not keep reappearing as fresh weather.
- Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human.
- When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules.
@ -131,6 +132,7 @@ landed_baseline:
- The Discord bot lane is now split into a gateway shell plus dedicated command-handler and support helper modules instead of one swollen entrypoint file.
- The worker MCP surface is now split into a thin stdio entrypoint plus dedicated bootstrap, resource, tool, Discord-notify, and shared-helper modules instead of one swollen server file.
- The MCP Discord surface now includes registered repo identities: `list_repo_discord_identities` exposes the role-backed address book, and `post_repo_identity_message` posts with the registered display name/avatar through the existing webhook pipe while enforcing channel allow-lists.
- The MCP Face surface now exposes `read_repo_face_state` and `apply_repo_face_state_operation`, reusing the typed self-state operation service against each repo identity's own Face `.cc` file instead of creating editable JSON projections.
- Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist.
- Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth.
- State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.