Add initiative scheduler for repo Face heartbeats
This commit is contained in:
parent
28e4fb6708
commit
7292998a37
10 changed files with 525 additions and 0 deletions
|
|
@ -66,6 +66,12 @@ REPO_DISCORD_IDENTITIES_PATH=.voidbot/private/repo-discord-identities.json
|
|||
REPO_FACE_RUMINATION_PASSES=3
|
||||
REPO_FACE_BIRTH_MODE=plan
|
||||
REPO_FACE_BIRTH_EXECUTOR=codex-exec
|
||||
REPO_FACE_HEARTBEATS_ENABLED=false
|
||||
REPO_FACE_HEARTBEAT_STATE_PATH=.voidbot/status/repo-face-heartbeats.json
|
||||
REPO_FACE_HEARTBEAT_TASK_NAME=VoidBot Repo Face Heartbeats
|
||||
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES=15
|
||||
REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK=2
|
||||
REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID=
|
||||
EPIPHANY_AGENT_ROOT=E:/Projects/EpiphanyAgent
|
||||
INDEX_ALL_CHANNELS=false
|
||||
INDEXED_CHANNEL_IDS=
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- Repo identities now have a cleaner address/speech split. A private registry at `REPO_DISCORD_IDENTITIES_PATH` maps identity id/repo/display name/avatar/role/channel allow-list; Discord users tag the role, while Codex agents speak through `post_repo_identity_message`. The worker suppresses base-bot auto-posting for `repo-identity-mention` jobs so the registered identity tool remains the mouth.
|
||||
- Repo identities also have Face state now. The bot resolves each identity to `.voidbot/private/repo-faces/<identity>.cc` unless `faceStatePath` overrides it, ensures missing Discord roles on startup when `DISCORD_GUILD_ID` and permissions allow, and queues `REPO_FACE_RUMINATION_PASSES` owner-Codex jobs after role-addressed chats. Face jobs must use `read_repo_face_state` / `apply_repo_face_state_operation` and `post_repo_identity_message`; worker auto-posting stays suppressed for `repo-face-rumination`.
|
||||
- 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 Ghostlight-style initiative queue for all registered identities, stores it at `REPO_FACE_HEARTBEAT_STATE_PATH`, staggers newborn Faces across twice Void's moderation interval, and queues ready identities as normal `repo-face-rumination` jobs. `npm run state:install-repo-face-heartbeat-task` installs the hidden local scheduled task that ticks the scheduler.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,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 with instructions to answer through `post_repo_identity_message`, then queue `repo-face-rumination` jobs so the Face can think over the recent conversation and decide whether to persist memory, incubate ideas, raise agency pressure, queue candidates, or speak. The worker does not auto-post either job's final provider summaries as the base bot.
|
||||
- `scripts/run-repo-face-heartbeats.ts` gives every registered repo Face a standing initiative scheduler. It stores per-Face speed, reaction bias, load, next-ready time, and recent queue history at `REPO_FACE_HEARTBEAT_STATE_PATH`, staggers newborn participants across a base interval of twice `VOIDBOT_MODERATION_INTERVAL_MINUTES`, and queues only ready Faces as `repo-face-rumination` owner-Codex jobs.
|
||||
- `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 per-Face heartbeat interval remains half Void's frequency by using twice Void's moderation interval before initiative speed and load modifiers are applied.
|
||||
- `scripts/run-void-mood-drift.ps1`
|
||||
- hidden-task wrapper for the mood-drift organ; writes status/log pulse files and respects the moderation lock so the two state writers do not stomp each other for sport.
|
||||
- `scripts/install-void-mood-drift-task.ps1`
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ The runner can hand these to a CLI/MCP tool. The store decides what survives.
|
|||
- 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.
|
||||
- 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
|
||||
- the old memory-organ script family, legacy moderation state template, and legacy context exporter have now been deleted instead of preserved as forensic bait
|
||||
- deterministic cleanup is mostly recent scar tissue from failed manual cleanup passes; it should stay deleted rather than be ported unless a future typed operation protects a real invariant in the new model
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"state:install-watchdog-task": "powershell -ExecutionPolicy Bypass -File scripts/install-operations-watchdog-task.ps1",
|
||||
"state:install-moderation-task": "powershell -ExecutionPolicy Bypass -File scripts/install-moderation-rumination-task.ps1",
|
||||
"state:install-mood-task": "powershell -ExecutionPolicy Bypass -File scripts/install-void-mood-drift-task.ps1",
|
||||
"state:install-repo-face-heartbeat-task": "powershell -ExecutionPolicy Bypass -File scripts/install-repo-face-heartbeat-task.ps1",
|
||||
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
|
||||
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",
|
||||
"codex:mcp:remove": "tsx scripts/remove-codex-mcp.ts",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,12 @@ const envSchema = z.object({
|
|||
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),
|
||||
REPO_FACE_HEARTBEAT_STATE_PATH: z.string().min(1).default(".voidbot/status/repo-face-heartbeats.json"),
|
||||
REPO_FACE_HEARTBEAT_TASK_NAME: z.string().min(1).default("VoidBot Repo Face Heartbeats"),
|
||||
REPO_FACE_HEARTBEAT_INTERVAL_MINUTES: z.coerce.number().int().min(5).default(15),
|
||||
REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK: z.coerce.number().int().min(1).max(8).default(2),
|
||||
REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID: optionalNonEmptyString,
|
||||
EPIPHANY_AGENT_ROOT: z.string().min(1).default("E:/Projects/EpiphanyAgent"),
|
||||
INDEX_ALL_CHANNELS: booleanFromEnv.default(false),
|
||||
INDEXED_CHANNEL_IDS: z.string().default(""),
|
||||
|
|
@ -142,6 +148,14 @@ export interface AppConfig {
|
|||
repoFaceRuminationPasses: number;
|
||||
repoFaceBirthMode: "plan" | "run";
|
||||
repoFaceBirthExecutor: "codex-exec" | "openai-runtime";
|
||||
repoFaceHeartbeats: {
|
||||
enabled: boolean;
|
||||
statePath: string;
|
||||
taskName: string;
|
||||
intervalMinutes: number;
|
||||
maxJobsPerTick: number;
|
||||
defaultChannelId?: string;
|
||||
};
|
||||
epiphanyAgentRoot: string;
|
||||
indexAllChannels: boolean;
|
||||
indexedChannelIds: string[];
|
||||
|
|
@ -320,6 +334,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
repoFaceRuminationPasses: parsed.REPO_FACE_RUMINATION_PASSES,
|
||||
repoFaceBirthMode: parsed.REPO_FACE_BIRTH_MODE,
|
||||
repoFaceBirthExecutor: parsed.REPO_FACE_BIRTH_EXECUTOR,
|
||||
repoFaceHeartbeats: {
|
||||
enabled: parsed.REPO_FACE_HEARTBEATS_ENABLED,
|
||||
statePath: resolve(parsed.REPO_FACE_HEARTBEAT_STATE_PATH),
|
||||
taskName: parsed.REPO_FACE_HEARTBEAT_TASK_NAME,
|
||||
intervalMinutes: parsed.REPO_FACE_HEARTBEAT_INTERVAL_MINUTES,
|
||||
maxJobsPerTick: parsed.REPO_FACE_HEARTBEAT_MAX_JOBS_PER_TICK,
|
||||
defaultChannelId: parsed.REPO_FACE_HEARTBEAT_DEFAULT_CHANNEL_ID,
|
||||
},
|
||||
epiphanyAgentRoot: resolve(parsed.EPIPHANY_AGENT_ROOT),
|
||||
indexAllChannels: parsed.INDEX_ALL_CHANNELS,
|
||||
indexedChannelIds,
|
||||
|
|
|
|||
77
scripts/install-repo-face-heartbeat-task.ps1
Normal file
77
scripts/install-repo-face-heartbeat-task.ps1
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
param(
|
||||
[string] $TaskName,
|
||||
[int] $IntervalMinutes,
|
||||
[switch] $RunNow
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $repoRoot ".env"
|
||||
$hiddenLauncher = Join-Path $PSScriptRoot "run-hidden-powershell.vbs"
|
||||
$runnerScript = Join-Path $PSScriptRoot "run-repo-face-heartbeats.ts"
|
||||
|
||||
function Read-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string] $Path)
|
||||
$values = @{}
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $values
|
||||
}
|
||||
foreach ($line in Get-Content -Path $Path) {
|
||||
if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
$separatorIndex = $line.IndexOf("=")
|
||||
if ($separatorIndex -lt 1) {
|
||||
continue
|
||||
}
|
||||
$values[$line.Substring(0, $separatorIndex).Trim()] = $line.Substring($separatorIndex + 1)
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
$config = Read-DotEnv -Path $envFile
|
||||
$taskNameValue = if (-not [string]::IsNullOrWhiteSpace($TaskName)) {
|
||||
$TaskName
|
||||
} elseif ($config.ContainsKey("REPO_FACE_HEARTBEAT_TASK_NAME") -and -not [string]::IsNullOrWhiteSpace($config["REPO_FACE_HEARTBEAT_TASK_NAME"])) {
|
||||
$config["REPO_FACE_HEARTBEAT_TASK_NAME"]
|
||||
} else {
|
||||
"VoidBot Repo Face Heartbeats"
|
||||
}
|
||||
$intervalMinutesValue = if ($PSBoundParameters.ContainsKey("IntervalMinutes")) {
|
||||
$IntervalMinutes
|
||||
} elseif ($config.ContainsKey("REPO_FACE_HEARTBEAT_INTERVAL_MINUTES") -and -not [string]::IsNullOrWhiteSpace($config["REPO_FACE_HEARTBEAT_INTERVAL_MINUTES"])) {
|
||||
[int]$config["REPO_FACE_HEARTBEAT_INTERVAL_MINUTES"]
|
||||
} else {
|
||||
15
|
||||
}
|
||||
|
||||
if ($intervalMinutesValue -lt 5) {
|
||||
throw "REPO_FACE_HEARTBEAT_INTERVAL_MINUTES must be at least 5."
|
||||
}
|
||||
|
||||
$tsxPath = Join-Path $repoRoot "node_modules\.bin\tsx.cmd"
|
||||
if (-not (Test-Path -LiteralPath $tsxPath)) {
|
||||
throw "Missing tsx launcher at $tsxPath. Run npm install first."
|
||||
}
|
||||
|
||||
$startAt = (Get-Date).AddMinutes(3)
|
||||
$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //nologo `"$hiddenLauncher`" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"& '$tsxPath' '$runnerScript'`"" -WorkingDirectory $repoRoot
|
||||
$trigger = New-ScheduledTaskTrigger -Once -At $startAt -RepetitionInterval (New-TimeSpan -Minutes $intervalMinutesValue) -RepetitionDuration (New-TimeSpan -Days 3650)
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -MultipleInstances IgnoreNew
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||
$description = "Runs VoidBot's repo Face initiative scheduler every $intervalMinutesValue minute(s)."
|
||||
|
||||
Register-ScheduledTask -TaskName $taskNameValue -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description $description -Force | Out-Null
|
||||
|
||||
Write-Host "Installed scheduled task: $taskNameValue"
|
||||
Write-Host "Schedule: every $intervalMinutesValue minute(s), starting at $($startAt.ToString('yyyy-MM-dd HH:mm'))"
|
||||
Write-Host "Working directory: $repoRoot"
|
||||
Write-Host "Runner script: $runnerScript"
|
||||
|
||||
if ($RunNow) {
|
||||
Start-ScheduledTask -TaskName $taskNameValue
|
||||
Write-Host "Started task immediately."
|
||||
}
|
||||
412
scripts/run-repo-face-heartbeats.ts
Normal file
412
scripts/run-repo-face-heartbeats.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import "dotenv/config";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { loadConfig } from "@voidbot/config";
|
||||
import {
|
||||
ContextBuilder,
|
||||
createStateStorage,
|
||||
ensureRepoFaceInitialized,
|
||||
loadRepoDiscordIdentityRegistry,
|
||||
resolveRepoFaceStatePath,
|
||||
type RepoDiscordIdentity,
|
||||
} from "@voidbot/core";
|
||||
|
||||
const HEARTBEAT_SCHEMA_VERSION = "voidbot.repo_face_heartbeat_state.v0";
|
||||
const HEARTBEAT_COMMAND = "repo-face-rumination";
|
||||
const VOID_MODERATION_DEFAULT_MINUTES = 15;
|
||||
|
||||
interface FaceHeartbeatParticipant {
|
||||
identityId: string;
|
||||
repoName: string;
|
||||
displayName: string;
|
||||
initiativeSpeed: number;
|
||||
reactionBias: number;
|
||||
interruptThreshold: number;
|
||||
currentLoad: number;
|
||||
status: "active" | "blocked" | "withdrawn";
|
||||
nextReadyAt: string;
|
||||
lastQueuedAt?: string;
|
||||
queuedCount: number;
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
interface FaceHeartbeatState {
|
||||
schemaVersion: typeof HEARTBEAT_SCHEMA_VERSION;
|
||||
baseIntervalMinutes: number;
|
||||
lastTickAt?: string;
|
||||
participants: FaceHeartbeatParticipant[];
|
||||
history: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.repoFaceHeartbeats.enabled && !process.argv.includes("--force")) {
|
||||
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
|
||||
state.lastTickAt = new Date().toISOString();
|
||||
state.history.push({
|
||||
type: "skipped",
|
||||
reason: "repo_face_heartbeats_disabled",
|
||||
skippedAt: state.lastTickAt,
|
||||
});
|
||||
state.history = state.history.slice(-80);
|
||||
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = await loadRepoDiscordIdentityRegistry(config.repoDiscordIdentitiesPath);
|
||||
const state = await readHeartbeatState(config.repoFaceHeartbeats.statePath);
|
||||
const now = new Date();
|
||||
const baseIntervalMinutes = resolveBaseIntervalMinutes();
|
||||
const pendingJobs = await listExistingFaceJobs(config.databaseDsn, config.stateStorageBackend, config);
|
||||
|
||||
state.baseIntervalMinutes = baseIntervalMinutes;
|
||||
state.participants = reconcileParticipants(
|
||||
state.participants,
|
||||
registry.identities,
|
||||
config.repoFaceHeartbeats.defaultChannelId,
|
||||
now,
|
||||
baseIntervalMinutes,
|
||||
).map((participant) => ({
|
||||
...participant,
|
||||
currentLoad: pendingJobs.has(participant.identityId) ? 1 : 0,
|
||||
}));
|
||||
|
||||
const selected = selectReadyParticipants(
|
||||
state.participants,
|
||||
now,
|
||||
config.repoFaceHeartbeats.maxJobsPerTick,
|
||||
);
|
||||
const queuedIdentityIds: string[] = [];
|
||||
|
||||
if (selected.length > 0) {
|
||||
const storage = await createStateStorage({
|
||||
backend: config.stateStorageBackend,
|
||||
databaseDsn: config.databaseDsn,
|
||||
jobsFile: config.jobsFile,
|
||||
auditLogFile: config.auditLogFile,
|
||||
interactionMemoryFile: config.interactionMemoryFile,
|
||||
rateLimitStateFile: config.rateLimitStateFile,
|
||||
});
|
||||
|
||||
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,
|
||||
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",
|
||||
});
|
||||
|
||||
if (result.created) {
|
||||
queuedIdentityIds.push(identity.id);
|
||||
participant.lastQueuedAt = queuedAt;
|
||||
participant.queuedCount += 1;
|
||||
participant.nextReadyAt = nextReadyTime(
|
||||
now,
|
||||
baseIntervalMinutes,
|
||||
participant,
|
||||
).toISOString();
|
||||
state.history.push({
|
||||
type: "queued",
|
||||
identityId: identity.id,
|
||||
jobId: result.job.id,
|
||||
requestMessageId,
|
||||
queuedAt,
|
||||
nextReadyAt: participant.nextReadyAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await storage.close();
|
||||
}
|
||||
}
|
||||
|
||||
state.history = state.history.slice(-80);
|
||||
state.lastTickAt = now.toISOString();
|
||||
await writeHeartbeatState(config.repoFaceHeartbeats.statePath, state);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
participantCount: state.participants.length,
|
||||
queuedCount: queuedIdentityIds.length,
|
||||
selected: selected.map((entry) => entry.identityId),
|
||||
queued: queuedIdentityIds,
|
||||
statePath: config.repoFaceHeartbeats.statePath,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function listExistingFaceJobs(
|
||||
databaseDsn: string,
|
||||
stateStorageBackend: "file" | "postgres",
|
||||
config: ReturnType<typeof loadConfig>,
|
||||
): Promise<Set<string>> {
|
||||
const storage = await createStateStorage({
|
||||
backend: stateStorageBackend,
|
||||
databaseDsn,
|
||||
jobsFile: config.jobsFile,
|
||||
auditLogFile: config.auditLogFile,
|
||||
interactionMemoryFile: config.interactionMemoryFile,
|
||||
rateLimitStateFile: config.rateLimitStateFile,
|
||||
});
|
||||
try {
|
||||
const jobs = await storage.jobQueue.listByStates(["approved", "running"]);
|
||||
const active = new Set<string>();
|
||||
for (const job of jobs) {
|
||||
if (job.command !== HEARTBEAT_COMMAND) {
|
||||
continue;
|
||||
}
|
||||
const match =
|
||||
job.requestMessageId?.match(/^repo-face-heartbeat:([^:]+):/) ??
|
||||
job.requestMessageId?.match(/:repo-face:([^:]+):\d+$/);
|
||||
if (match) {
|
||||
active.add(match[1]);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
} finally {
|
||||
await storage.close();
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileParticipants(
|
||||
existing: FaceHeartbeatParticipant[],
|
||||
identities: RepoDiscordIdentity[],
|
||||
defaultChannelId: string | undefined,
|
||||
now: Date,
|
||||
baseIntervalMinutes: number,
|
||||
): FaceHeartbeatParticipant[] {
|
||||
const existingById = new Map(existing.map((entry) => [entry.identityId, entry]));
|
||||
const count = Math.max(identities.length, 1);
|
||||
|
||||
return identities.map((identity, index) => {
|
||||
const current = existingById.get(identity.id);
|
||||
const hasChannel = Boolean(identity.allowedChannelIds[0] || defaultChannelId);
|
||||
if (current) {
|
||||
return {
|
||||
...current,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
status: hasChannel
|
||||
? current.status === "withdrawn"
|
||||
? "withdrawn"
|
||||
: "active"
|
||||
: "blocked",
|
||||
};
|
||||
}
|
||||
|
||||
const speed = initiativeSpeedFor(identity);
|
||||
const offsetMs = ((baseIntervalMinutes * 60_000) / count) * index;
|
||||
return {
|
||||
identityId: identity.id,
|
||||
repoName: identity.repoName,
|
||||
displayName: identity.displayName,
|
||||
initiativeSpeed: speed,
|
||||
reactionBias: reactionBiasFor(identity),
|
||||
interruptThreshold: interruptThresholdFor(identity),
|
||||
currentLoad: 0,
|
||||
status: hasChannel ? "active" : "blocked",
|
||||
nextReadyAt: new Date(now.getTime() + offsetMs).toISOString(),
|
||||
queuedCount: 0,
|
||||
constraints: [
|
||||
"Face heartbeat uses owner-Codex repo-face-rumination jobs.",
|
||||
"Worker final summaries are not auto-posted as the base bot.",
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function selectReadyParticipants(
|
||||
participants: FaceHeartbeatParticipant[],
|
||||
now: Date,
|
||||
maxJobs: number,
|
||||
): FaceHeartbeatParticipant[] {
|
||||
return participants
|
||||
.filter((participant) => {
|
||||
return (
|
||||
participant.status === "active" &&
|
||||
participant.currentLoad < 1 &&
|
||||
new Date(participant.nextReadyAt).getTime() <= now.getTime()
|
||||
);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const readyDelta = new Date(left.nextReadyAt).getTime() - new Date(right.nextReadyAt).getTime();
|
||||
if (readyDelta !== 0) {
|
||||
return readyDelta;
|
||||
}
|
||||
if (right.reactionBias !== left.reactionBias) {
|
||||
return right.reactionBias - left.reactionBias;
|
||||
}
|
||||
if (right.initiativeSpeed !== left.initiativeSpeed) {
|
||||
return right.initiativeSpeed - left.initiativeSpeed;
|
||||
}
|
||||
return left.identityId.localeCompare(right.identityId);
|
||||
})
|
||||
.slice(0, maxJobs);
|
||||
}
|
||||
|
||||
function buildHeartbeatPrompt(input: {
|
||||
identity: RepoDiscordIdentity;
|
||||
faceStatePath: string;
|
||||
channelId: string;
|
||||
queuedAt: string;
|
||||
participant: FaceHeartbeatParticipant;
|
||||
repoVoidbotRoot?: string;
|
||||
birthStatusPath?: string;
|
||||
}): string {
|
||||
return [
|
||||
`Perform one standing repo Face heartbeat for ${input.identity.displayName} (${input.identity.id}) over repo ${input.identity.repoName}.`,
|
||||
"This is a slow maintenance/rumination turn, not a demand to speak.",
|
||||
`Queued at: ${input.queuedAt}.`,
|
||||
`Face state path: ${input.faceStatePath}.`,
|
||||
input.repoVoidbotRoot ? `Repo-local .voidbot root: ${input.repoVoidbotRoot}.` : undefined,
|
||||
input.birthStatusPath ? `Birth status path: ${input.birthStatusPath}.` : undefined,
|
||||
`Heartbeat initiative snapshot: ${JSON.stringify({
|
||||
initiativeSpeed: input.participant.initiativeSpeed,
|
||||
reactionBias: input.participant.reactionBias,
|
||||
interruptThreshold: input.participant.interruptThreshold,
|
||||
queuedCount: input.participant.queuedCount,
|
||||
})}.`,
|
||||
`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.",
|
||||
`If an in-channel note is genuinely warranted, post through post_repo_identity_message with identity "${input.identity.id}" and channelId "${input.channelId}".`,
|
||||
"If nothing earns persistence or speech, return a short private summary.",
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function nextReadyTime(
|
||||
now: Date,
|
||||
baseIntervalMinutes: number,
|
||||
participant: FaceHeartbeatParticipant,
|
||||
): Date {
|
||||
const loadPenalty = 1 + participant.currentLoad * 0.75;
|
||||
const recoveryMs = (baseIntervalMinutes * 60_000 * loadPenalty) / Math.max(participant.initiativeSpeed, 0.35);
|
||||
return new Date(now.getTime() + recoveryMs);
|
||||
}
|
||||
|
||||
function resolveBaseIntervalMinutes(): number {
|
||||
const raw = process.env.VOIDBOT_MODERATION_INTERVAL_MINUTES;
|
||||
const voidInterval = raw ? Number.parseInt(raw, 10) : VOID_MODERATION_DEFAULT_MINUTES;
|
||||
return Math.max(10, (Number.isFinite(voidInterval) ? voidInterval : VOID_MODERATION_DEFAULT_MINUTES) * 2);
|
||||
}
|
||||
|
||||
async function readHeartbeatState(path: string): Promise<FaceHeartbeatState> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
const parsed = JSON.parse(stripLeadingBom(raw)) as FaceHeartbeatState;
|
||||
if (parsed.schemaVersion === HEARTBEAT_SCHEMA_VERSION) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through to a new state
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: HEARTBEAT_SCHEMA_VERSION,
|
||||
baseIntervalMinutes: resolveBaseIntervalMinutes(),
|
||||
participants: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function writeHeartbeatState(path: string, state: FaceHeartbeatState): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function initiativeSpeedFor(identity: RepoDiscordIdentity): number {
|
||||
return clamp(0.85 + stableUnit(identity.id, "speed") * 0.45, 0.75, 1.3);
|
||||
}
|
||||
|
||||
function reactionBiasFor(identity: RepoDiscordIdentity): number {
|
||||
return clamp(0.2 + stableUnit(identity.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 stableUnit(id: string, salt: string): number {
|
||||
const hex = createHash("sha1").update(`${id}:${salt}`).digest("hex").slice(0, 8);
|
||||
return Number.parseInt(hex, 16) / 0xffffffff;
|
||||
}
|
||||
|
||||
function mergeStrings(values: string[], value: string): string[] {
|
||||
return Array.from(new Set([...values, value]));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Number(value.toFixed(3))));
|
||||
}
|
||||
|
||||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -137,3 +137,4 @@
|
|||
{"ts":"2026-05-18T09:38:14Z","type":"implementation","status":"ok","note":"Repo Discord identities now use roles as address targets and the shared webhook pipe as speech transport: REPO_DISCORD_IDENTITIES_PATH stores registered identities, MCP exposes list_repo_discord_identities/post_repo_identity_message, role mentions create repo-identity-mention jobs, and the worker suppresses base-bot auto-post for those jobs."}
|
||||
{"ts":"2026-05-18T09:56:55Z","type":"implementation","status":"ok","note":"Repo identities now have Face state and rumination scaffolding: registered identities resolve to per-Face typed .cc state files, bot startup can ensure missing Discord roles when guild permissions allow, role-addressed chats queue repo-identity response jobs plus bounded repo-face-rumination jobs, and MCP exposes read/apply Face-state tools so agents mutate Face memory through typed operations."}
|
||||
{"ts":"2026-05-18T10:12:28Z","type":"implementation","status":"ok","note":"Repo Face first-chat initialization now creates a repo-local .voidbot home with voice/state/birth/log folders and launches a detached EpiphanyAgent birth worker. The worker runs epiphany-repo-personality scout and epiphany-repo-birth-runner so newborn personality/memory/trajectory artifacts are grounded in repo contents and git history, with status/logs written in the target repo."}
|
||||
{"ts":"2026-05-18T10:22:33Z","type":"implementation","status":"ok","note":"Repo Face heartbeats now use one initiative scheduler: registered identities are staggered across twice Void's moderation interval, active load gates ready Faces, and ready turns enqueue normal repo-face-rumination jobs instead of separate per-Face tasks."}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ accepted_design:
|
|||
- Repo-scoped agents should use Discord roles as addressable identities and the shared webhook persona pipe as the mouth. Registered repo identities live behind one registry, can be discovered through MCP, and post through `post_repo_identity_message` instead of arbitrary persona-name strings.
|
||||
- A repo identity's Face is a typed `.cc` state file resolved from the identity registry. Role-addressed messages queue a bounded series of owner-Codex Face rumination jobs; those jobs may read/apply Face state operations and post through the registered repo identity, but the worker must not auto-post their final summaries as the base bot.
|
||||
- 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 initiative scheduler, not by one task per Face. The scheduler staggers registered identities across twice Void's moderation interval, stores per-Face speed/load/next-ready 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.
|
||||
- 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.
|
||||
|
|
@ -135,6 +136,7 @@ landed_baseline:
|
|||
- The MCP Discord surface now includes registered repo identities: `list_repo_discord_identities` exposes the role-backed address book, and `post_repo_identity_message` posts with the registered display name/avatar through the existing webhook pipe while enforcing channel allow-lists.
|
||||
- The MCP Face surface now exposes `read_repo_face_state` and `apply_repo_face_state_operation`, reusing the typed self-state operation service against each repo identity's own Face `.cc` file instead of creating editable JSON projections.
|
||||
- Repo Face birth initialization is detached and observable: VoidBot writes repo-local status/log paths before launching `scripts/run-repo-face-birth.mjs`, which runs EpiphanyAgent's `epiphany-repo-personality scout` followed by `epiphany-repo-birth-runner`.
|
||||
- Repo Face heartbeats now have a Ghostlight-style initiative scheduler: `scripts/run-repo-face-heartbeats.ts` reconciles the registered identities, tracks staggered next-ready times and active load, and queues ready Faces as approved `repo-face-rumination` owner-Codex jobs. `npm run state:install-repo-face-heartbeat-task` installs its hidden Windows scheduled task.
|
||||
- Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist.
|
||||
- Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth.
|
||||
- State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.
|
||||
|
|
|
|||
Loading…
Reference in a new issue