Add typed memory maintenance runner
This commit is contained in:
parent
79485cd832
commit
e3735fb9c4
9 changed files with 542 additions and 9 deletions
446
scripts/run-void-memory-maintenance.ps1
Normal file
446
scripts/run-void-memory-maintenance.ps1
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
param(
|
||||
[string] $StateFilePath,
|
||||
[switch] $SkipModel
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $repoRoot
|
||||
|
||||
$stateFilePath = if ([string]::IsNullOrWhiteSpace($StateFilePath)) {
|
||||
Join-Path $repoRoot ".voidbot\private\void-self-state.cc"
|
||||
} else {
|
||||
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($StateFilePath)
|
||||
}
|
||||
$statusDir = Join-Path $repoRoot ".voidbot\status"
|
||||
$logDir = Join-Path $repoRoot ".voidbot\logs"
|
||||
$statusPath = Join-Path $statusDir "void-memory-maintenance.json"
|
||||
$summaryLogPath = Join-Path $logDir "void-memory-maintenance.log"
|
||||
$lastMessagePath = Join-Path $statusDir "void-memory-maintenance-last-message.txt"
|
||||
$operationOutputPath = Join-Path $statusDir "void-memory-maintenance-operations.json"
|
||||
$contextPath = Join-Path $statusDir "void-memory-maintenance-context.json"
|
||||
$lockPath = Join-Path $statusDir "void-memory-maintenance.lock"
|
||||
$moderationLockPath = Join-Path $statusDir "moderation-rumination.lock"
|
||||
$promptTemplatePath = Join-Path $repoRoot "prompts\void-memory-maintenance.md"
|
||||
$contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination-context-projection.ps1"
|
||||
$selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs"
|
||||
$startedAtUtc = [DateTime]::UtcNow
|
||||
|
||||
. $contextProjectionScriptPath
|
||||
|
||||
function Write-JsonFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
$Data
|
||||
)
|
||||
|
||||
$directory = Split-Path -Parent $Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($directory)) {
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
}
|
||||
|
||||
$json = if ($Data -is [System.Array] -and $Data.Count -eq 0) {
|
||||
"[]"
|
||||
} else {
|
||||
$Data | ConvertTo-Json -Depth 32
|
||||
}
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($Path, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Read-JsonFile {
|
||||
param([Parameter(Mandatory = $true)][string] $Path)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = Get-Content -Path $Path -Raw -Encoding UTF8
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Read-TextFile {
|
||||
param([Parameter(Mandatory = $true)][string] $Path)
|
||||
return Get-Content -Path $Path -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
function Append-RunLog {
|
||||
param([Parameter(Mandatory = $true)][string] $Line)
|
||||
$timestamped = "[{0}] {1}" -f ([DateTime]::UtcNow.ToString("o")), $Line
|
||||
Add-Content -Path $summaryLogPath -Value $timestamped -Encoding UTF8
|
||||
}
|
||||
|
||||
function Read-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string] $Path)
|
||||
|
||||
$values = @{}
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $values
|
||||
}
|
||||
|
||||
foreach ($line in Get-Content -Path $Path -Encoding UTF8) {
|
||||
$trimmed = $line.Trim()
|
||||
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$separator = $trimmed.IndexOf("=")
|
||||
if ($separator -lt 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
$key = $trimmed.Substring(0, $separator).Trim()
|
||||
$value = $trimmed.Substring($separator + 1).Trim()
|
||||
if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
$values[$key] = $value
|
||||
}
|
||||
|
||||
return $values
|
||||
}
|
||||
|
||||
function Split-CommandArgs {
|
||||
param([string] $Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
return [regex]::Matches($Value, '("[^"]*"|''[^'']*''|\S+)') |
|
||||
ForEach-Object { $_.Value.Trim().Trim("'`"") }
|
||||
}
|
||||
|
||||
function Quote-ProcessArgument {
|
||||
param([Parameter(Mandatory = $true)][string] $Value)
|
||||
|
||||
if ($Value -notmatch '[\s"]') {
|
||||
return $Value
|
||||
}
|
||||
|
||||
$escaped = $Value -replace '(\\*)"', '$1$1\"'
|
||||
$escaped = $escaped -replace '(\\+)$', '$1$1'
|
||||
return '"' + $escaped + '"'
|
||||
}
|
||||
|
||||
function Invoke-CodexExec {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Executable,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]] $Arguments,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $WorkingDirectory,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $InputText
|
||||
)
|
||||
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $Executable
|
||||
$startInfo.WorkingDirectory = $WorkingDirectory
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.RedirectStandardInput = $true
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
$startInfo.CreateNoWindow = $true
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
$startInfo.StandardOutputEncoding = $utf8NoBom
|
||||
$startInfo.StandardErrorEncoding = $utf8NoBom
|
||||
$startInfo.Arguments = [string]::Join(" ", ($Arguments | ForEach-Object { Quote-ProcessArgument -Value $_ }))
|
||||
|
||||
$process = [System.Diagnostics.Process]::new()
|
||||
$process.StartInfo = $startInfo
|
||||
|
||||
try {
|
||||
[void]$process.Start()
|
||||
$inputWriter = [System.IO.StreamWriter]::new($process.StandardInput.BaseStream, $utf8NoBom, 4096, $true)
|
||||
|
||||
try {
|
||||
$inputWriter.Write($InputText)
|
||||
$inputWriter.Flush()
|
||||
} finally {
|
||||
$inputWriter.Dispose()
|
||||
$process.StandardInput.Close()
|
||||
}
|
||||
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
|
||||
$process.WaitForExit()
|
||||
$stdoutTask.Wait()
|
||||
$stderrTask.Wait()
|
||||
|
||||
return @{
|
||||
ExitCode = $process.ExitCode
|
||||
StdOut = $stdoutTask.Result
|
||||
StdErr = $stderrTask.Result
|
||||
}
|
||||
} finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-NodeJson {
|
||||
param([Parameter(Mandatory = $true)][string[]] $Arguments)
|
||||
|
||||
$output = & node @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Node helper failed: node $($Arguments -join ' ')"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($output)) {
|
||||
return $null
|
||||
}
|
||||
return $output | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-TypedSelfState {
|
||||
$script = @"
|
||||
const core = require('./packages/core/dist');
|
||||
Promise.all([
|
||||
core.loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[1] }),
|
||||
core.loadVoidSelfState(process.argv[1]),
|
||||
]).then(([typedState, context]) => {
|
||||
console.log(JSON.stringify({ typedState, summary: context?.summary ?? '' }));
|
||||
}).catch((error) => { console.error(error); process.exit(1); });
|
||||
"@
|
||||
$output = & node -e $script $stateFilePath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to read typed self-state."
|
||||
}
|
||||
return $output | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Apply-TypedOperation {
|
||||
param([Parameter(Mandatory = $true)] $Operation)
|
||||
|
||||
$operationInputPath = Join-Path $statusDir ("void-memory-maintenance-operation-{0}.json" -f ([Guid]::NewGuid().ToString("n")))
|
||||
Write-JsonFile -Path $operationInputPath -Data $Operation
|
||||
|
||||
try {
|
||||
return Invoke-NodeJson -Arguments @(
|
||||
$selfStateScriptPath,
|
||||
"apply-operation",
|
||||
"--canonical", $stateFilePath,
|
||||
"--operation-file", $operationInputPath
|
||||
)
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $operationInputPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-ToOperationArray {
|
||||
param($Value)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return @()
|
||||
}
|
||||
|
||||
if ($Value -is [System.Array]) {
|
||||
return @($Value)
|
||||
}
|
||||
|
||||
return @($Value)
|
||||
}
|
||||
|
||||
function Test-RecentLockPresent {
|
||||
param([Parameter(Mandatory = $true)][string] $Path, [Parameter(Mandatory = $true)][int] $Minutes)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$lockAge = [DateTime]::UtcNow - (Get-Item $Path).LastWriteTimeUtc
|
||||
return $lockAge.TotalMinutes -lt $Minutes
|
||||
}
|
||||
|
||||
function Assert-AllowedMemoryMaintenanceOperation {
|
||||
param($Operation)
|
||||
|
||||
$operationName = Get-ObjectPropertyString -Value $Operation -Name "operation"
|
||||
$allowed = @(
|
||||
"append_distilled_memory",
|
||||
"merge_incubation_support",
|
||||
"queue_candidate_intervention",
|
||||
"retire_candidate_intervention",
|
||||
"propose_memory_distillation",
|
||||
"apply_memory_distillation"
|
||||
)
|
||||
|
||||
if ($null -eq $operationName -or -not $allowed.Contains($operationName)) {
|
||||
throw "Memory maintenance proposed disallowed operation '$operationName'."
|
||||
}
|
||||
}
|
||||
|
||||
trap {
|
||||
$finishedAtUtc = [DateTime]::UtcNow
|
||||
$failureMessage = $_.Exception.Message
|
||||
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "failed"
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
finishedAt = $finishedAtUtc.ToString("o")
|
||||
durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2)
|
||||
failureMessage = $failureMessage
|
||||
skipModel = [bool]$SkipModel
|
||||
stateFile = $stateFilePath
|
||||
contextPath = $contextPath
|
||||
operationOutputPath = $operationOutputPath
|
||||
}
|
||||
|
||||
Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $statusDir, $logDir, (Split-Path -Parent $stateFilePath) | Out-Null
|
||||
|
||||
if (Test-RecentLockPresent -Path $moderationLockPath -Minutes 20) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "moderation_loop_active"
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-Path $lockPath) {
|
||||
if (Test-RecentLockPresent -Path $lockPath -Minutes 20) {
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "skipped"
|
||||
reason = "lock_present"
|
||||
observedAt = ([DateTime]::UtcNow.ToString("o"))
|
||||
stateFile = $stateFilePath
|
||||
}
|
||||
return
|
||||
}
|
||||
Remove-Item -LiteralPath $lockPath -Force
|
||||
}
|
||||
|
||||
Write-JsonFile -Path $lockPath -Data @{
|
||||
pid = $PID
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
}
|
||||
|
||||
if (Test-Path $operationOutputPath) {
|
||||
Remove-Item -LiteralPath $operationOutputPath -Force
|
||||
}
|
||||
|
||||
foreach ($requiredPath in @($contextProjectionScriptPath, $selfStateScriptPath, $promptTemplatePath)) {
|
||||
if (-not (Test-Path $requiredPath)) {
|
||||
throw "Missing required helper at $requiredPath"
|
||||
}
|
||||
}
|
||||
|
||||
$envValues = Read-DotEnv -Path (Join-Path $repoRoot ".env")
|
||||
$codexExecutable = if ($envValues.ContainsKey("CODEX_EXECUTABLE") -and -not [string]::IsNullOrWhiteSpace($envValues["CODEX_EXECUTABLE"])) { $envValues["CODEX_EXECUTABLE"] } else { "codex" }
|
||||
$codexModel = if ($envValues.ContainsKey("CODEX_MODEL") -and -not [string]::IsNullOrWhiteSpace($envValues["CODEX_MODEL"])) { $envValues["CODEX_MODEL"] } else { "gpt-5.4" }
|
||||
$codexReasoningEffort = if ($envValues.ContainsKey("CODEX_MODEL_REASONING_EFFORT") -and -not [string]::IsNullOrWhiteSpace($envValues["CODEX_MODEL_REASONING_EFFORT"])) { $envValues["CODEX_MODEL_REASONING_EFFORT"] } else { "medium" }
|
||||
$codexExecArgs = if ($envValues.ContainsKey("CODEX_EXEC_ARGS")) { Split-CommandArgs -Value $envValues["CODEX_EXEC_ARGS"] } else { @() }
|
||||
|
||||
$typedContext = Get-TypedSelfState
|
||||
$typedState = $typedContext.typedState
|
||||
|
||||
Write-JsonFile -Path $contextPath -Data @{
|
||||
generated = "now"
|
||||
stateFile = $stateFilePath
|
||||
selfStateSummary = $typedContext.summary
|
||||
mode = if ($typedState.scheduledRuntime.sleepCycle.isNapping) { "sleep_maintenance" } else { "awake_memory_maintenance" }
|
||||
maintenanceBoundary = "This pass may propose only typed memory/incubation/candidate operations. Exact timestamps stay in typed state; prompt-facing chronology uses relative phrases."
|
||||
memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc)
|
||||
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
|
||||
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
|
||||
scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc
|
||||
speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc)
|
||||
}
|
||||
|
||||
$prompt = Read-TextFile -Path $promptTemplatePath
|
||||
$prompt = $prompt.Replace("{{CONTEXT_PATH}}", $contextPath)
|
||||
$prompt = $prompt.Replace("{{STATE_FILE_PATH}}", $stateFilePath)
|
||||
$prompt = $prompt.Replace("{{OPERATION_OUTPUT_PATH}}", $operationOutputPath)
|
||||
|
||||
$exitCode = 0
|
||||
$combinedText = ""
|
||||
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = if ($SkipModel) { "running_skip_model" } else { "running" }
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
skipModel = [bool]$SkipModel
|
||||
stateFile = $stateFilePath
|
||||
contextPath = $contextPath
|
||||
operationOutputPath = $operationOutputPath
|
||||
codexExecutable = $codexExecutable
|
||||
codexModel = $codexModel
|
||||
}
|
||||
|
||||
if ($SkipModel) {
|
||||
Append-RunLog "skip-model: writing empty memory-maintenance proposal."
|
||||
Write-JsonFile -Path $operationOutputPath -Data @()
|
||||
$combinedText = "Skipped model memory maintenance by explicit -SkipModel smoke mode."
|
||||
} else {
|
||||
$codexArgs = @()
|
||||
$codexArgs += $codexExecArgs
|
||||
$codexArgs += @(
|
||||
"exec",
|
||||
"--ignore-user-config",
|
||||
"-m", $codexModel,
|
||||
"-c", 'approval_policy="never"',
|
||||
"-c", ("model_reasoning_effort={0}" -f (ConvertTo-Json $codexReasoningEffort -Compress)),
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
"-o", $lastMessagePath,
|
||||
"-"
|
||||
)
|
||||
|
||||
$execution = Invoke-CodexExec -Executable $codexExecutable -Arguments $codexArgs -WorkingDirectory $repoRoot -InputText $prompt
|
||||
$exitCode = $execution.ExitCode
|
||||
$combinedText = (($execution.StdOut, $execution.StdErr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw $(if ([string]::IsNullOrWhiteSpace($combinedText)) { "Codex memory maintenance failed with exit code $exitCode." } else { $combinedText })
|
||||
}
|
||||
|
||||
if (-not (Test-Path $operationOutputPath)) {
|
||||
throw "Memory maintenance completed without writing operation output at $operationOutputPath."
|
||||
}
|
||||
|
||||
$proposedOperations = @(Convert-ToOperationArray -Value (Read-JsonFile -Path $operationOutputPath))
|
||||
$appliedOperations = @()
|
||||
|
||||
foreach ($operation in $proposedOperations) {
|
||||
Assert-AllowedMemoryMaintenanceOperation -Operation $operation
|
||||
$appliedOperations += Apply-TypedOperation -Operation $operation
|
||||
}
|
||||
|
||||
$finishedAtUtc = [DateTime]::UtcNow
|
||||
$lastMessage = if (Test-Path $lastMessagePath) { Get-Content -Path $lastMessagePath -Raw -Encoding UTF8 } else { "" }
|
||||
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "ok"
|
||||
mode = if ($SkipModel) { "typed_memory_maintenance_skip_model" } else { "typed_memory_maintenance" }
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
finishedAt = $finishedAtUtc.ToString("o")
|
||||
durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2)
|
||||
exitCode = [int]$exitCode
|
||||
skipModel = [bool]$SkipModel
|
||||
stateFile = [string]$stateFilePath
|
||||
contextPath = [string]$contextPath
|
||||
operationOutputPath = [string]$operationOutputPath
|
||||
proposedOperationCount = [int]@($proposedOperations).Count
|
||||
appliedOperationCount = [int]@($appliedOperations).Count
|
||||
stateUpdated = [bool](@($appliedOperations).Count -gt 0)
|
||||
lastMessagePath = [string]$lastMessagePath
|
||||
}
|
||||
|
||||
Append-RunLog ("mode=typed_memory_maintenance proposed={0} applied={1}" -f @($proposedOperations).Count, @($appliedOperations).Count)
|
||||
Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue
|
||||
Loading…
Reference in a new issue