Add Void to agent initiative roster
This commit is contained in:
parent
518ec4ce90
commit
a55ecd09a8
5 changed files with 245 additions and 109 deletions
|
|
@ -106,7 +106,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- 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`.
|
||||
- Repo Faces now also have standing heartbeats. `scripts/run-repo-face-heartbeats.ts` owns one CTB/Final-Fantasy-X-style initiative queue for all registered identities, stores it at `REPO_FACE_HEARTBEAT_STATE_PATH`, advances virtual initiative time independently of wall-clock due dates, and queues selected identities as normal `repo-face-rumination` jobs. Active jobs freeze their Face's initiative: recovery starts only after the owner-Codex job leaves approved/running, 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. Heat knobs are `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; overrides can target `all`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or bare identity/repo/display keys.
|
||||
- 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. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ This note is the source-grounded description of how the live VoidBot stack is sh
|
|||
- 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.
|
||||
- `scripts/run-repo-face-heartbeats.ts` gives every registered repo Face a standing 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-Face speed, reaction bias, load, groups, heat, effective speed, next-turn time, active turn markers, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`, then queues selected Faces as `repo-face-rumination` owner-Codex jobs. A Face's initiative is frozen while its owner-Codex job is approved/running; recovery is scheduled only after that active job 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`; 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`, `identity:<id>`, `repo:<repo>`, `display:<name>`, `channel:<id>`, or the bare identity/repo/display key.
|
||||
- `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`; 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.
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import "dotenv/config";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
|
|
@ -21,6 +22,8 @@ const HEARTBEAT_COMMAND = "repo-face-rumination";
|
|||
|
||||
interface FaceHeartbeatParticipant {
|
||||
identityId: string;
|
||||
participantKind: "repo_face" | "system_agent";
|
||||
turnKind: "repo_face_rumination" | "void_moderation";
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
initiativeSpeed: number;
|
||||
|
|
@ -73,14 +76,14 @@ async function main(): Promise<void> {
|
|||
const now = new Date();
|
||||
const pendingJobs = dryRun
|
||||
? new Map<string, string>()
|
||||
: await listExistingFaceJobs(config.databaseDsn, config.stateStorageBackend, config);
|
||||
: await listExistingActiveTurns(config.databaseDsn, config.stateStorageBackend, config);
|
||||
|
||||
state.baseRecoveryMinutes = config.repoFaceHeartbeats.baseRecoveryMinutes;
|
||||
state.globalHeat = config.repoFaceHeartbeats.globalHeat;
|
||||
const completedThisTick = new Set<string>();
|
||||
state.participants = reconcileParticipants(
|
||||
state.participants,
|
||||
registry.identities,
|
||||
buildParticipantSpecs(registry.identities),
|
||||
config.repoFaceHeartbeats.defaultChannelId,
|
||||
config.repoFaceHeartbeats.speedOverrides,
|
||||
config.repoFaceHeartbeats.heatOverrides,
|
||||
|
|
@ -129,84 +132,31 @@ async function main(): Promise<void> {
|
|||
});
|
||||
|
||||
try {
|
||||
const contextBuilder = new ContextBuilder();
|
||||
for (const participant of selected) {
|
||||
const identity = registry.identities.find((entry) => entry.id === participant.identityId);
|
||||
if (!identity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelId = identity.allowedChannelIds[0] ?? config.repoFaceHeartbeats.defaultChannelId;
|
||||
if (!channelId) {
|
||||
participant.status = "blocked";
|
||||
participant.constraints = mergeStrings(
|
||||
participant.constraints,
|
||||
"No heartbeat channel is configured for this Face.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const queuedAt = new Date().toISOString();
|
||||
const initialization = await ensureRepoFaceInitialized({
|
||||
identity,
|
||||
storageRoot: config.storageRoot,
|
||||
sourceRepoRoot: config.sourceRepoRoot,
|
||||
epiphanyAgentRoot: config.epiphanyAgentRoot,
|
||||
workspaceRoot: process.cwd(),
|
||||
birthMode: config.repoFaceBirthMode,
|
||||
birthExecutor: config.repoFaceBirthExecutor,
|
||||
});
|
||||
const prompt = buildHeartbeatPrompt({
|
||||
identity,
|
||||
faceStatePath: resolveRepoFaceStatePath(identity, config.storageRoot),
|
||||
channelId,
|
||||
queuedAt,
|
||||
const turn = await queueParticipantTurn({
|
||||
participant,
|
||||
repoVoidbotRoot: initialization.repoVoidbotRoot,
|
||||
birthStatusPath: initialization.birthStatusPath,
|
||||
});
|
||||
const contextBundle = contextBuilder.build({
|
||||
prompt,
|
||||
actor: {
|
||||
id: "voidbot-repo-face-heartbeat",
|
||||
displayName: "VoidBot Repo Face Heartbeat",
|
||||
isAdmin: true,
|
||||
isBot: true,
|
||||
},
|
||||
guildContext: {
|
||||
channelId,
|
||||
},
|
||||
recentMessages: [],
|
||||
retrieval: [],
|
||||
});
|
||||
const requestMessageId = `repo-face-heartbeat:${identity.id}:${queuedAt}`;
|
||||
const result = await storage.jobQueue.createJob({
|
||||
command: HEARTBEAT_COMMAND,
|
||||
provider: "owner_codex",
|
||||
runApprovalRequired: false,
|
||||
postApprovalRequired: false,
|
||||
requester: contextBundle.actor,
|
||||
guildContext: contextBundle.guildContext,
|
||||
prompt,
|
||||
contextBundle,
|
||||
outputChannelId: channelId,
|
||||
requestMessageId,
|
||||
initialState: "approved",
|
||||
registryIdentities: registry.identities,
|
||||
config,
|
||||
storage,
|
||||
queuedAt,
|
||||
});
|
||||
|
||||
if (result.created) {
|
||||
queuedIdentityIds.push(identity.id);
|
||||
if (turn.created) {
|
||||
queuedIdentityIds.push(participant.identityId);
|
||||
participant.lastQueuedAt = queuedAt;
|
||||
participant.activeTurnStartedAt = state.initiativeClock;
|
||||
participant.activeJobId = result.job.id;
|
||||
participant.activeJobId = turn.activeJobId;
|
||||
participant.lastTurnAt = state.initiativeClock;
|
||||
participant.queuedCount += 1;
|
||||
participant.currentLoad = 1;
|
||||
state.history.push({
|
||||
type: "queued",
|
||||
identityId: identity.id,
|
||||
jobId: result.job.id,
|
||||
requestMessageId,
|
||||
identityId: participant.identityId,
|
||||
participantKind: participant.participantKind,
|
||||
turnKind: participant.turnKind,
|
||||
activeJobId: turn.activeJobId,
|
||||
requestMessageId: turn.requestMessageId,
|
||||
queuedAt,
|
||||
initiativeClock: state.initiativeClock,
|
||||
frozen: true,
|
||||
|
|
@ -237,7 +187,163 @@ async function main(): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
async function listExistingFaceJobs(
|
||||
interface ParticipantSpec {
|
||||
id: string;
|
||||
participantKind: FaceHeartbeatParticipant["participantKind"];
|
||||
turnKind: FaceHeartbeatParticipant["turnKind"];
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
allowedChannelIds: string[];
|
||||
identity?: RepoDiscordIdentity;
|
||||
}
|
||||
|
||||
function buildParticipantSpecs(identities: RepoDiscordIdentity[]): ParticipantSpec[] {
|
||||
return [
|
||||
{
|
||||
id: "void",
|
||||
participantKind: "system_agent",
|
||||
turnKind: "void_moderation",
|
||||
repoName: "VoidBot",
|
||||
displayName: "Void",
|
||||
allowedChannelIds: [],
|
||||
},
|
||||
...identities.map((identity) => ({
|
||||
id: identity.id,
|
||||
participantKind: "repo_face" as const,
|
||||
turnKind: "repo_face_rumination" as const,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
allowedChannelIds: identity.allowedChannelIds,
|
||||
identity,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
async function queueParticipantTurn(input: {
|
||||
participant: FaceHeartbeatParticipant;
|
||||
registryIdentities: RepoDiscordIdentity[];
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
storage: Awaited<ReturnType<typeof createStateStorage>>;
|
||||
queuedAt: string;
|
||||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string }> {
|
||||
switch (input.participant.turnKind) {
|
||||
case "repo_face_rumination":
|
||||
return queueRepoFaceTurn(input);
|
||||
case "void_moderation":
|
||||
return startVoidModerationTurn(input.queuedAt);
|
||||
}
|
||||
}
|
||||
|
||||
async function queueRepoFaceTurn(input: {
|
||||
participant: FaceHeartbeatParticipant;
|
||||
registryIdentities: RepoDiscordIdentity[];
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
storage: Awaited<ReturnType<typeof createStateStorage>>;
|
||||
queuedAt: string;
|
||||
}): Promise<{ created: boolean; activeJobId?: string; requestMessageId?: string }> {
|
||||
const identity = input.registryIdentities.find((entry) => entry.id === input.participant.identityId);
|
||||
if (!identity) {
|
||||
return { created: false };
|
||||
}
|
||||
|
||||
const channelId = identity.allowedChannelIds[0] ?? input.config.repoFaceHeartbeats.defaultChannelId;
|
||||
if (!channelId) {
|
||||
input.participant.status = "blocked";
|
||||
input.participant.constraints = mergeStrings(
|
||||
input.participant.constraints,
|
||||
"No heartbeat channel is configured for this Face.",
|
||||
);
|
||||
return { created: false };
|
||||
}
|
||||
|
||||
const contextBuilder = new ContextBuilder();
|
||||
const initialization = await ensureRepoFaceInitialized({
|
||||
identity,
|
||||
storageRoot: input.config.storageRoot,
|
||||
sourceRepoRoot: input.config.sourceRepoRoot,
|
||||
epiphanyAgentRoot: input.config.epiphanyAgentRoot,
|
||||
workspaceRoot: process.cwd(),
|
||||
birthMode: input.config.repoFaceBirthMode,
|
||||
birthExecutor: input.config.repoFaceBirthExecutor,
|
||||
});
|
||||
const prompt = buildHeartbeatPrompt({
|
||||
identity,
|
||||
faceStatePath: resolveRepoFaceStatePath(identity, input.config.storageRoot),
|
||||
channelId,
|
||||
queuedAt: input.queuedAt,
|
||||
participant: input.participant,
|
||||
repoVoidbotRoot: initialization.repoVoidbotRoot,
|
||||
birthStatusPath: initialization.birthStatusPath,
|
||||
});
|
||||
const contextBundle = contextBuilder.build({
|
||||
prompt,
|
||||
actor: {
|
||||
id: "voidbot-agent-heartbeat",
|
||||
displayName: "VoidBot Agent Heartbeat",
|
||||
isAdmin: true,
|
||||
isBot: true,
|
||||
},
|
||||
guildContext: {
|
||||
channelId,
|
||||
},
|
||||
recentMessages: [],
|
||||
retrieval: [],
|
||||
});
|
||||
const requestMessageId = `agent-heartbeat:${identity.id}:${input.queuedAt}`;
|
||||
const result = await input.storage.jobQueue.createJob({
|
||||
command: HEARTBEAT_COMMAND,
|
||||
provider: "owner_codex",
|
||||
runApprovalRequired: false,
|
||||
postApprovalRequired: false,
|
||||
requester: contextBundle.actor,
|
||||
guildContext: contextBundle.guildContext,
|
||||
prompt,
|
||||
contextBundle,
|
||||
outputChannelId: channelId,
|
||||
requestMessageId,
|
||||
initialState: "approved",
|
||||
});
|
||||
|
||||
return {
|
||||
created: result.created,
|
||||
activeJobId: result.job.id,
|
||||
requestMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
function startVoidModerationTurn(
|
||||
queuedAt: string,
|
||||
): { created: boolean; activeJobId?: string; requestMessageId?: string } {
|
||||
const runnerScript = resolve(process.cwd(), "scripts", "run-void-moderator-rumination.ps1");
|
||||
const child = spawn(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
runnerScript,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
|
||||
return {
|
||||
created: true,
|
||||
activeJobId: child.pid ? `process:${child.pid}` : `process:void-moderation:${queuedAt}`,
|
||||
requestMessageId: `agent-heartbeat:void:${queuedAt}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listExistingActiveTurns(
|
||||
databaseDsn: string,
|
||||
stateStorageBackend: "file" | "postgres",
|
||||
config: ReturnType<typeof loadConfig>,
|
||||
|
|
@ -258,21 +364,36 @@ async function listExistingFaceJobs(
|
|||
continue;
|
||||
}
|
||||
const match =
|
||||
job.requestMessageId?.match(/^agent-heartbeat:([^:]+):/) ??
|
||||
job.requestMessageId?.match(/^repo-face-heartbeat:([^:]+):/) ??
|
||||
job.requestMessageId?.match(/:repo-face:([^:]+):\d+$/);
|
||||
if (match) {
|
||||
active.set(match[1], job.id);
|
||||
}
|
||||
}
|
||||
const voidLock = await readRecentLock(resolve(config.storageRoot, "status", "moderation-rumination.lock"), 20);
|
||||
if (voidLock) {
|
||||
active.set("void", "lock:moderation-rumination");
|
||||
}
|
||||
return active;
|
||||
} finally {
|
||||
await storage.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecentLock(path: string, maxAgeMinutes: number): Promise<boolean> {
|
||||
try {
|
||||
const info = await stat(path);
|
||||
const ageMs = Date.now() - info.mtimeMs;
|
||||
return ageMs >= 0 && ageMs < maxAgeMinutes * 60_000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileParticipants(
|
||||
existing: FaceHeartbeatParticipant[],
|
||||
identities: RepoDiscordIdentity[],
|
||||
specs: ParticipantSpec[],
|
||||
defaultChannelId: string | undefined,
|
||||
speedOverrides: Record<string, number>,
|
||||
heatOverrides: Record<string, number>,
|
||||
|
|
@ -281,14 +402,14 @@ function reconcileParticipants(
|
|||
globalHeat: number,
|
||||
): FaceHeartbeatParticipant[] {
|
||||
const existingById = new Map(existing.map((entry) => [entry.identityId, entry]));
|
||||
const count = Math.max(identities.length, 1);
|
||||
const count = Math.max(specs.length, 1);
|
||||
|
||||
return identities.map((identity, index) => {
|
||||
const current = existingById.get(identity.id);
|
||||
const hasChannel = Boolean(identity.allowedChannelIds[0] || defaultChannelId);
|
||||
const speed = initiativeSpeedFor(identity, speedOverrides);
|
||||
const groups = initiativeGroupsFor(identity);
|
||||
const heat = heatFor(identity, groups, globalHeat, heatOverrides);
|
||||
return specs.map((spec, index) => {
|
||||
const current = existingById.get(spec.id);
|
||||
const hasChannel = spec.participantKind === "system_agent" || Boolean(spec.allowedChannelIds[0] || defaultChannelId);
|
||||
const speed = initiativeSpeedFor(spec, speedOverrides);
|
||||
const groups = initiativeGroupsFor(spec);
|
||||
const heat = heatFor(spec, groups, globalHeat, heatOverrides);
|
||||
const effectiveSpeed = clamp(speed * heat, 0.1, 12);
|
||||
const nextTurnAt = Number.isFinite(current?.nextTurnAt)
|
||||
? current.nextTurnAt
|
||||
|
|
@ -296,8 +417,10 @@ function reconcileParticipants(
|
|||
if (current) {
|
||||
return {
|
||||
...current,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
participantKind: spec.participantKind,
|
||||
turnKind: spec.turnKind,
|
||||
repoName: spec.repoName,
|
||||
displayName: spec.displayName,
|
||||
initiativeSpeed: speed,
|
||||
groups,
|
||||
heat,
|
||||
|
|
@ -307,7 +430,7 @@ function reconcileParticipants(
|
|||
constraints: mergeStrings(
|
||||
mergeStrings(
|
||||
current.constraints,
|
||||
"Face heartbeat uses CTB-style owner-Codex repo-face-rumination jobs.",
|
||||
"Agent heartbeat uses CTB-style turns.",
|
||||
),
|
||||
"The wall-clock task only ticks the initiative engine; virtual initiative chooses turns.",
|
||||
),
|
||||
|
|
@ -320,12 +443,14 @@ function reconcileParticipants(
|
|||
}
|
||||
|
||||
return {
|
||||
identityId: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
identityId: spec.id,
|
||||
participantKind: spec.participantKind,
|
||||
turnKind: spec.turnKind,
|
||||
repoName: spec.repoName,
|
||||
displayName: spec.displayName,
|
||||
initiativeSpeed: speed,
|
||||
reactionBias: reactionBiasFor(identity),
|
||||
interruptThreshold: interruptThresholdFor(identity),
|
||||
reactionBias: reactionBiasFor(spec),
|
||||
interruptThreshold: interruptThresholdFor(spec),
|
||||
currentLoad: 0,
|
||||
status: hasChannel ? "active" : "blocked",
|
||||
groups,
|
||||
|
|
@ -335,7 +460,7 @@ function reconcileParticipants(
|
|||
nextTurnAt,
|
||||
queuedCount: 0,
|
||||
constraints: [
|
||||
"Face heartbeat uses CTB-style owner-Codex repo-face-rumination jobs.",
|
||||
"Agent heartbeat uses CTB-style turns.",
|
||||
"The wall-clock task only ticks the initiative engine; virtual initiative chooses turns.",
|
||||
"Worker final summaries are not auto-posted as the base bot.",
|
||||
],
|
||||
|
|
@ -606,29 +731,35 @@ function migrateLegacyHeartbeatState(
|
|||
}
|
||||
|
||||
function initiativeSpeedFor(
|
||||
identity: RepoDiscordIdentity,
|
||||
spec: ParticipantSpec,
|
||||
speedOverrides: Record<string, number>,
|
||||
): number {
|
||||
const override = speedOverrides[identity.id.toLowerCase()];
|
||||
const override = speedOverrides[spec.id.toLowerCase()];
|
||||
if (override !== undefined) {
|
||||
return clamp(override, 0.35, 6);
|
||||
}
|
||||
|
||||
return clamp(0.85 + stableUnit(identity.id, "speed") * 0.45, 0.75, 1.3);
|
||||
if (spec.id === "void") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return clamp(0.85 + stableUnit(spec.id, "speed") * 0.45, 0.75, 1.3);
|
||||
}
|
||||
|
||||
function initiativeGroupsFor(identity: RepoDiscordIdentity): string[] {
|
||||
function initiativeGroupsFor(spec: ParticipantSpec): string[] {
|
||||
return Array.from(new Set([
|
||||
"all",
|
||||
`identity:${normalizeKey(identity.id)}`,
|
||||
`repo:${normalizeKey(identity.repoName)}`,
|
||||
`display:${normalizeKey(identity.displayName)}`,
|
||||
...identity.allowedChannelIds.map((channelId) => `channel:${channelId}`),
|
||||
`kind:${spec.participantKind}`,
|
||||
`turn:${spec.turnKind}`,
|
||||
`identity:${normalizeKey(spec.id)}`,
|
||||
`repo:${normalizeKey(spec.repoName)}`,
|
||||
`display:${normalizeKey(spec.displayName)}`,
|
||||
...spec.allowedChannelIds.map((channelId) => `channel:${channelId}`),
|
||||
]));
|
||||
}
|
||||
|
||||
function heatFor(
|
||||
identity: RepoDiscordIdentity,
|
||||
spec: ParticipantSpec,
|
||||
groups: string[],
|
||||
globalHeat: number,
|
||||
heatOverrides: Record<string, number>,
|
||||
|
|
@ -636,9 +767,9 @@ function heatFor(
|
|||
const keys = [
|
||||
"all",
|
||||
...groups,
|
||||
normalizeKey(identity.id),
|
||||
normalizeKey(identity.repoName),
|
||||
normalizeKey(identity.displayName),
|
||||
normalizeKey(spec.id),
|
||||
normalizeKey(spec.repoName),
|
||||
normalizeKey(spec.displayName),
|
||||
];
|
||||
return clamp(
|
||||
keys.reduce((heat, key) => heat * (heatOverrides[key] ?? 1), globalHeat),
|
||||
|
|
@ -647,12 +778,16 @@ function heatFor(
|
|||
);
|
||||
}
|
||||
|
||||
function reactionBiasFor(identity: RepoDiscordIdentity): number {
|
||||
return clamp(0.2 + stableUnit(identity.id, "reaction") * 0.55, 0.2, 0.75);
|
||||
function reactionBiasFor(spec: ParticipantSpec): number {
|
||||
return spec.id === "void"
|
||||
? 0.55
|
||||
: clamp(0.2 + stableUnit(spec.id, "reaction") * 0.55, 0.2, 0.75);
|
||||
}
|
||||
|
||||
function interruptThresholdFor(identity: RepoDiscordIdentity): number {
|
||||
return clamp(0.45 + stableUnit(identity.id, "threshold") * 0.35, 0.45, 0.8);
|
||||
function interruptThresholdFor(spec: ParticipantSpec): number {
|
||||
return spec.id === "void"
|
||||
? 0.5
|
||||
: clamp(0.45 + stableUnit(spec.id, "threshold") * 0.35, 0.45, 0.8);
|
||||
}
|
||||
|
||||
function stableUnit(id: string, salt: string): number {
|
||||
|
|
|
|||
|
|
@ -154,3 +154,4 @@
|
|||
{"ts":"2026-05-18T17:06:08Z","type":"implementation","status":"ok","note":"Typed self-state write normalization now parent-stamps model-originated short-term memory and candidate intervention writes, clamps existing future timestamps on write, and defers targetless queued candidates with a normalization tag. Live .voidbot/private/void-self-state.cc repair cleared the inspection findings for rumination-2026-05-18-localcastbridge-runtime-sync-observability-gate, rumination-2026-05-18-aquasynth-compiler-custody-public-claim, and rumination-2026-05-18-localcastbridge-audio-field-gate."}
|
||||
{"ts":"2026-05-18T17:30:22Z","type":"implementation","status":"ok","note":"Repo Face heartbeats now use a CTB-style initiative scheduler: wall-clock ticks only pulse the engine while virtual initiative clock, per-Face speed, load, groups, heat, and effective speed decide turns. Config now supports global heat plus identity/repo/display/channel/all heat overrides. Local runtime is installed at a 5-minute pulse with identity:nibu heat 2.5, and live forced ticks queued Nibu jobs 7418afa5 and be95c045, then another post-patch Nibu turn."}
|
||||
{"ts":"2026-05-18T17:52:49Z","type":"bugfix","status":"ok","note":"Repo Face CTB initiative now freezes a Face while its owner-Codex rumination job is approved/running. Queuing marks activeTurnStartedAt/activeJobId and does not spend recovery; once the job completes, the next scheduler pulse clears load and schedules recovery from completion without allowing the same pulse to immediately queue another turn. Live verification with Nibu job c65f2dcf showed completion tick queuedCount 0, currentLoad 0, and nextTurnAt moved from 26.8375 to 30.1764."}
|
||||
{"ts":"2026-05-18T18:00:31Z","type":"implementation","status":"ok","note":"Agent CTB roster now includes built-in Void as a system_agent participant alongside repo Faces. The scheduler dispatches repo Faces as repo-face-rumination owner-Codex jobs and dispatches Void by starting the existing moderation rumination runner, with moderation-rumination.lock treated as Void's active turn. Live state now shows participants void and nibu; the old independent Void Moderator Rumination task was disabled so CTB owns Void moderation timing after the currently running pass clears."}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ accepted_design:
|
|||
- 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.
|
||||
- Nibu now stewards `Aetheria/Lore/Nibu.md` and may produce Nibu-authored Aetheria lore essays as opinionated perspective. Character-note/canon edits still need consensus; bylined essays do not.
|
||||
- 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.
|
||||
- Repo Face standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per Face. The wall-clock task only pulses the engine; virtual initiative time chooses the next Face turns. The scheduler stores per-Face speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, and emits ordinary `repo-face-rumination` jobs so the existing Face tools remain the only memory/speech boundary. Heat can be turned up globally, by identity, repo, display name, channel, or `all` group without creating new schedulers. A Face's initiative is frozen while its owner-Codex job is active; recovery is scheduled after completion, not when the job is queued.
|
||||
- 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.
|
||||
- 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