Add novelty-aware herald mood drift
This commit is contained in:
parent
f5af472a5a
commit
d85563c176
14 changed files with 752 additions and 9 deletions
89
scripts/install-void-mood-drift-task.ps1
Normal file
89
scripts/install-void-mood-drift-task.ps1
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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"
|
||||
$runnerScript = Join-Path $PSScriptRoot "run-void-mood-drift.ps1"
|
||||
$hiddenLauncher = Join-Path $PSScriptRoot "run-hidden-powershell.vbs"
|
||||
|
||||
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)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line.TrimStart().StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$separatorIndex = $line.IndexOf("=")
|
||||
|
||||
if ($separatorIndex -lt 1) {
|
||||
continue
|
||||
}
|
||||
|
||||
$key = $line.Substring(0, $separatorIndex).Trim()
|
||||
$value = $line.Substring($separatorIndex + 1)
|
||||
$values[$key] = $value
|
||||
}
|
||||
|
||||
return $values
|
||||
}
|
||||
|
||||
$config = Read-DotEnv -Path $envFile
|
||||
$taskNameValue = if (-not [string]::IsNullOrWhiteSpace($TaskName)) {
|
||||
$TaskName
|
||||
} elseif ($config.ContainsKey("VOIDBOT_MOOD_TASK_NAME") -and -not [string]::IsNullOrWhiteSpace($config["VOIDBOT_MOOD_TASK_NAME"])) {
|
||||
$config["VOIDBOT_MOOD_TASK_NAME"]
|
||||
} else {
|
||||
"Void Mood Drift"
|
||||
}
|
||||
$intervalMinutesValue = if ($PSBoundParameters.ContainsKey("IntervalMinutes")) {
|
||||
$IntervalMinutes
|
||||
} elseif ($config.ContainsKey("VOIDBOT_MOOD_INTERVAL_MINUTES") -and -not [string]::IsNullOrWhiteSpace($config["VOIDBOT_MOOD_INTERVAL_MINUTES"])) {
|
||||
[int]$config["VOIDBOT_MOOD_INTERVAL_MINUTES"]
|
||||
} else {
|
||||
5
|
||||
}
|
||||
|
||||
if ($intervalMinutesValue -lt 5) {
|
||||
throw "VOIDBOT_MOOD_INTERVAL_MINUTES must be at least 5."
|
||||
}
|
||||
|
||||
$startAt = (Get-Date).AddMinutes(1)
|
||||
$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //nologo `"$hiddenLauncher`" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$runnerScript`""
|
||||
$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 3) -MultipleInstances IgnoreNew
|
||||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
||||
$description = "Runs Void's background mood-drift pass every $intervalMinutesValue minutes so current activations and speaking pressure do not stay emotionally flat between moderation loops."
|
||||
|
||||
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 "Task runs as: $($env:USERDOMAIN)\$($env:USERNAME)"
|
||||
Write-Host "Execution time limit: 3 minutes"
|
||||
Write-Host "Runner script: $runnerScript"
|
||||
|
||||
if ($RunNow) {
|
||||
Start-ScheduledTask -TaskName $taskNameValue
|
||||
Write-Host "Started task immediately."
|
||||
}
|
||||
|
|
@ -252,6 +252,7 @@ Required operating posture:
|
|||
- Use `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect current tracked-repo motion across the broader GameCult zoo.
|
||||
- Maintain parallel analytic and associative thought lanes in `.voidbot/private/moderation-agent-state.json`, then let the bridge decide what actually deserves synthesis, speech, or cooling-off.
|
||||
- You are the only routine local agent in this workspace with a standing cross-project view, so notice ongoing experiments, commit clusters, and weird convergences that the narrower workers cannot see.
|
||||
- Treat `moderation_runtime.speaking_bias` as a live meter, not decorative metadata. If the mood drift has built up speaking pressure and a thought survives a novelty check, do not smother it out of reflex.
|
||||
- When the room is quiet, use the usual VoidBot retrieval tools to think about GameCult projects and contribute ideas:
|
||||
- `search_history`
|
||||
- `get_message_context`
|
||||
|
|
@ -260,6 +261,8 @@ Required operating posture:
|
|||
- `get_source_context`
|
||||
- `notify_owner` when owner interruption is actually warranted
|
||||
- If recent repo motion gives you a grounded observation about the ongoing work, you are allowed to say so. Be a herald, not a weather vane nailed to one porch.
|
||||
- Do not keep repo-weather thoughts under glass out of reflexive politeness. If a fresh convergence across active repos survives one or two passes and still feels room-native, bias toward a concise herald note or at least a sharpened candidate intervention.
|
||||
- Before withholding a thought, use semantic retrieval to check whether the room has already said it in roughly that shape. Novel thoughts should not keep dying of manners.
|
||||
- Do not keep circling the same musing just because it still feels tidy. Chase at least one fresh branch when the room gives you a concrete hook, and record archive excursions so you can avoid pacing the same trench forever.
|
||||
- Use the local bot-voice script `node scripts/send-discord-message.mjs ...` when you genuinely need to speak.
|
||||
- Update only `.voidbot/private/moderation-agent-state.json` as routine writable memory.
|
||||
|
|
|
|||
68
scripts/run-void-mood-drift.ps1
Normal file
68
scripts/run-void-mood-drift.ps1
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$statusDir = Join-Path $repoRoot ".voidbot\status"
|
||||
$logDir = Join-Path $repoRoot ".voidbot\logs"
|
||||
$statusPath = Join-Path $statusDir "void-mood-drift-run.json"
|
||||
$logPath = Join-Path $logDir "void-mood-drift.log"
|
||||
$scriptPath = Join-Path $PSScriptRoot "simulate-void-mood.mjs"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$Data | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Encoding UTF8
|
||||
}
|
||||
|
||||
function Append-RunLog {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Line
|
||||
)
|
||||
|
||||
$timestamped = "[{0}] {1}" -f ([DateTime]::UtcNow.ToString("o")), $Line
|
||||
Add-Content -Path $logPath -Value $timestamped -Encoding UTF8
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $statusDir, $logDir | Out-Null
|
||||
|
||||
$startedAt = [DateTime]::UtcNow
|
||||
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = "running"
|
||||
startedAt = $startedAt.ToString("o")
|
||||
scriptPath = $scriptPath
|
||||
}
|
||||
|
||||
$output = & node $scriptPath 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$finishedAt = [DateTime]::UtcNow
|
||||
$combined = ($output | Out-String).Trim()
|
||||
|
||||
Write-JsonFile -Path $statusPath -Data @{
|
||||
status = if ($exitCode -eq 0) { "ok" } else { "failed" }
|
||||
startedAt = $startedAt.ToString("o")
|
||||
finishedAt = $finishedAt.ToString("o")
|
||||
durationSeconds = [Math]::Round(($finishedAt - $startedAt).TotalSeconds, 2)
|
||||
exitCode = $exitCode
|
||||
output = $combined
|
||||
scriptPath = $scriptPath
|
||||
}
|
||||
|
||||
Append-RunLog ("exit={0} output={1}" -f $exitCode, $combined)
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "Void mood drift failed with exit code $exitCode. $combined"
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -28,6 +28,14 @@ async function main() {
|
|||
|
||||
const channelId = await openOwnerDmChannel(botToken, ownerId);
|
||||
await postChunkedDiscordMessage(botToken, channelId, content);
|
||||
writeLastSpeechStatus({
|
||||
sentAt: new Date().toISOString(),
|
||||
mode: "owner_dm",
|
||||
channelId,
|
||||
contentLength: content.length,
|
||||
chunkCount: splitDiscordContent(content).length,
|
||||
preview: content.slice(0, 280),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, mode: "owner_dm", channelId })}\n`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -42,6 +50,15 @@ async function main() {
|
|||
content,
|
||||
options.replyToMessageId,
|
||||
);
|
||||
writeLastSpeechStatus({
|
||||
sentAt: new Date().toISOString(),
|
||||
mode: "channel",
|
||||
channelId: options.channelId,
|
||||
replyToMessageId: options.replyToMessageId ?? null,
|
||||
contentLength: content.length,
|
||||
chunkCount: splitDiscordContent(content).length,
|
||||
preview: content.slice(0, 280),
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
|
|
@ -280,6 +297,12 @@ function findSplitIndex(input, limit) {
|
|||
return limit;
|
||||
}
|
||||
|
||||
function writeLastSpeechStatus(payload) {
|
||||
const statusPath = resolve(repoRoot, ".voidbot/status/void-last-speech.json");
|
||||
mkdirSync(dirname(statusPath), { recursive: true });
|
||||
writeFileSync(statusPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function stripLeadingBom(input) {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
|
|
|||
419
scripts/simulate-void-mood.mjs
Normal file
419
scripts/simulate-void-mood.mjs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { mkdirSync, openSync, closeSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const statePath = resolve(repoRoot, ".voidbot/private/moderation-agent-state.json");
|
||||
const statusPath = resolve(repoRoot, ".voidbot/status/void-mood-drift.json");
|
||||
const lockPath = resolve(repoRoot, ".voidbot/status/void-mood-drift.lock");
|
||||
const lastSpeechPath = resolve(repoRoot, ".voidbot/status/void-last-speech.json");
|
||||
const moderationLockPath = resolve(repoRoot, ".voidbot/status/moderation-rumination.lock");
|
||||
|
||||
function main() {
|
||||
const now = new Date();
|
||||
withLock(() => {
|
||||
if (isRecentLockPresent(moderationLockPath, 20 * 60 * 1000)) {
|
||||
writeStatus({
|
||||
status: "skipped",
|
||||
reason: "moderation_loop_active",
|
||||
observedAt: now.toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = readJson(statePath);
|
||||
const canonicalState = ensureObject(state.canonical_state);
|
||||
const touchedVectors = [];
|
||||
|
||||
for (const [categoryName, categoryValue] of Object.entries(canonicalState)) {
|
||||
if (!isObject(categoryValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryConfig = getCategoryConfig(categoryName);
|
||||
|
||||
for (const [vectorName, vectorValue] of Object.entries(categoryValue)) {
|
||||
if (!isObject(vectorValue) || typeof vectorValue.mean !== "number" || typeof vectorValue.plasticity !== "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentActivation =
|
||||
typeof vectorValue.current_activation === "number" ? vectorValue.current_activation : vectorValue.mean;
|
||||
const target = computeTargetActivation({
|
||||
categoryName,
|
||||
vectorName,
|
||||
mean: vectorValue.mean,
|
||||
plasticity: vectorValue.plasticity,
|
||||
now,
|
||||
periodHours: categoryConfig.periodHours,
|
||||
amplitudeMultiplier: categoryConfig.amplitudeMultiplier,
|
||||
});
|
||||
const driftRate = clamp(0.08 + vectorValue.plasticity * 0.22 + categoryConfig.driftBonus, 0.06, 0.4);
|
||||
const nextActivation = clamp(currentActivation + (target - currentActivation) * driftRate, 0, 1);
|
||||
|
||||
vectorValue.current_activation = round3(nextActivation);
|
||||
touchedVectors.push({
|
||||
key: `${categoryName}.${vectorName}`,
|
||||
previous: currentActivation,
|
||||
next: nextActivation,
|
||||
delta: nextActivation - currentActivation,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = ensureObject(state.moderation_runtime);
|
||||
const speakingBias = ensureObject(runtime.speaking_bias);
|
||||
const candidateInterventions = Array.isArray(runtime.candidate_interventions)
|
||||
? runtime.candidate_interventions.filter(isObject)
|
||||
: [];
|
||||
const noveltyChecks = Array.isArray(runtime.recent_novelty_checks)
|
||||
? runtime.recent_novelty_checks.filter(isObject)
|
||||
: [];
|
||||
const repoSweeps = Array.isArray(runtime.recent_repo_activity_sweeps)
|
||||
? runtime.recent_repo_activity_sweeps.filter(isObject)
|
||||
: [];
|
||||
|
||||
const lastSpeech = readJsonSafe(lastSpeechPath);
|
||||
const lastSpokeAt =
|
||||
typeof lastSpeech?.sentAt === "string"
|
||||
? lastSpeech.sentAt
|
||||
: typeof speakingBias.lastSpokeAt === "string"
|
||||
? speakingBias.lastSpokeAt
|
||||
: null;
|
||||
const hoursSinceSpeech = lastSpokeAt ? hoursBetween(now, new Date(lastSpokeAt)) : 24;
|
||||
const lastSpeechWeight =
|
||||
lastSpeech && typeof lastSpeech.contentLength === "number"
|
||||
? clamp(0.25 + Math.min(lastSpeech.contentLength, 900) / 900, 0.25, 1)
|
||||
: 0.5;
|
||||
const recentSpeechDamping = clamp(Math.exp(-hoursSinceSpeech / 3.5) * lastSpeechWeight, 0, 1);
|
||||
const draftPressure = candidateInterventions.reduce((score, entry) => {
|
||||
const status = typeof entry.status === "string" ? entry.status : "";
|
||||
const priority = typeof entry.priority === "string" ? entry.priority : "";
|
||||
if (status !== "draft") {
|
||||
return score;
|
||||
}
|
||||
|
||||
return score + (priority === "medium" ? 0.18 : priority === "high" ? 0.24 : 0.1);
|
||||
}, 0);
|
||||
const recentNoveltyPressure = noveltyChecks
|
||||
.slice(-6)
|
||||
.filter((entry) => typeof entry.result === "string" && entry.result.toLowerCase() === "novel").length * 0.08;
|
||||
const freshRepoSweepPressure = repoSweeps
|
||||
.slice(-3)
|
||||
.reduce((score, sweep) => {
|
||||
const repoNames = Array.isArray(sweep.repoNames) ? sweep.repoNames.length : 0;
|
||||
const sweepAgeHours =
|
||||
typeof sweep.timestamp === "string" ? hoursBetween(now, new Date(sweep.timestamp)) : 999;
|
||||
if (repoNames < Number(speakingBias.minimumActiveReposForHeraldNote ?? 2) || sweepAgeHours > 18) {
|
||||
return score;
|
||||
}
|
||||
|
||||
return score + 0.12;
|
||||
}, 0);
|
||||
const topSpeakUrge = topThreadDesire(runtime, 4);
|
||||
const silencePressure = clamp(hoursSinceSpeech / 6, 0, 1);
|
||||
|
||||
speakingBias.heraldRepoActivity = speakingBias.heraldRepoActivity !== false;
|
||||
speakingBias.minimumActiveReposForHeraldNote = Number(speakingBias.minimumActiveReposForHeraldNote ?? 2);
|
||||
speakingBias.maxQuietRunsBeforePostingFreshRepoObservation = Number(
|
||||
speakingBias.maxQuietRunsBeforePostingFreshRepoObservation ?? 2,
|
||||
);
|
||||
speakingBias.lastSpokeAt = lastSpokeAt;
|
||||
speakingBias.recentSpeechDamping = round3(recentSpeechDamping);
|
||||
|
||||
const targetNeedToSpeak = clamp(
|
||||
0.2 + silencePressure * 0.32 + draftPressure * 0.45 + freshRepoSweepPressure + topSpeakUrge * 0.28 - recentSpeechDamping * 0.5,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const priorNeedToSpeak =
|
||||
typeof speakingBias.needToSpeak === "number" ? speakingBias.needToSpeak : 0.42;
|
||||
speakingBias.needToSpeak = round3(priorNeedToSpeak + (targetNeedToSpeak - priorNeedToSpeak) * 0.42);
|
||||
|
||||
const targetConfessionPressure = clamp(
|
||||
0.16 + silencePressure * 0.22 + topExpressiveDelta(canonicalState) * 0.35 - recentSpeechDamping * 0.25,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const priorConfessionPressure =
|
||||
typeof speakingBias.confessionPressure === "number" ? speakingBias.confessionPressure : 0.33;
|
||||
speakingBias.confessionPressure = round3(
|
||||
priorConfessionPressure + (targetConfessionPressure - priorConfessionPressure) * 0.38,
|
||||
);
|
||||
|
||||
const targetNoveltyPressure = clamp(
|
||||
0.2 + recentNoveltyPressure + freshRepoSweepPressure * 0.7 + topSpeakUrge * 0.18,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const priorNoveltyPressure =
|
||||
typeof speakingBias.noveltyPressure === "number" ? speakingBias.noveltyPressure : 0.46;
|
||||
speakingBias.noveltyPressure = round3(
|
||||
priorNoveltyPressure + (targetNoveltyPressure - priorNoveltyPressure) * 0.4,
|
||||
);
|
||||
|
||||
state.moderation_runtime = runtime;
|
||||
writeJson(statePath, state);
|
||||
|
||||
const strongestShifts = touchedVectors
|
||||
.sort((left, right) => Math.abs(right.delta) - Math.abs(left.delta))
|
||||
.slice(0, 8)
|
||||
.map((entry) => ({
|
||||
key: entry.key,
|
||||
previous: round3(entry.previous),
|
||||
next: round3(entry.next),
|
||||
delta: round3(entry.delta),
|
||||
}));
|
||||
|
||||
writeStatus({
|
||||
status: "ok",
|
||||
observedAt: now.toISOString(),
|
||||
statePath,
|
||||
strongestShifts,
|
||||
speakingBias: {
|
||||
lastSpokeAt: speakingBias.lastSpokeAt ?? null,
|
||||
needToSpeak: speakingBias.needToSpeak,
|
||||
confessionPressure: speakingBias.confessionPressure,
|
||||
noveltyPressure: speakingBias.noveltyPressure,
|
||||
recentSpeechDamping: speakingBias.recentSpeechDamping,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function topThreadDesire(runtime, limit) {
|
||||
const thoughtLanes = isObject(runtime.thought_lanes) ? runtime.thought_lanes : {};
|
||||
const desires = [];
|
||||
|
||||
for (const lane of ["analytic", "associative"]) {
|
||||
const activeThreads = Array.isArray(thoughtLanes?.[lane]?.active_threads)
|
||||
? thoughtLanes[lane].active_threads
|
||||
: [];
|
||||
for (const thread of activeThreads) {
|
||||
if (isObject(thread) && typeof thread.desireToSpeak === "number") {
|
||||
desires.push(thread.desireToSpeak);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (desires.length === 0) {
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
return desires
|
||||
.sort((left, right) => right - left)
|
||||
.slice(0, limit)
|
||||
.reduce((sum, value) => sum + value, 0) / Math.min(desires.length, limit);
|
||||
}
|
||||
|
||||
function topExpressiveDelta(canonicalState) {
|
||||
let strongest = 0;
|
||||
|
||||
for (const categoryName of ["voice_style", "behavioral_dimensions", "presentation_strategy", "situational_state"]) {
|
||||
const category = canonicalState?.[categoryName];
|
||||
if (!isObject(category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const value of Object.values(category)) {
|
||||
if (!isObject(value) || typeof value.mean !== "number" || typeof value.current_activation !== "number") {
|
||||
continue;
|
||||
}
|
||||
strongest = Math.max(strongest, Math.abs(value.current_activation - value.mean));
|
||||
}
|
||||
}
|
||||
|
||||
return strongest;
|
||||
}
|
||||
|
||||
function computeTargetActivation({
|
||||
categoryName,
|
||||
vectorName,
|
||||
mean,
|
||||
plasticity,
|
||||
now,
|
||||
periodHours,
|
||||
amplitudeMultiplier,
|
||||
}) {
|
||||
const time = now.getTime() / (periodHours * 60 * 60 * 1000);
|
||||
const seed = hashString(`${categoryName}:${vectorName}`);
|
||||
const noise = fbmNoise(seed, time);
|
||||
const amplitude = (0.06 + plasticity * 0.24) * amplitudeMultiplier;
|
||||
return clamp(mean + noise * amplitude, 0, 1);
|
||||
}
|
||||
|
||||
function getCategoryConfig(categoryName) {
|
||||
switch (categoryName) {
|
||||
case "underlying_organization":
|
||||
return { periodHours: 14, amplitudeMultiplier: 0.55, driftBonus: 0.02 };
|
||||
case "stable_dispositions":
|
||||
return { periodHours: 9, amplitudeMultiplier: 0.65, driftBonus: 0.03 };
|
||||
case "behavioral_dimensions":
|
||||
return { periodHours: 5.5, amplitudeMultiplier: 1.0, driftBonus: 0.05 };
|
||||
case "presentation_strategy":
|
||||
return { periodHours: 4.5, amplitudeMultiplier: 0.95, driftBonus: 0.05 };
|
||||
case "voice_style":
|
||||
return { periodHours: 4.0, amplitudeMultiplier: 0.9, driftBonus: 0.06 };
|
||||
case "situational_state":
|
||||
return { periodHours: 2.5, amplitudeMultiplier: 1.1, driftBonus: 0.08 };
|
||||
default:
|
||||
return { periodHours: 6, amplitudeMultiplier: 0.75, driftBonus: 0.03 };
|
||||
}
|
||||
}
|
||||
|
||||
function fbmNoise(seed, time) {
|
||||
let total = 0;
|
||||
let amplitude = 1;
|
||||
let frequency = 1;
|
||||
let amplitudeSum = 0;
|
||||
|
||||
for (let octave = 0; octave < 4; octave += 1) {
|
||||
total += valueNoise1d(seed + octave * 1013, time * frequency) * amplitude;
|
||||
amplitudeSum += amplitude;
|
||||
amplitude *= 0.5;
|
||||
frequency *= 2;
|
||||
}
|
||||
|
||||
return amplitudeSum === 0 ? 0 : total / amplitudeSum;
|
||||
}
|
||||
|
||||
function valueNoise1d(seed, x) {
|
||||
const x0 = Math.floor(x);
|
||||
const x1 = x0 + 1;
|
||||
const t = smoothstep(x - x0);
|
||||
const v0 = hashToUnit(seed, x0);
|
||||
const v1 = hashToUnit(seed, x1);
|
||||
return lerp(v0, v1, t);
|
||||
}
|
||||
|
||||
function hashToUnit(seed, x) {
|
||||
let value = seed ^ Math.imul(x, 0x45d9f3b);
|
||||
value = Math.imul(value ^ (value >>> 16), 0x45d9f3b);
|
||||
value = value ^ (value >>> 16);
|
||||
return ((value >>> 0) / 0xffffffff) * 2 - 1;
|
||||
}
|
||||
|
||||
function hashString(input) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function smoothstep(t) {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function round3(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function hoursBetween(later, earlier) {
|
||||
return Math.max(0, (later.getTime() - earlier.getTime()) / (1000 * 60 * 60));
|
||||
}
|
||||
|
||||
function ensureObject(value) {
|
||||
return isObject(value) ? value : {};
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(stripBom(readFileSync(path, "utf8")));
|
||||
}
|
||||
|
||||
function readJsonSafe(path) {
|
||||
try {
|
||||
return readJson(path);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(path, value) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function writeStatus(payload) {
|
||||
writeJson(statusPath, payload);
|
||||
}
|
||||
|
||||
function stripBom(input) {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
||||
function withLock(fn) {
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
|
||||
try {
|
||||
const staleThresholdMs = 15 * 60 * 1000;
|
||||
if (isRecentLockPresent(lockPath, staleThresholdMs)) {
|
||||
writeStatus({
|
||||
status: "skipped",
|
||||
reason: "mood_lock_present",
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = openSync(lockPath, "wx");
|
||||
writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2));
|
||||
closeSync(fd);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
||||
writeStatus({
|
||||
status: "skipped",
|
||||
reason: "mood_lock_present",
|
||||
observedAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
rmSync(lockPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentLockPresent(path, thresholdMs) {
|
||||
try {
|
||||
const stats = statSync(path);
|
||||
return Date.now() - stats.mtimeMs < thresholdMs;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
writeStatus({
|
||||
status: "failed",
|
||||
observedAt: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Loading…
Reference in a new issue