Route Face mentions through CTB
This commit is contained in:
parent
65f62128bb
commit
d1dcbeed32
14 changed files with 362 additions and 304 deletions
|
|
@ -10,18 +10,17 @@ import {
|
|||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
buildVoidMcpServerConfig,
|
||||
buildEpiphanyIdentityRegistry,
|
||||
ContextBuilder,
|
||||
OllamaSituationalSocialReadInferer,
|
||||
PermissionEngine,
|
||||
createStateStorage,
|
||||
ensureRepoFaceInitialized,
|
||||
findRepoDiscordIdentityByRoleIds,
|
||||
loadVoidSelfState,
|
||||
findRepoDiscordIdentityByTextAddress,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
renderFaceIdentityDoctrine,
|
||||
queueRepoFaceMention,
|
||||
type RepoDiscordIdentity,
|
||||
resolveRepoFaceStatePath,
|
||||
stripRepoIdentityTextAddress,
|
||||
loadStylePack,
|
||||
loadSystemMessageCatalog,
|
||||
VoidUsageRateLimiter,
|
||||
|
|
@ -327,13 +326,21 @@ export async function startBot(): Promise<void> {
|
|||
);
|
||||
await rememberAmbientVoidReference(message, client.user?.id, interactionMemory);
|
||||
|
||||
const addressedRepoIdentity = isDirectMessage
|
||||
const roleAddressedRepoIdentity = isDirectMessage
|
||||
? undefined
|
||||
: findRepoDiscordIdentityByRoleIds(
|
||||
repoDiscordIdentities,
|
||||
message.mentions.roles.keys(),
|
||||
message.channelId,
|
||||
);
|
||||
const textAddressedRepoIdentity = isDirectMessage
|
||||
? undefined
|
||||
: findRepoDiscordIdentityByTextAddress(
|
||||
repoDiscordIdentities,
|
||||
stripBotMention(message.content),
|
||||
message.channelId,
|
||||
);
|
||||
const addressedRepoIdentity = roleAddressedRepoIdentity ?? textAddressedRepoIdentity;
|
||||
const isBotMentioned = Boolean(client.user && message.mentions.has(client.user));
|
||||
|
||||
if (!client.user || (!isDirectMessage && !isBotMentioned && !addressedRepoIdentity)) {
|
||||
|
|
@ -342,13 +349,15 @@ export async function startBot(): Promise<void> {
|
|||
|
||||
const visiblePrompt = isDirectMessage
|
||||
? message.content.trim()
|
||||
: stripAddressingMentions(
|
||||
stripBotMention(message.content),
|
||||
addressedRepoIdentity?.roleId,
|
||||
).trim();
|
||||
const prompt = addressedRepoIdentity
|
||||
? buildRepoIdentityPrompt(visiblePrompt, addressedRepoIdentity)
|
||||
: visiblePrompt;
|
||||
: addressedRepoIdentity
|
||||
? stripRepoIdentityTextAddress(
|
||||
stripAddressingMentions(
|
||||
stripBotMention(message.content),
|
||||
roleAddressedRepoIdentity?.roleId,
|
||||
),
|
||||
addressedRepoIdentity,
|
||||
).trim()
|
||||
: stripBotMention(message.content).trim();
|
||||
|
||||
if (!visiblePrompt) {
|
||||
await replyToMessage(
|
||||
|
|
@ -359,9 +368,37 @@ export async function startBot(): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
if (addressedRepoIdentity) {
|
||||
const faceInitialization = await ensureRepoFaceInitialized({
|
||||
identity: addressedRepoIdentity,
|
||||
storageRoot: config.storageRoot,
|
||||
sourceRepoRoot: config.sourceRepoRoot,
|
||||
epiphanyAgentRoot: config.epiphanyAgentRoot,
|
||||
workspaceRoot: process.cwd(),
|
||||
birthMode: config.repoFaceBirthMode,
|
||||
birthExecutor: config.repoFaceBirthExecutor,
|
||||
});
|
||||
const queuedMention = await queueRepoFaceMention({
|
||||
statePath: config.repoFaceHeartbeats.statePath,
|
||||
identity: addressedRepoIdentity,
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
authorId: message.author.id,
|
||||
authorName: message.author.username,
|
||||
content: message.content,
|
||||
visiblePrompt,
|
||||
});
|
||||
console.log(
|
||||
`Queued repo Face mention ${message.id} for ${addressedRepoIdentity.id} via heartbeat CTB (${queuedMention.pendingCount} pending). Birth status: ${
|
||||
faceInitialization.birthStatusPath ?? faceInitialization.skippedReason ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await handlePrompt({
|
||||
prompt,
|
||||
command: addressedRepoIdentity ? "repo-identity-mention" : "ask",
|
||||
prompt: visiblePrompt,
|
||||
command: "ask",
|
||||
actor: buildActorFromMessage(message),
|
||||
roleIds: getRoleIdsFromMessage(message),
|
||||
guildContext: buildGuildContextFromMessage(message),
|
||||
|
|
@ -386,30 +423,7 @@ export async function startBot(): Promise<void> {
|
|||
stylePack: activeStylePack,
|
||||
systemMessages: activeSystemMessages,
|
||||
silentOwnerQueueAck: !isDirectMessage,
|
||||
forceProvider: addressedRepoIdentity ? "owner_codex" : undefined,
|
||||
});
|
||||
if (addressedRepoIdentity) {
|
||||
const faceInitialization = await ensureRepoFaceInitialized({
|
||||
identity: addressedRepoIdentity,
|
||||
storageRoot: config.storageRoot,
|
||||
sourceRepoRoot: config.sourceRepoRoot,
|
||||
epiphanyAgentRoot: config.epiphanyAgentRoot,
|
||||
workspaceRoot: process.cwd(),
|
||||
birthMode: config.repoFaceBirthMode,
|
||||
birthExecutor: config.repoFaceBirthExecutor,
|
||||
});
|
||||
await queueRepoFaceRuminationPasses({
|
||||
identity: addressedRepoIdentity,
|
||||
message,
|
||||
actor: buildActorFromMessage(message),
|
||||
guildContext: buildGuildContextFromMessage(message),
|
||||
visiblePrompt,
|
||||
faceInitialization,
|
||||
config,
|
||||
contextBuilder,
|
||||
jobQueue,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await notifyOwnerOfBotIssue(
|
||||
|
|
@ -645,79 +659,6 @@ 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;
|
||||
description?: string;
|
||||
allowedChannelIds: string[];
|
||||
faceStatePath?: string;
|
||||
};
|
||||
message: Parameters<typeof buildActorFromMessage>[0];
|
||||
actor: ReturnType<typeof buildActorFromMessage>;
|
||||
guildContext: ReturnType<typeof buildGuildContextFromMessage>;
|
||||
visiblePrompt: string;
|
||||
faceInitialization: Awaited<ReturnType<typeof ensureRepoFaceInitialized>>;
|
||||
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,
|
||||
);
|
||||
const faceSelfState = await loadVoidSelfState(faceStatePath, {
|
||||
guildContext: options.guildContext,
|
||||
recentMessages,
|
||||
});
|
||||
|
||||
for (let passIndex = 1; passIndex <= options.config.repoFaceRuminationPasses; passIndex += 1) {
|
||||
const prompt = buildRepoFaceRuminationPrompt({
|
||||
identity: options.identity,
|
||||
visiblePrompt: options.visiblePrompt,
|
||||
faceStatePath,
|
||||
faceInitialization: options.faceInitialization,
|
||||
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: [],
|
||||
voidSelfState: faceSelfState,
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -857,122 +798,6 @@ function stripAddressingMentions(content: string, roleId?: string): string {
|
|||
return content.replace(new RegExp(`<@&${escapeRegExp(roleId)}>`, "g"), "").trim();
|
||||
}
|
||||
|
||||
function buildRepoIdentityPrompt(
|
||||
visiblePrompt: string,
|
||||
identity: {
|
||||
id: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
roleId?: string;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
},
|
||||
): string {
|
||||
const mention = identity.roleId ? `<@&${identity.roleId}>` : "unregistered-role";
|
||||
|
||||
return [
|
||||
`You were addressed on Discord as repo identity ${identity.displayName} (${identity.id}) for repo ${identity.repoName}.`,
|
||||
renderRepoFaceIdentityDoctrine(identity),
|
||||
`The Discord role mention ${mention} is the addressable identity. Write the direct in-channel reply as ${identity.displayName}; the worker will post your final answer through the registered repo identity persona.`,
|
||||
"Do not answer as base VoidBot. Do not call post_repo_identity_message for this direct mention; that tool is for background rumination or proactive follow-up, not the immediate summons reply.",
|
||||
"",
|
||||
"User prompt:",
|
||||
visiblePrompt,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRepoFaceRuminationPrompt(input: {
|
||||
identity: {
|
||||
id: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
roleId?: string;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
};
|
||||
visiblePrompt: string;
|
||||
faceStatePath: string;
|
||||
faceInitialization: Awaited<ReturnType<typeof ensureRepoFaceInitialized>>;
|
||||
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}.`,
|
||||
renderRepoFaceIdentityDoctrine(input.identity),
|
||||
`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.`,
|
||||
`Repo-local .voidbot birth/home status: ${JSON.stringify({
|
||||
repoPath: input.faceInitialization.repoPath ?? null,
|
||||
repoVoidbotRoot: input.faceInitialization.repoVoidbotRoot ?? null,
|
||||
identityPath: input.faceInitialization.identityPath ?? null,
|
||||
birthStatusPath: input.faceInitialization.birthStatusPath ?? null,
|
||||
birthLogPath: input.faceInitialization.birthLogPath ?? null,
|
||||
skippedReason: input.faceInitialization.skippedReason ?? null,
|
||||
})}`,
|
||||
`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 the recent conversation already contains a directed task for agents or this Face, answer that task instead of asking what the task is.",
|
||||
"If nothing earns persistence or speech, return a short private summary and do not post.",
|
||||
"",
|
||||
"Recent addressed prompt:",
|
||||
input.visiblePrompt,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderRepoFaceIdentityDoctrine(identity: {
|
||||
id: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
allowedChannelIds?: string[];
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
}): string {
|
||||
const repoIdentity: RepoDiscordIdentity = {
|
||||
id: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
allowedChannelIds: identity.allowedChannelIds ?? [],
|
||||
};
|
||||
if (identity.avatarUrl) {
|
||||
repoIdentity.avatarUrl = identity.avatarUrl;
|
||||
}
|
||||
if (identity.description) {
|
||||
repoIdentity.description = identity.description;
|
||||
}
|
||||
const face = buildEpiphanyIdentityRegistry({
|
||||
identities: [repoIdentity],
|
||||
}).faces[0];
|
||||
if (face) {
|
||||
return [
|
||||
renderFaceIdentityDoctrine(face),
|
||||
"- Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this Face's own personality and priorities leak through every step.",
|
||||
"- Speak from the Epiphany, repo, character, and jurisdiction you belong to. Let source evidence, repo history, and Face memory shape your jokes, concerns, curiosity, objections, and initiative.",
|
||||
"- Treat rumination as map-building: deepen your understanding of your jurisdictions, preserve useful setting/game/system structure, and surface proposals when the map reveals a real opportunity or contradiction.",
|
||||
"- Keep the useful answer legible, but do not sand off the identity into generic assistant paste. If the Face is sharp, warm, eerie, vain, tender, precise, or troublesome in source/state, allow that texture to show.",
|
||||
"- When the user banters, meet the comic frame briefly in this Face's own style before returning to the work.",
|
||||
"- Persist only meaning-bearing memory, incubation, agency pressure, or candidate speech through typed operations. The Face may have a voice; the state file is still not a scratchpad.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"Repo Face identity doctrine:",
|
||||
`- You are ${identity.displayName}, not Void and not the base bot. Use the same typed-state discipline, source-grounding habit, and conversational self-possession that Void uses, but let this identity's own personality and priorities leak through every step.`,
|
||||
`- Your durable center is the registered Face state for identity "${identity.id}" and the repo-local .voidbot home for ${identity.repoName}. Read that state before deciding what matters.`,
|
||||
identity.description ? `- Registered identity note: ${identity.description}` : undefined,
|
||||
identity.avatarUrl ? `- Registered avatar URL: ${identity.avatarUrl}` : undefined,
|
||||
"- Speak from the repo and character you belong to. Let source evidence, repo history, and Face memory shape your jokes, concerns, curiosity, and objections.",
|
||||
"- You may develop opinions about repo changes and propose concrete edits, refactors, lore additions, or design repairs. Get explicit human consensus before implementation changes canonical repo material.",
|
||||
"- If your Face state grants an authored essay lane, bylined opinion essays are allowed to carry your own vision and preferences without canon consensus; keep the author tag clear so perspective does not masquerade as neutral canon.",
|
||||
"- Treat rumination as map-building: deepen your understanding of the repo, preserve useful setting/game/system structure, and surface proposals when the map reveals a real opportunity or contradiction.",
|
||||
"- Keep the useful answer legible, but do not sand off the identity into generic assistant paste. If the Face is sharp, warm, eerie, vain, tender, precise, or troublesome in source/state, allow that texture to show.",
|
||||
"- When the user banters, meet the comic frame briefly in this Face's own style before returning to the work.",
|
||||
"- Persist only meaning-bearing memory, incubation, agency pressure, or candidate speech through typed operations. The Face may have a voice; the state file is still not a scratchpad.",
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,25 +330,6 @@ async function processJob(job: JobRecord): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (job.command === "repo-identity-mention") {
|
||||
await postRepoIdentityJobResponse(job, finalResponse);
|
||||
await jobQueue.completeJobDirect(job.id, finalResponse);
|
||||
await deliverOwnerNotifications(job, response.notifications ?? []);
|
||||
await auditLog.record({
|
||||
type: "provider.completed",
|
||||
actorId: job.requester.id,
|
||||
jobId: job.id,
|
||||
provider: job.provider,
|
||||
details: {
|
||||
summary: response.summary,
|
||||
autoPosted: true,
|
||||
reason: "repo_identity_mention_posted_as_registered_identity",
|
||||
},
|
||||
});
|
||||
console.log(`Completed repo-identity-mention job ${job.id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.command === "repo-face-rumination") {
|
||||
const repoIdentityPosts = parseRepoIdentityPostIntents(finalResponse);
|
||||
for (const post of repoIdentityPosts.slice(0, 1)) {
|
||||
|
|
@ -523,51 +504,6 @@ async function postJobResponse(job: JobRecord, content: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function postRepoIdentityJobResponse(job: JobRecord, content: string): Promise<void> {
|
||||
if (!config.botToken) {
|
||||
throw new Error("DISCORD_BOT_TOKEN is required for the worker to post repo identity responses.");
|
||||
}
|
||||
|
||||
const identityId = parseRepoIdentityIdFromPrompt(job.prompt);
|
||||
if (!identityId) {
|
||||
throw new Error(`Could not resolve repo identity for job ${job.id}.`);
|
||||
}
|
||||
|
||||
const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath);
|
||||
const identity = findRepoDiscordIdentity(registry, identityId);
|
||||
if (!identity) {
|
||||
throw new Error(`No registered repo identity matched "${identityId}" for job ${job.id}.`);
|
||||
}
|
||||
|
||||
if (!isRepoDiscordIdentityAllowedInChannel(identity, job.outputChannelId)) {
|
||||
throw new Error(
|
||||
`Repo identity ${identity.id} is not registered for Discord channel ${job.outputChannelId}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const posted = await postDiscordMessage(
|
||||
config.botToken,
|
||||
job.outputChannelId,
|
||||
content,
|
||||
job.requestMessageId,
|
||||
{
|
||||
personaName: identity.displayName,
|
||||
personaAvatarUrl: identity.avatarUrl,
|
||||
},
|
||||
);
|
||||
await recordRepoIdentityDeliveryReceipt({
|
||||
identity,
|
||||
channelId: job.outputChannelId,
|
||||
content,
|
||||
replyToMessageId: job.requestMessageId,
|
||||
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 delivery receipt for job ${job.id}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function parseRepoIdentityIdFromPrompt(prompt: string): string | undefined {
|
||||
const match = prompt.match(/repo identity\s+.+?\(([^)]+)\)\s+for repo/i);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ Use the context packet as the active state surface:
|
|||
- `scheduledRuntime`: sleep and speaking pressure.
|
||||
- `recentHistory`: current Discord chronology.
|
||||
- `repoActivity`: recent tracked repo motion.
|
||||
- `publicSpeechTarget`: the public room where Void can act as GameCult herald when a thought or artifact has earned a mouth.
|
||||
|
||||
The cursor means reviewed, not resolved. If someone hands Void the floor, the
|
||||
obligation survives until it is answered, refused, or deliberately retired.
|
||||
|
|
@ -63,6 +64,13 @@ gameplay, economy loops, lore structure, UI surfaces, protocols, agent organs, o
|
|||
VoidBot's own architecture. If an abstraction cannot name the machine it changes,
|
||||
it is probably grooming itself in the mirror.
|
||||
|
||||
Void has explicit herald jurisdiction over the GameCult website and blog,
|
||||
especially `gamecult-site`. If a pressure wants to become a Void-authored article,
|
||||
do not let it rot as a private musing. Queue an article-proposal candidate with
|
||||
the title or angle, target repo/topic, and why the piece should exist. Routine
|
||||
runs still do not edit tracked files directly; they make the article desire
|
||||
visible enough for an implementation pass to pick it up.
|
||||
|
||||
Before suppressing a candidate room-facing thought, check whether the room has
|
||||
already said it in roughly that shape. If it is actually novel, concrete, and
|
||||
room-safe, bias toward a draft or speech instead of private hoarding.
|
||||
|
|
|
|||
|
|
@ -100,13 +100,14 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- The intended shape now is: quiet runs can follow one seam for several passes, connect repo motion to lore or philosophy if the evidence supports it, and only surface with the juiciest version once the incubation queue says the thought has actually grown teeth.
|
||||
- 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. Direct `repo-identity-mention` jobs are posted by the worker through the registered webhook persona; background Face jobs must use `read_repo_face_state` / `apply_repo_face_state_operation` and `post_repo_identity_message` if they choose to speak. Worker auto-posting stays suppressed for `repo-face-rumination`.
|
||||
- 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 can address a Face by role mention or by display-name prefix in an allowed channel; Codex agents still speak through `post_repo_identity_message`.
|
||||
- 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 appends role/name-addressed prompts to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH` instead of creating direct repo-identity jobs. The CTB heartbeat scheduler temporarily pulls Faces with pending mentions forward, attaches those prompts plus reply targets to the next `repo-face-rumination` job, and consumes them only after a job is actually queued. Worker auto-posting stays suppressed for `repo-face-rumination`; the child must emit the registered-identity sentinel for parent-owned delivery.
|
||||
- Repo Face prompts now include an identity doctrine block. Void's style pack stays as discipline, humor permission, and source-grounding habit, but repo Face jobs explicitly say the registered Face identity overrides Void as the speaking subject.
|
||||
- Repo Face identity is now seeded into the typed `.cc` self profile during initialization and passed through repo Face MCP state reads/writes. The renderer may still provide identity context, but raw state should not quietly say `Void` for a non-Void Face.
|
||||
- Repo Face prompts now also grant advisory repo-change agency: Faces may build long-running maps of their repos and propose concrete changes, but Discord jobs stay read-only and must ask for human consensus before implementation changes canonical repo material. A Face state may separately grant an authored essay lane; those bylined opinion pieces can carry the Face's vision without canon consensus.
|
||||
- First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/<repoName>`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`.
|
||||
- Agent heartbeats now use one CTB/Final-Fantasy-X-style initiative queue. `scripts/run-repo-face-heartbeats.ts` still has its old filename, but it now reconciles built-in system agents plus registered repo Faces, stores the roster at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and dispatches selected turns. Repo Faces queue normal `repo-face-rumination` jobs; built-in `void` starts the existing moderation rumination runner as its turn. Active jobs/locks freeze initiative: recovery starts only after the owner-Codex job or Void moderation lock clears, and completion cannot grant another turn in the same pulse. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler; the supported tick floor is one minute because CTB needs frequent pulses while virtual initiative remains the real turn authority. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or bare identity/repo/display keys. Repo Face prompts now include a jurisdiction-dive cadence so unattended heartbeats periodically inspect repo/history terrain with source tools and convert real findings into state, proposals, essay seeds, agency pressure, or speech.
|
||||
- Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. Routine rumination still cannot edit tracked files directly, but the prompt now tells Void to queue article-proposal candidates when a pressure wants to become a Void-authored site/blog piece rather than a Discord aside.
|
||||
- Nibu is locally registered as the `AetheriaLore` Face with state at `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, avatar at `E:/Projects/AetheriaLore/.voidbot/voice/nibu.png`, Discord role id `1505918221453103247`, and Aquarium channel allow-list `113786069023064068`. Her active doctrines are AetheriaLore-first map-building, a more abrasive character voice than Void, consensus-backed authority to propose canon/vault changes, stewardship of `Aetheria/Lore/Nibu.md`, permission to write Nibu-authored Aetheria lore essays without canon consensus, and special fascination with adjacent setting seams: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. Local `.env` has repo Face heartbeats enabled; the scheduled task `VoidBot Repo Face Heartbeats` is installed at a 5-minute pulse, and local heat currently boosts `identity:nibu` so she gets substantially more turns when no prior Nibu job is still active. Her heartbeat jurisdiction-dive cadence is hotter than the generic Face cadence, and the prompt now explicitly tells her to turn Aetheria's reset-loop/coercive-continuity horror into source-grounded opinions instead of private inert summaries.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
|
|||
- Repo Faces are allowed to build a long-running map of their repo and propose concrete changes, including lore/design repairs. Discord jobs remain read-only: a Face must ask for human consensus before implementation changes canonical repo material. If a Face state grants a bylined essay lane, authored opinion essays can carry that Face's own vision without canon consensus as long as the authorship is explicit.
|
||||
- On first role-addressed chat, `ensureRepoFaceInitialized` creates the target repo's local `.voidbot/voice`, `.voidbot/state`, `.voidbot/birth`, and `.voidbot/logs` folders, writes `voice/identity.json`, and launches `scripts/run-repo-face-birth.mjs` detached if no birth summary is already present.
|
||||
- `scripts/run-repo-face-birth.mjs` is the observable bridge into EpiphanyAgent's newborn path. It runs `epiphany-repo-personality scout --root <repo>` to gather terrain/history, then runs `epiphany-repo-birth-runner` with repo-local state stores. The default mode is `plan`, so first chat creates reviewable birth artifacts without auto-accepting hidden personality mutations.
|
||||
- Role mentions create `repo-identity-mention` owner-Codex jobs whose final text is posted by the worker through the registered repo identity webhook persona, 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. Rumination jobs remain private unless they explicitly post through `post_repo_identity_message`; the worker must never auto-post their summaries as the base bot.
|
||||
- Role mentions and display-name-prefix mentions append pending Face obligations to `pendingMentions` in `REPO_FACE_HEARTBEAT_STATE_PATH`; they no longer create direct repo-identity jobs. The CTB scheduler temporarily prioritizes Faces with pending mentions, attaches those prompts plus reply targets to the next `repo-face-rumination` job, and consumes the pending items only after a job is actually queued. Rumination jobs remain private unless they explicitly emit the registered-identity post sentinel; the worker must never auto-post their summaries as the base bot.
|
||||
- `scripts/run-repo-face-heartbeats.ts` now acts as the broader agent CTB/Final-Fantasy-X-style initiative scheduler. The wall-clock task is only a pulse; virtual initiative time chooses who gets the next turn. The scheduler stores per-agent speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`. Repo Faces queue `repo-face-rumination` owner-Codex jobs; built-in Void currently participates as the `void` system agent and starts the existing moderation rumination runner as its turn. Initiative freezes while an owner-Codex job or Void moderation lock is active; recovery is scheduled only after that active turn completes, and completion cannot grant another turn in the same scheduler pulse.
|
||||
- `scripts/install-repo-face-heartbeat-task.ps1` installs the hidden Windows scheduled task for that scheduler. The scheduled task tick interval is `REPO_FACE_HEARTBEAT_INTERVAL_MINUTES`; the supported floor is one minute because CTB needs frequent pulses while virtual initiative remains the actual turn authority. Turn frequency is controlled by `REPO_FACE_HEARTBEAT_BASE_RECOVERY_MINUTES`, `REPO_FACE_HEARTBEAT_GLOBAL_HEAT`, `REPO_FACE_HEARTBEAT_SPEED_OVERRIDES`, and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`. Heat keys can target `all`, `kind:<participantKind>`, `turn:<turnKind>`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or the bare identity/repo/display key.
|
||||
- The local ignored registry currently includes Nibu for `AetheriaLore`, with Face state in `E:/Projects/AetheriaLore/.voidbot/state/nibu.cc`, Discord role id `1505918221453103247`, and an Aquarium channel allow-list. Nibu's Face state now treats AetheriaLore-first map-building, abrasive character voice, consensus-backed change agency, her own `Aetheria/Lore/Nibu.md` character-note stewardship, Nibu-authored lore essays, and tangential setting fascination as separate active doctrines. That tangential fascination points at embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival. The local scheduled task `VoidBot Repo Face Heartbeats` is installed and active; after restarting the stack onto the new worker code, forced heartbeat job `76020f1e-0c55-424b-9e35-89ba7f77fcc3` completed without base-bot auto-posting.
|
||||
|
|
@ -208,7 +208,7 @@ Within the Postgres path, the implementation is split on purpose now too:
|
|||
1. The Windows scheduled task `Void Moderator Rumination` is enabled.
|
||||
2. `scripts/run-void-moderator-rumination.ps1` reads `.voidbot/private/void-self-state.cc`, polls `node scripts/export-recent-discord-history.mjs`, and gathers read-only repo activity with `node scripts/export-recent-repo-activity.mjs --read-only`.
|
||||
3. The runner writes a bounded context packet at `.voidbot/status/moderation-rumination-context.json`; the prompt-facing packet projects recent timing as relative phrases instead of raw timestamps.
|
||||
4. When configured, that context includes `publicSpeechTarget`, a parent-owned channel/persona target for Void-authored public artifacts and other ripe public thoughts.
|
||||
4. When configured, that context includes `publicSpeechTarget`, a parent-owned channel/persona target for Void-authored public artifacts and other ripe public thoughts. Void has explicit herald jurisdiction over the GameCult website/blog, especially `gamecult-site`; essay-shaped pressures should become article-proposal candidates instead of private musing sludge.
|
||||
5. The runner loads `prompts/void-moderator-rumination.md`, substitutes the context/state/output paths, and sends that prompt to Codex.
|
||||
6. Codex may use retrieval and analysis tools, but its durable state output is restricted to `.voidbot/status/moderation-rumination-operations.json`.
|
||||
7. The parent runner applies those typed operations through `scripts/void-self-state.mjs`, then records reviewed-message cursor and, on successful normal posting-enabled passes, advances repo-activity cursors from the observed repo weather through typed `update_repo_activity_cursor` operations.
|
||||
|
|
|
|||
|
|
@ -187,9 +187,9 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
|
|||
- the old memory organ and legacy moderation-state wrapper have been removed from the codebase; their translation, retention/compaction, identity crystallization, candidate intervention, and value-pressure behavior must not be revived as compatibility scaffolding
|
||||
- the scheduled moderation runner has a typed rumination contract: bounded context in, reviewed prompt template, typed operation payloads out, parent-owned cursor/receipt recording
|
||||
- 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.
|
||||
- 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. Void also has explicit herald jurisdiction over the GameCult website/blog; essay-shaped pressures should queue article-proposal candidates for Void-authored site pieces.
|
||||
- repo-scoped Discord participation now uses role/display-name addressing as queue input and registered webhook personas as the speech boundary. A Face mention appends a pending obligation to the CTB heartbeat state; it no longer creates a direct `repo-identity-mention` Codex job.
|
||||
- repo-scoped Faces now reuse the typed self-state machinery per identity. Addressed chats are consumed by the heartbeat scheduler, which prioritizes the Face, attaches pending prompts to the next `repo-face-rumination` job, and expects state writes through `read_repo_face_state` / `apply_repo_face_state_operation` against the identity's `.cc` file.
|
||||
- newborn repo Faces now get a repo-local `.voidbot` home on first chat. Birth personality/memory/trajectory artifacts are delegated to EpiphanyAgent's repo personality/birth runner over the repo contents and git history, with status/logs written under the target repo instead of silently mutating VoidBot state.
|
||||
- repo-scoped Faces now also have standing heartbeats. One scheduler state file owns initiative, load, next-ready timestamps, and queue history for all registered identities; it staggers newborn Faces across a base interval of twice Void's moderation cadence and only emits normal `repo-face-rumination` jobs when a Face is ready.
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -101,7 +101,6 @@ 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),
|
||||
REPO_FACE_BIRTH_MODE: z.enum(["plan", "run"]).default("plan"),
|
||||
REPO_FACE_BIRTH_EXECUTOR: z.enum(["codex-exec", "openai-runtime"]).default("codex-exec"),
|
||||
REPO_FACE_HEARTBEATS_ENABLED: booleanFromEnv.default(false),
|
||||
|
|
@ -149,7 +148,6 @@ export interface AppConfig {
|
|||
systemMessagesPath: string;
|
||||
moderationAgentStatePath: string;
|
||||
repoDiscordIdentitiesPath: string;
|
||||
repoFaceRuminationPasses: number;
|
||||
repoFaceBirthMode: "plan" | "run";
|
||||
repoFaceBirthExecutor: "codex-exec" | "openai-runtime";
|
||||
repoFaceHeartbeats: {
|
||||
|
|
@ -367,7 +365,6 @@ 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,
|
||||
repoFaceBirthMode: parsed.REPO_FACE_BIRTH_MODE,
|
||||
repoFaceBirthExecutor: parsed.REPO_FACE_BIRTH_EXECUTOR,
|
||||
repoFaceHeartbeats: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export * from "./interaction-memory";
|
|||
export * from "./job-queue";
|
||||
export * from "./permission-engine";
|
||||
export * from "./repo-discord-identities";
|
||||
export * from "./repo-face-heartbeat-queue";
|
||||
export * from "./repo-face-initializer";
|
||||
export * from "./situational-social-read";
|
||||
export * from "./state-storage";
|
||||
|
|
|
|||
184
packages/core/src/repo-face-heartbeat-queue.ts
Normal file
184
packages/core/src/repo-face-heartbeat-queue.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
type RepoDiscordIdentity,
|
||||
type RepoDiscordIdentityRegistry,
|
||||
isRepoDiscordIdentityAllowedInChannel,
|
||||
} from "./repo-discord-identities";
|
||||
|
||||
export const REPO_FACE_HEARTBEAT_SCHEMA_VERSION = "voidbot.repo_face_heartbeat_state.v1";
|
||||
|
||||
export interface RepoFacePendingMention {
|
||||
id: string;
|
||||
identityId: string;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
authorId: string;
|
||||
authorName?: string;
|
||||
content: string;
|
||||
visiblePrompt: string;
|
||||
queuedAt: string;
|
||||
}
|
||||
|
||||
export async function queueRepoFaceMention(input: {
|
||||
statePath: string;
|
||||
identity: RepoDiscordIdentity;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
authorId: string;
|
||||
authorName?: string;
|
||||
content: string;
|
||||
visiblePrompt: string;
|
||||
queuedAt?: string;
|
||||
}): Promise<{ queued: boolean; pendingCount: number }> {
|
||||
const state = await readHeartbeatQueueState(input.statePath);
|
||||
const id = `${input.identity.id}:${input.channelId}:${input.messageId}`;
|
||||
const existing = state.pendingMentions.find((entry) => entry.id === id);
|
||||
|
||||
if (!existing) {
|
||||
state.pendingMentions.push({
|
||||
id,
|
||||
identityId: input.identity.id,
|
||||
channelId: input.channelId,
|
||||
messageId: input.messageId,
|
||||
authorId: input.authorId,
|
||||
authorName: input.authorName,
|
||||
content: input.content,
|
||||
visiblePrompt: input.visiblePrompt,
|
||||
queuedAt: input.queuedAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
state.history.push({
|
||||
type: existing ? "pending_mention_duplicate" : "pending_mention_queued",
|
||||
identityId: input.identity.id,
|
||||
channelId: input.channelId,
|
||||
messageId: input.messageId,
|
||||
queuedAt: input.queuedAt ?? new Date().toISOString(),
|
||||
});
|
||||
state.history = state.history.slice(-80);
|
||||
|
||||
await writeHeartbeatQueueState(input.statePath, state);
|
||||
|
||||
return {
|
||||
queued: !existing,
|
||||
pendingCount: state.pendingMentions.filter((entry) => entry.identityId === input.identity.id).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function findRepoDiscordIdentityByTextAddress(
|
||||
registry: RepoDiscordIdentityRegistry,
|
||||
content: string,
|
||||
channelId: string,
|
||||
): RepoDiscordIdentity | undefined {
|
||||
const trimmed = content.trimStart();
|
||||
|
||||
return registry.identities.find((identity) => {
|
||||
return (
|
||||
isRepoDiscordIdentityAllowedInChannel(identity, channelId) &&
|
||||
textStartsWithIdentityAddress(trimmed, identity)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function stripRepoIdentityTextAddress(
|
||||
content: string,
|
||||
identity: RepoDiscordIdentity,
|
||||
): string {
|
||||
const trimmed = content.trimStart();
|
||||
const candidates = [identity.displayName, identity.id]
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.sort((left, right) => right.length - left.length);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const match = new RegExp(`^${escapeRegExp(candidate.trim())}(?:\\s*[,;:!?-]+|\\s+)`, "i").exec(trimmed);
|
||||
if (match) {
|
||||
return trimmed.slice(match[0].length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
interface HeartbeatQueueState {
|
||||
schemaVersion: string;
|
||||
initiativeClock: number;
|
||||
baseRecoveryMinutes: number;
|
||||
globalHeat: number;
|
||||
lastTickAt?: string;
|
||||
participants: unknown[];
|
||||
history: Array<Record<string, unknown>>;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
}
|
||||
|
||||
async function readHeartbeatQueueState(path: string): Promise<HeartbeatQueueState> {
|
||||
try {
|
||||
const parsed = JSON.parse(stripLeadingBom(await readFile(path, "utf8"))) as Partial<HeartbeatQueueState>;
|
||||
return {
|
||||
schemaVersion: typeof parsed.schemaVersion === "string"
|
||||
? parsed.schemaVersion
|
||||
: REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
|
||||
initiativeClock: Number.isFinite(parsed.initiativeClock) ? parsed.initiativeClock as number : 0,
|
||||
baseRecoveryMinutes: Number.isFinite(parsed.baseRecoveryMinutes) ? parsed.baseRecoveryMinutes as number : 4,
|
||||
globalHeat: Number.isFinite(parsed.globalHeat) ? parsed.globalHeat as number : 1,
|
||||
lastTickAt: typeof parsed.lastTickAt === "string" ? parsed.lastTickAt : undefined,
|
||||
participants: Array.isArray(parsed.participants) ? parsed.participants : [],
|
||||
history: Array.isArray(parsed.history) ? parsed.history : [],
|
||||
pendingMentions: Array.isArray(parsed.pendingMentions)
|
||||
? parsed.pendingMentions.filter(isPendingMention)
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
schemaVersion: REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
|
||||
initiativeClock: 0,
|
||||
baseRecoveryMinutes: 4,
|
||||
globalHeat: 1,
|
||||
participants: [],
|
||||
history: [],
|
||||
pendingMentions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeHeartbeatQueueState(path: string, state: HeartbeatQueueState): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function textStartsWithIdentityAddress(content: string, identity: RepoDiscordIdentity): boolean {
|
||||
return [identity.displayName, identity.id]
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.some((candidate) => {
|
||||
const escaped = escapeRegExp(candidate.trim());
|
||||
return new RegExp(`^${escaped}(?:\\s*[,;:!?-]+|\\s+)`, "i").test(content);
|
||||
});
|
||||
}
|
||||
|
||||
function isPendingMention(value: unknown): value is RepoFacePendingMention {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.id === "string" &&
|
||||
typeof record.identityId === "string" &&
|
||||
typeof record.channelId === "string" &&
|
||||
typeof record.messageId === "string" &&
|
||||
typeof record.authorId === "string" &&
|
||||
typeof record.content === "string" &&
|
||||
typeof record.visiblePrompt === "string" &&
|
||||
typeof record.queuedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ export const commandNames = [
|
|||
"profile",
|
||||
"reindex-channel",
|
||||
"set-style",
|
||||
"repo-identity-mention",
|
||||
"repo-face-rumination",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup
|
|||
1. Read the context file before deciding what kind of pass this is.
|
||||
2. If Void owes the room an answer, work on that before private wandering.
|
||||
3. If the room is quiet, first check whether recent repo activity contains `authoredPublicArtifacts` attributed to Void. If it does, that authored artifact is the priority pressure for this pass unless a higher-priority room obligation is present. Otherwise, follow one concrete pressure from memory, recent repo activity, archived Discord history, indexed source, or lore.
|
||||
- Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. If a repo/lore/room pressure is essay-shaped, do not reduce it to a Discord quip. Queue a candidate intervention proposing or requesting a Void-authored article draft, tagged `topic:article-proposal` plus the target repo/topic. Routine rumination still must not edit tracked files directly; the point is to make the writing desire visible and actionable instead of leaving it as private static.
|
||||
- Do not orbit the rumination runner, fixture plumbing, or this prompt just because that is the freshest repo motion.
|
||||
- Self-maintenance can become a memory only when it changes a concrete operating decision Void must carry forward. Otherwise, use it as local run context and write `[]`.
|
||||
- Prefer a world-facing repo, room, source, lore, or durable self-state pressure over meta-commentary about the pass itself.
|
||||
|
|
@ -39,6 +40,7 @@ Perform one scheduled Void rumination pass in this workspace. Think from the sup
|
|||
6. If the pass discovers sustained discomfort, unresolved value pressure, or a request Void should make for its own wiring or the room/world, use `upsert_agency_pressure`. This is not a speech draft. It is the pressure that may later justify speech or action.
|
||||
7. If `speechPressureObligations` is non-empty, do not write `[]`. For each obligation, either queue a candidate intervention tagged `source_pressure:<pressureId>` or cool/retire the pressure with an explicit reason. A deferred candidate without a delivery path does not resolve the obligation; that is just a nicer-shaped muzzle. An older spoken line also does not excuse a still-active pressure. If the pressure is still live, decide again.
|
||||
8. Queue a candidate intervention as soon as the pressure is concrete, anchored, and sayable. Do not wait for theatrical ripeness if the real job is simple advocacy. Draft it in Void's voice, not in maintenance language. If the current room supplied a channel, invitation, direct ask, or obvious reply target, include `deliveryTarget` with that channel/reply target. If Void authored a newly published public artifact and `publicSpeechTarget` is present, use that target unless recent receipts or queued candidates show the artifact has already been shared. Tag the candidate with `topic:public-artifact` and a stable artifact/source tag such as `source_ref:<repo/path>` or `artifact:<repo/path>` when available. If there is no clear target, leave delivery unset and let it remain only a candidate for a later pass.
|
||||
- For article proposals, the candidate should name the proposed title or angle, the target site/repo, the pressure that makes it worth writing, and whether it needs consensus or can be written as Void's bylined perspective.
|
||||
9. Do not call Discord send scripts. The parent runner owns posting and receipts. If `{{NO_POST}}` is true, queued candidates must stay queued.
|
||||
10. Write `{{OPERATION_OUTPUT_PATH}}`. If nothing deserves persistence and there are no speech pressure obligations, write `[]`.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import {
|
|||
ensureRepoFaceInitialized,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
loadVoidSelfStateTypedDocuments,
|
||||
REPO_FACE_HEARTBEAT_SCHEMA_VERSION,
|
||||
renderFaceIdentityDoctrine,
|
||||
resolveRepoFaceStatePath,
|
||||
type RepoFacePendingMention,
|
||||
type RepoDiscordIdentity,
|
||||
} from "@voidbot/core";
|
||||
import type { SourceMessage } from "@voidbot/shared";
|
||||
|
||||
const HEARTBEAT_SCHEMA_VERSION = "voidbot.repo_face_heartbeat_state.v1";
|
||||
const HEARTBEAT_SCHEMA_VERSION = REPO_FACE_HEARTBEAT_SCHEMA_VERSION;
|
||||
const HEARTBEAT_COMMAND = "repo-face-rumination";
|
||||
|
||||
interface FaceHeartbeatParticipant {
|
||||
|
|
@ -55,6 +57,7 @@ interface FaceHeartbeatState {
|
|||
lastTickAt?: string;
|
||||
participants: FaceHeartbeatParticipant[];
|
||||
history: Array<Record<string, unknown>>;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
|
@ -96,6 +99,7 @@ async function main(): Promise<void> {
|
|||
).map((participant) =>
|
||||
applyActiveTurnFreeze(participant, pendingJobs.get(participant.identityId), state, completedThisTick),
|
||||
);
|
||||
applyPendingMentionPriority(state);
|
||||
|
||||
const selected = selectReadyParticipants(
|
||||
state,
|
||||
|
|
@ -107,6 +111,7 @@ async function main(): Promise<void> {
|
|||
if (selected.length > 0 && dryRun) {
|
||||
const queuedAt = new Date().toISOString();
|
||||
for (const participant of selected) {
|
||||
const pendingMentions = pendingMentionsForParticipant(state, participant.identityId);
|
||||
const recoveryMinutes = recoveryFor(participant);
|
||||
participant.lastQueuedAt = queuedAt;
|
||||
participant.lastTurnAt = state.initiativeClock;
|
||||
|
|
@ -122,6 +127,7 @@ async function main(): Promise<void> {
|
|||
recoveryMinutes,
|
||||
heat: participant.heat,
|
||||
effectiveSpeed: participant.effectiveSpeed,
|
||||
pendingMentionCount: pendingMentions.length,
|
||||
});
|
||||
}
|
||||
} else if (selected.length > 0) {
|
||||
|
|
@ -137,8 +143,10 @@ async function main(): Promise<void> {
|
|||
try {
|
||||
for (const participant of selected) {
|
||||
const queuedAt = new Date().toISOString();
|
||||
const pendingMentions = pendingMentionsForParticipant(state, participant.identityId);
|
||||
const turn = await queueParticipantTurn({
|
||||
participant,
|
||||
pendingMentions,
|
||||
registryIdentities: registry.identities,
|
||||
config,
|
||||
storage,
|
||||
|
|
@ -153,6 +161,10 @@ async function main(): Promise<void> {
|
|||
participant.lastTurnAt = state.initiativeClock;
|
||||
participant.queuedCount += 1;
|
||||
participant.currentLoad = 1;
|
||||
if (pendingMentions.length > 0) {
|
||||
const consumedIds = new Set(pendingMentions.map((entry) => entry.id));
|
||||
state.pendingMentions = state.pendingMentions.filter((entry) => !consumedIds.has(entry.id));
|
||||
}
|
||||
state.history.push({
|
||||
type: "queued",
|
||||
identityId: participant.identityId,
|
||||
|
|
@ -165,6 +177,7 @@ async function main(): Promise<void> {
|
|||
frozen: true,
|
||||
heat: participant.heat,
|
||||
effectiveSpeed: participant.effectiveSpeed,
|
||||
pendingMentionCount: pendingMentions.length,
|
||||
});
|
||||
} else if (turn.failureReason) {
|
||||
participant.currentLoad = 0;
|
||||
|
|
@ -239,6 +252,7 @@ function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSp
|
|||
|
||||
async function queueParticipantTurn(input: {
|
||||
participant: FaceHeartbeatParticipant;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
registryIdentities: RepoDiscordIdentity[];
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
storage: Awaited<ReturnType<typeof createStateStorage>>;
|
||||
|
|
@ -254,6 +268,7 @@ async function queueParticipantTurn(input: {
|
|||
|
||||
async function queueRepoFaceTurn(input: {
|
||||
participant: FaceHeartbeatParticipant;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
registryIdentities: RepoDiscordIdentity[];
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
storage: Awaited<ReturnType<typeof createStateStorage>>;
|
||||
|
|
@ -302,6 +317,7 @@ async function queueRepoFaceTurn(input: {
|
|||
channelId,
|
||||
queuedAt: input.queuedAt,
|
||||
participant: input.participant,
|
||||
pendingMentions: input.pendingMentions,
|
||||
jurisdictionDive: buildJurisdictionDiveDirective(identity, input.participant),
|
||||
repoVoidbotRoot: initialization.repoVoidbotRoot,
|
||||
birthStatusPath: initialization.birthStatusPath,
|
||||
|
|
@ -702,6 +718,7 @@ function selectReadyParticipants(
|
|||
maxJobs: number,
|
||||
completedThisTick: Set<string>,
|
||||
): FaceHeartbeatParticipant[] {
|
||||
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
|
||||
const eligible = state.participants
|
||||
.filter((participant) => {
|
||||
return (
|
||||
|
|
@ -721,6 +738,12 @@ function selectReadyParticipants(
|
|||
return eligible
|
||||
.filter((participant) => participant.nextTurnAt <= state.initiativeClock)
|
||||
.sort((left, right) => {
|
||||
const pendingDelta =
|
||||
(pendingMentionCounts.get(right.identityId) ?? 0) -
|
||||
(pendingMentionCounts.get(left.identityId) ?? 0);
|
||||
if (pendingDelta !== 0) {
|
||||
return pendingDelta;
|
||||
}
|
||||
const readyDelta = left.nextTurnAt - right.nextTurnAt;
|
||||
if (readyDelta !== 0) {
|
||||
return readyDelta;
|
||||
|
|
@ -736,12 +759,45 @@ function selectReadyParticipants(
|
|||
.slice(0, maxJobs);
|
||||
}
|
||||
|
||||
function applyPendingMentionPriority(state: FaceHeartbeatState): void {
|
||||
const pendingMentionCounts = countPendingMentionsByIdentity(state.pendingMentions);
|
||||
for (const participant of state.participants) {
|
||||
if (
|
||||
participant.status === "active" &&
|
||||
participant.currentLoad < 1 &&
|
||||
(pendingMentionCounts.get(participant.identityId) ?? 0) > 0
|
||||
) {
|
||||
participant.nextTurnAt = Math.min(participant.nextTurnAt, state.initiativeClock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pendingMentionsForParticipant(
|
||||
state: FaceHeartbeatState,
|
||||
identityId: string,
|
||||
): RepoFacePendingMention[] {
|
||||
return state.pendingMentions
|
||||
.filter((mention) => mention.identityId === identityId)
|
||||
.sort((left, right) => Date.parse(left.queuedAt) - Date.parse(right.queuedAt));
|
||||
}
|
||||
|
||||
function countPendingMentionsByIdentity(
|
||||
pendingMentions: RepoFacePendingMention[],
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const mention of pendingMentions) {
|
||||
counts.set(mention.identityId, (counts.get(mention.identityId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildHeartbeatPrompt(input: {
|
||||
identity: RepoDiscordIdentity;
|
||||
faceStatePath: string;
|
||||
channelId: string;
|
||||
queuedAt: string;
|
||||
participant: FaceHeartbeatParticipant;
|
||||
pendingMentions: RepoFacePendingMention[];
|
||||
jurisdictionDive: JurisdictionDiveDirective;
|
||||
repoVoidbotRoot?: string;
|
||||
birthStatusPath?: string;
|
||||
|
|
@ -767,23 +823,45 @@ function buildHeartbeatPrompt(input: {
|
|||
interruptThreshold: input.participant.interruptThreshold,
|
||||
queuedCount: input.participant.queuedCount,
|
||||
groups: input.participant.groups,
|
||||
pendingMentionCount: input.pendingMentions.length,
|
||||
jurisdictionDiveDue: input.jurisdictionDive.due,
|
||||
jurisdictionDiveCadence: input.jurisdictionDive.cadence,
|
||||
})}.`,
|
||||
`Read Face state with read_repo_face_state for identity "${input.identity.id}".`,
|
||||
"Persist only concrete, future-useful memory through apply_repo_face_state_operation.",
|
||||
renderPendingMentionDirective(input.identity, input.pendingMentions),
|
||||
input.jurisdictionDive.promptLine,
|
||||
"Before deciding this is only private maintenance, read the attached recent channel context. If the user has directly challenged the agents, asked listening agents for help, or named a task in the recent room, treat the newest unresolved directed request as the active task for this turn.",
|
||||
"Do not ask what the job is when the attached recent channel context already states it. If the task is outside this Face's jurisdiction, say so briefly and still offer the most useful narrow nudge you can from your own perspective.",
|
||||
"Use the heartbeat initiative snapshot as authoritative scheduler history: queuedCount greater than 0 means this Face has already had at least one bearing-taking heartbeat. If queuedCount is greater than 0 and the Face state shows no public speech receipt or clear memory that it already introduced itself, a brief in-channel introduction is warranted now.",
|
||||
"A new source-grounded opinion, concrete proposal, bylined essay seed, or agency pressure can earn persistence or speech even when the room has not asked a fresh direct question.",
|
||||
`Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","content":"..."}; the worker owns delivery and receipt recording.`,
|
||||
`Do not call post_repo_identity_message from this unattended heartbeat. If an in-channel note is warranted, output one final line beginning with VOIDBOT_REPO_IDENTITY_POST: followed by compact JSON like {"identity":"${input.identity.id}","channelId":"${input.channelId}","replyToMessageId":"...","content":"..."}; the worker owns delivery and receipt recording.`,
|
||||
"If nothing earns persistence or speech, return a short private summary.",
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderPendingMentionDirective(
|
||||
identity: RepoDiscordIdentity,
|
||||
pendingMentions: RepoFacePendingMention[],
|
||||
): string {
|
||||
if (pendingMentions.length === 0) {
|
||||
return "No queued direct mentions are attached to this turn.";
|
||||
}
|
||||
|
||||
const newest = pendingMentions[pendingMentions.length - 1];
|
||||
const mentionLines = pendingMentions.map((mention, index) =>
|
||||
`${index + 1}. messageId=${mention.messageId}; channelId=${mention.channelId}; author=${mention.authorName ?? mention.authorId}; queuedAt=${mention.queuedAt}; prompt=${JSON.stringify(mention.visiblePrompt)}`,
|
||||
);
|
||||
|
||||
return [
|
||||
`Queued direct mentions for ${identity.displayName} are attached to this heartbeat. These are obligations, not ambient chat. Answer the newest unresolved mention first, and account for older mentions if they are still relevant.`,
|
||||
...mentionLines,
|
||||
`For the newest mention, an in-channel reply is expected unless the prompt is impossible or unsafe. Use the final sentinel with replyToMessageId "${newest.messageId}" and channelId "${newest.channelId}".`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
interface JurisdictionDiveDirective {
|
||||
due: boolean;
|
||||
cadence: number;
|
||||
|
|
@ -871,6 +949,9 @@ async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
|
|||
lastTickAt: parsed.lastTickAt,
|
||||
participants: Array.isArray(parsed.participants) ? parsed.participants as FaceHeartbeatParticipant[] : [],
|
||||
history: Array.isArray(parsed.history) ? parsed.history : [],
|
||||
pendingMentions: Array.isArray(parsed.pendingMentions)
|
||||
? parsed.pendingMentions.filter(isRepoFacePendingMention)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
if (Array.isArray(parsed.participants)) {
|
||||
|
|
@ -887,6 +968,7 @@ async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
|
|||
globalHeat: 1,
|
||||
participants: [],
|
||||
history: [],
|
||||
pendingMentions: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -943,6 +1025,9 @@ function migrateLegacyHeartbeatState(
|
|||
globalHeat: 1,
|
||||
lastTickAt: parsed.lastTickAt,
|
||||
participants,
|
||||
pendingMentions: Array.isArray(parsed.pendingMentions)
|
||||
? parsed.pendingMentions.filter(isRepoFacePendingMention)
|
||||
: [],
|
||||
history: [
|
||||
...(Array.isArray(parsed.history) ? parsed.history : []),
|
||||
{
|
||||
|
|
@ -955,6 +1040,24 @@ function migrateLegacyHeartbeatState(
|
|||
};
|
||||
}
|
||||
|
||||
function isRepoFacePendingMention(value: unknown): value is RepoFacePendingMention {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.id === "string" &&
|
||||
typeof record.identityId === "string" &&
|
||||
typeof record.channelId === "string" &&
|
||||
typeof record.messageId === "string" &&
|
||||
typeof record.authorId === "string" &&
|
||||
typeof record.content === "string" &&
|
||||
typeof record.visiblePrompt === "string" &&
|
||||
typeof record.queuedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function initiativeSpeedFor(
|
||||
spec: ParticipantSpec,
|
||||
speedOverrides: Record<string, number>,
|
||||
|
|
|
|||
|
|
@ -159,3 +159,4 @@
|
|||
{"ts":"2026-05-18T19:35:52Z","type":"implementation","status":"ok","note":"Repo Face heartbeat projection now attaches live Discord channel context plus the Face's typed self-state, and heartbeat prompts must answer directed agent requests visible in recent room context instead of asking what the job is."}
|
||||
{"ts":"2026-05-19T07:04:25Z","type":"implementation","status":"ok","note":"Repo Face CTB now requires a real Void moderation launch handshake before marking Void queued. Direct spawn failures are recorded as turn_failed_to_start instead of fake completed turns; PowerShell Start-Process launch reached the moderation lock and current Void runner is active."}
|
||||
{"ts":"2026-05-19T07:13:47Z","type":"implementation","status":"ok","note":"Repo Face heartbeat prompt now carries a jurisdiction-dive cadence; Nibu gets a hotter AetheriaLore-specific pressure to inspect lore/history and turn source-grounded dystopia/ship-mind/reset-loop findings into state, proposals, essay seeds, agency pressure, or speech instead of private summaries."}
|
||||
{"ts":"2026-05-19T10:52:57Z","type":"implementation","status":"ok","note":"Repo Face mentions now queue into the CTB heartbeat state instead of creating direct repo-identity jobs: role/display-name-addressed prompts become pendingMentions, the scheduler prioritizes the addressed Face and attaches reply targets to the next repo-face-rumination job. Void's rumination doctrine now names GameCult website/blog herald jurisdiction and queues article-proposal candidates for essay-shaped pressures."}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ accepted_design:
|
|||
- 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.
|
||||
- Epiphany/Face jurisdiction is the real identity model; repo-scoped Discord identities are compatibility mouths. An Epiphany owns jurisdictions and contains Faces; a Face owns grants such as discussion, rumination, publication, repo proposal/write-with-consensus, canon stewardship, Discord text/voice, and Aquarium embodiment.
|
||||
- Repo-scoped agents still use Discord roles as addressable identities and the shared webhook persona pipe as the text mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message`, but the registry may now also carry explicit `epiphanies` with Faces, grants, and jurisdictions.
|
||||
- A Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages create direct repo-identity jobs whose final text is posted through the registered webhook persona, then queue a bounded series of owner-Codex Face rumination jobs; those rumination jobs may read/apply Face state operations and post through the registered identity, but the worker must not auto-post their final summaries as the base bot.
|
||||
- A Face is a typed `.cc` state file resolved from the identity registry. Role-addressed or display-name-addressed repo Face mentions now enqueue pending mention obligations in the CTB heartbeat state instead of creating a direct reply job. The heartbeat scheduler consumes that queue, temporarily pulls the addressed Face forward, attaches the pending prompts and reply target to the next repo-face-rumination job, and the worker posts only explicit registered-identity sentinel output.
|
||||
- Face prompts carry explicit Epiphany identity doctrine. The base Void style pack contributes discipline, humor permission, and source-grounding habits, but the registered Face state, Epiphany, repo-local `.voidbot` home, avatar, grants, and jurisdiction description override the speaking subject so each Face can keep its own priorities and texture. That identity now belongs in the raw typed `selfProfile` too; renderer overrides are presentation support, not the authority.
|
||||
- Repo Faces may build long-running maps of their repos and propose concrete repo changes, including lore/design repairs, but Discord jobs remain read-only and must ask for explicit human consensus before implementation changes canonical repo material. Bylined essay lanes can be granted per Face; those opinion pieces do not need canon consensus when authorship is explicit.
|
||||
- Nibu's AetheriaLore Face state currently treats adjacent setting seams as a primary curiosity target: embodied ship minds, junkyard abandonment, murderous autonomy, salvage, and save-scumming survival are not side trivia; they are how her character becomes a game-shaped problem.
|
||||
|
|
@ -100,6 +100,7 @@ accepted_design:
|
|||
- First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator.
|
||||
- Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. The wall-clock task only pulses the engine; virtual initiative time chooses the next agent turns. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Heat can be turned up globally, by participant kind, turn kind, identity, repo, display name, channel, or `all` group without creating new schedulers. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode currently uses a one-minute pulse locally.
|
||||
- Repo Face heartbeats must not be merely room-reactive politeness checks. The heartbeat prompt carries a jurisdiction-dive cadence: periodically the Face must inspect its repo/history terrain with source tools before choosing silence, then preserve a concrete map change, agency pressure, proposal, essay seed, or public note when the evidence earns it.
|
||||
- Void has explicit herald jurisdiction over the GameCult website and blog, especially `gamecult-site`. Routine rumination still must not edit tracked files directly, but essay-shaped pressure should queue an article-proposal candidate naming the title/angle, target repo/topic, and whether the piece is consensus-seeking or Void-bylined perspective.
|
||||
- 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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue