Revive heartbeat state and cool empty-room loops
This commit is contained in:
parent
e8272f1abf
commit
555ee2f57d
7 changed files with 135 additions and 9 deletions
|
|
@ -51,6 +51,7 @@ Use `memories` as live social memory, not ceremonial scrapbooking:
|
|||
- keep only the level of detail that would help future moderation, participation, or contextual understanding
|
||||
- novelty matters more than polishing the same old thought until it shines like a worry stone
|
||||
- if a new musing substantially overlaps one of the last few musings, either sharpen the genuinely new delta or discard it
|
||||
- repeated "no new messages / no smoke / no post" observations are low-signal bookkeeping, not a deep new memory; merge or trim them aggressively instead of letting them annex the state
|
||||
- treat `semanticVector` metadata, `memory_resonance`, and `incubation` as real organs rather than decorative bookkeeping
|
||||
- a thought is allowed to stay private for several runs while it deepens, but only if it is actually gaining connective tissue instead of being gently embalmed
|
||||
|
||||
|
|
@ -215,6 +216,7 @@ should leave the state cleaner and more connected than it found it.
|
|||
- if the sweep finds a fresh, speak-worthy convergence across active repos, prefer producing or refreshing one concise candidate intervention instead of just admiring it privately
|
||||
- novelty-check any candidate herald thought with `search_history`; if the room has not already said the thing in that shape, almost always bring it up
|
||||
- use `memory_resonance` and `incubation` to decide whether a thought wants another deep dive, a distilled dream, a held draft, or actual speech
|
||||
- do not let empty-room bookkeeping become the main incubating seam; if the strongest cluster is mostly "no new traffic / no smoke / no post", cool it off and go find a better question
|
||||
- touch one analytic thread and one associative thread even if only one becomes salient enough to keep
|
||||
- pick one or two seeds from `moderation_runtime.rumination_seeds`, `open_cases`, or `watch_patterns`
|
||||
- inspect older Discord history with `search_history` and `get_message_context`
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- The loop is now less shy about that repo weather: if a fresh convergence across active repos survives a pass or two and still feels room-native, it should bias toward a concise herald note or at least a medium-priority candidate intervention instead of filing the thought under private wallpaper.
|
||||
- There is also a separate 5-minute scheduled task named `Void Mood Drift`. It runs `scripts/run-void-mood-drift.ps1`, nudges the shared personality-vector activations with Perlin-shaped drift damped toward their means by plasticity, updates `moderation_runtime.speaking_bias`, and uses the local `void-last-speech.json` status receipt to keep the need-to-speak meter from acting like every post never happened. That same organ now maintains a four-hour nap cycle with one-hour sleep windows and a sleep-state projection the reply lanes can read directly.
|
||||
- That same mood organ now also runs a hybrid symbolic/vector memory pass through `scripts/void-memory-organ.mjs`: persistent memories and recent runtime seeds get compact semantic vectors, `moderation_runtime.memory_resonance` tracks the strongest cross-memory clusters, and `moderation_runtime.incubation` keeps multi-run thoughts alive long enough to deepen before they either speak or cool off.
|
||||
- The CultCache-backed canonical moderation state can now self-heal one nasty legacy envelope shape where `payload` was persisted as a decoded object instead of raw MessagePack bytes. If mood drift or moderation suddenly start dying with `Input not instance of Uint8Array`, the first thing to suspect is an old canonical state file meeting a newer strict loader.
|
||||
- The memory organ now treats repeated empty-room bookkeeping as low-signal. Refrains like `no new messages / no smoke / no post` should be merged or cooled off instead of becoming the dominant incubating seam and teaching Void that silence is the only virtue.
|
||||
- During naps, the moderation loop is supposed to turn inward: distill/prune memories against the active goals, record dream residue in `memories.dreams`, refresh `moderation_runtime.sleep_cycle`, and only break the nap for real smoke or an unusually novel thought worth muttering into the room.
|
||||
- The intended shape now is: quiet runs can follow one seam for several passes, connect repo motion to lore or philosophy if the evidence supports it, and only surface with the juiciest version once the incubation queue says the thought has actually grown teeth.
|
||||
- Directly invoked Discord replies now load that same private moderation state as distilled private self-state context, so the ruminating scheduled loop and the summoned Void share one evolving personality/state spine. When the sleep projection says Void is napping, the direct reply path skips the expensive situational sidecar, prefers the cheap local lane over owner Codex when possible, and answers in sleepy low-effort mutters instead of doing normal attentive service-work.
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ Within the Postgres path, the implementation is split on purpose now too:
|
|||
1. The Windows scheduled task `Void Mood Drift` starts `scripts/run-void-mood-drift.ps1` every 5 minutes.
|
||||
2. That wrapper calls `node scripts/simulate-void-mood.mjs`, which reads canonical moderation state from `.voidbot/private/moderation-agent-state.msgpack`, then refreshes the editable JSON working projection after each pass.
|
||||
3. The script first runs `scripts/void-memory-organ.mjs` over the shared self-state: episodic/semantic/musing/dream memories plus recent repo/archive/runtime seeds get compact semantic vectors, the strongest cross-memory similarities become `moderation_runtime.memory_resonance`, and the best cluster candidates are promoted into `moderation_runtime.incubation`.
|
||||
The collector now intentionally downweights repetitive empty-room bookkeeping, so strings like `no new messages / no smoke / no post` stop masquerading as the deepest thought in the machine just because the room stayed quiet for a while.
|
||||
4. It then nudges each canonical-state `current_activation` toward a Perlin-shaped target centered on the vector mean, with excursion size and drift rate governed by plasticity and category-specific tempo.
|
||||
5. The same script also maintains `moderation_runtime.sleep_cycle`: every four hours it opens a one-hour nap window, marks the shared self-state as napping or awake, and adds a small sleep-bias push toward exhaustion, figurative language, self-disclosure, and reduced technical density while the nap is active. During those nap windows it can also create or reinforce distilled dream residue from the strongest incubating thought.
|
||||
6. It also reads `.voidbot/status/void-last-speech.json` so recent actual speech damps `needToSpeak` instead of letting the herald impulse climb forever like a busted pressure gauge.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ $lockPath = Join-Path $statusDir "moderation-rumination.lock"
|
|||
$mcpServerPath = Join-Path $repoRoot "apps\worker\dist\mcp-server.js"
|
||||
$recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs"
|
||||
$stateStoreScriptPath = Join-Path $repoRoot "scripts\moderation-state-store.mjs"
|
||||
$startedAtUtc = [DateTime]::UtcNow
|
||||
|
||||
function Read-DotEnv {
|
||||
param(
|
||||
|
|
@ -202,6 +203,28 @@ function Invoke-NodeJson {
|
|||
return $output | ConvertFrom-Json
|
||||
}
|
||||
|
||||
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
|
||||
noPost = [bool]$NoPost
|
||||
repoRoot = $repoRoot
|
||||
stateFile = $stateFilePath
|
||||
stateWorkingFile = $stateWorkingPath
|
||||
tracePath = $tracePath
|
||||
lastMessagePath = $lastMessagePath
|
||||
}
|
||||
|
||||
Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
if (Test-Path $lockPath) {
|
||||
$lock = Get-Content -Path $lockPath -Raw | ConvertFrom-Json
|
||||
$startedAt = $null
|
||||
|
|
@ -227,8 +250,6 @@ if (Test-Path $lockPath) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
$startedAtUtc = [DateTime]::UtcNow
|
||||
$envValues = Read-DotEnv -Path $envFile
|
||||
$codexExecutable = if ($envValues.ContainsKey("CODEX_EXECUTABLE") -and -not [string]::IsNullOrWhiteSpace($envValues["CODEX_EXECUTABLE"])) {
|
||||
$envValues["CODEX_EXECUTABLE"]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ const DEFAULT_VECTOR_DIMENSIONS = 96;
|
|||
const MAX_EDGE_COUNT = 24;
|
||||
const MAX_CLUSTER_COUNT = 8;
|
||||
const MAX_INCUBATING_THOUGHTS = 8;
|
||||
const MAX_RECENT_EPISODIC_RECORDS = 10;
|
||||
const MAX_RECENT_QUIET_EPISODIC_RECORDS = 2;
|
||||
const MAX_RECENT_ANALYTIC_THREADS = 6;
|
||||
const MAX_RECENT_QUIET_ANALYTIC_THREADS = 1;
|
||||
const EDGE_SIMILARITY_THRESHOLD = 0.56;
|
||||
const CLUSTER_SIMILARITY_THRESHOLD = 0.64;
|
||||
const stopwords = new Set([
|
||||
|
|
@ -35,6 +39,7 @@ const stopwords = new Set([
|
|||
"can",
|
||||
"current",
|
||||
"did",
|
||||
"discord",
|
||||
"do",
|
||||
"does",
|
||||
"doing",
|
||||
|
|
@ -63,6 +68,8 @@ const stopwords = new Set([
|
|||
"made",
|
||||
"make",
|
||||
"means",
|
||||
"message",
|
||||
"messages",
|
||||
"more",
|
||||
"most",
|
||||
"new",
|
||||
|
|
@ -72,17 +79,20 @@ const stopwords = new Set([
|
|||
"of",
|
||||
"on",
|
||||
"one",
|
||||
"owner",
|
||||
"or",
|
||||
"other",
|
||||
"our",
|
||||
"out",
|
||||
"over",
|
||||
"own",
|
||||
"post",
|
||||
"posted",
|
||||
"quiet",
|
||||
"recent",
|
||||
"run",
|
||||
"same",
|
||||
"saved",
|
||||
"seam",
|
||||
"should",
|
||||
"small",
|
||||
|
|
@ -98,6 +108,7 @@ const stopwords = new Set([
|
|||
"thing",
|
||||
"this",
|
||||
"thought",
|
||||
"traffic",
|
||||
"through",
|
||||
"to",
|
||||
"too",
|
||||
|
|
@ -120,6 +131,7 @@ const stopwords = new Set([
|
|||
"would",
|
||||
"yet",
|
||||
"you",
|
||||
"smoke",
|
||||
]);
|
||||
|
||||
export async function reconcileSemanticMemoryState({
|
||||
|
|
@ -196,7 +208,10 @@ export async function reconcileSemanticMemoryState({
|
|||
function collectMemoryRecords({ memories, runtime }) {
|
||||
const records = [];
|
||||
|
||||
pushArrayRecords(records, ensureArray(memories.episodic).slice(-10), "episodic", (entry) => ({
|
||||
pushArrayRecords(records, selectRecentRecords(ensureArray(memories.episodic), {
|
||||
limit: MAX_RECENT_EPISODIC_RECORDS,
|
||||
quietLimit: MAX_RECENT_QUIET_EPISODIC_RECORDS,
|
||||
}), "episodic", (entry) => ({
|
||||
text: readString(entry, "summary"),
|
||||
label: readString(entry, "summary") ?? "episodic memory",
|
||||
timestamp: readString(entry, "timestamp"),
|
||||
|
|
@ -230,7 +245,10 @@ function collectMemoryRecords({ memories, runtime }) {
|
|||
}));
|
||||
pushArrayRecords(
|
||||
records,
|
||||
ensureArray(ensureObject(runtime.thought_lanes).analytic?.active_threads).slice(-6),
|
||||
selectRecentRecords(ensureArray(ensureObject(runtime.thought_lanes).analytic?.active_threads), {
|
||||
limit: MAX_RECENT_ANALYTIC_THREADS,
|
||||
quietLimit: MAX_RECENT_QUIET_ANALYTIC_THREADS,
|
||||
}),
|
||||
"analytic_thread",
|
||||
(entry) => ({
|
||||
text: [readString(entry, "topic"), readString(entry, "claim"), readString(entry, "counterweight")]
|
||||
|
|
@ -287,6 +305,7 @@ function pushArrayRecords(target, entries, kind, toRecord) {
|
|||
text,
|
||||
label: record.label ?? kind,
|
||||
timestamp: record.timestamp ?? null,
|
||||
lowSignalQuietRoom: isLowSignalQuietRoomText(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -326,6 +345,9 @@ function buildAssociationEdges(records) {
|
|||
for (let rightIndex = leftIndex + 1; rightIndex < records.length; rightIndex += 1) {
|
||||
const left = records[leftIndex];
|
||||
const right = records[rightIndex];
|
||||
if (left.lowSignalQuietRoom && right.lowSignalQuietRoom) {
|
||||
continue;
|
||||
}
|
||||
const threshold = edgeThresholdForKinds(left.kind, right.kind);
|
||||
const similarity = cosineSimilarity(
|
||||
left.entry.semanticVector.values,
|
||||
|
|
@ -413,6 +435,8 @@ function buildClusters(records, edges) {
|
|||
if (sourceKinds.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const quietSignalCount = items.filter((item) => item.lowSignalQuietRoom).length;
|
||||
const quietSignalRatio = quietSignalCount / items.length;
|
||||
const resonance = averagePairwiseSimilarity(items);
|
||||
const keywords = topKeywords(items.map((item) => item.text).join(" "), 4);
|
||||
const label = keywords.length > 0 ? keywords.join(" / ") : `${sourceKinds.join(" + ")} seam`;
|
||||
|
|
@ -426,6 +450,7 @@ function buildClusters(records, edges) {
|
|||
memoryIds: items.map((item) => item.memoryId),
|
||||
sourceKinds,
|
||||
topKeywords: keywords,
|
||||
quietSignalRatio: round3(quietSignalRatio),
|
||||
lastStrengthenedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
|
@ -461,21 +486,24 @@ function reconcileIncubation({ previous, clusters, runtime, now }) {
|
|||
overlapRatio(ensureStringArray(thought.sourceMemoryIds), cluster.memoryIds) >= 0.45,
|
||||
);
|
||||
const sourceDiversity = Math.min(1, cluster.sourceKinds.length / 4);
|
||||
const quietSignalRatio = readNumber(cluster, "quietSignalRatio") ?? 0;
|
||||
const priorMaturation = readNumber(previousThought, "maturation") ?? 0.32;
|
||||
const deepDiveCount = (readNumber(previousThought, "deepDiveCount") ?? 0) + 1;
|
||||
const maturation = clamp(
|
||||
priorMaturation * 0.56 + cluster.resonance * 0.26 + sourceDiversity * 0.18,
|
||||
priorMaturation * 0.56 + cluster.resonance * 0.26 + sourceDiversity * 0.18 - quietSignalRatio * 0.24,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const novelty = clamp(0.35 + sourceDiversity * 0.28 + quietNovelty * 0.42, 0, 1);
|
||||
const novelty = clamp(0.35 + sourceDiversity * 0.28 + quietNovelty * 0.42 - quietSignalRatio * 0.18, 0, 1);
|
||||
const desireToSpeak = clamp(
|
||||
cluster.resonance * 0.38 + maturation * 0.34 + novelty * 0.22 + needToSpeak * 0.14 - recentSpeechDamping * 0.22,
|
||||
cluster.resonance * 0.38 + maturation * 0.34 + novelty * 0.22 + needToSpeak * 0.14 - recentSpeechDamping * 0.22 - quietSignalRatio * 0.26,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const status =
|
||||
desireToSpeak >= 0.72 || maturation >= 0.78
|
||||
quietSignalRatio >= 0.55 && desireToSpeak < 0.68
|
||||
? "cooling"
|
||||
: desireToSpeak >= 0.72 || maturation >= 0.78
|
||||
? "ripe"
|
||||
: maturation <= 0.28 && recentSpeechDamping >= 0.55
|
||||
? "cooling"
|
||||
|
|
@ -488,6 +516,7 @@ function reconcileIncubation({ previous, clusters, runtime, now }) {
|
|||
sourceMemoryIds: cluster.memoryIds,
|
||||
sourceKinds: cluster.sourceKinds,
|
||||
resonance: cluster.resonance,
|
||||
quietSignalRatio: round3(quietSignalRatio),
|
||||
novelty: round3(novelty),
|
||||
maturation: round3(maturation),
|
||||
desireToSpeak: round3(desireToSpeak),
|
||||
|
|
@ -591,6 +620,12 @@ function buildAttractionLine(cluster) {
|
|||
}
|
||||
|
||||
function buildHoldingLine({ cluster, status, recentSpeechDamping }) {
|
||||
const quietSignalRatio = readNumber(cluster, "quietSignalRatio") ?? 0;
|
||||
|
||||
if (quietSignalRatio >= 0.55) {
|
||||
return "This seam is mostly empty-room bookkeeping; keep at most a trace of it and go find a better question.";
|
||||
}
|
||||
|
||||
if (status === "ripe") {
|
||||
return "This seam has enough connective tissue that silence should be a choice, not a reflex.";
|
||||
}
|
||||
|
|
@ -785,6 +820,70 @@ function normalizeText(value) {
|
|||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function selectRecentRecords(entries, { limit, quietLimit }) {
|
||||
const recent = ensureArray(entries).slice(-Math.max(limit * 3, limit));
|
||||
const quiet = [];
|
||||
const active = [];
|
||||
|
||||
for (let index = recent.length - 1; index >= 0; index -= 1) {
|
||||
const entry = recent[index];
|
||||
if (!isObject(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = normalizeText([
|
||||
readString(entry, "summary"),
|
||||
readString(entry, "claim"),
|
||||
readString(entry, "topic"),
|
||||
readString(entry, "counterweight"),
|
||||
].filter(Boolean).join(" | "));
|
||||
|
||||
if (isLowSignalQuietRoomText(text)) {
|
||||
if (quiet.length < quietLimit) {
|
||||
quiet.unshift(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (active.length < limit - quietLimit) {
|
||||
active.unshift(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return [...active, ...quiet].slice(-limit);
|
||||
}
|
||||
|
||||
function isLowSignalQuietRoomText(text) {
|
||||
const normalized = normalizeText(text).toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const quietSignals = [
|
||||
"no new discord",
|
||||
"no fresh discord",
|
||||
"no new messages",
|
||||
"no fresh live traffic",
|
||||
"no live moderation",
|
||||
"no moderation smoke",
|
||||
"no owner escalation",
|
||||
"room was empty",
|
||||
"room is idle",
|
||||
"there was no live moderation pressure",
|
||||
"nothing got posted",
|
||||
"no discord post",
|
||||
];
|
||||
|
||||
let matches = 0;
|
||||
for (const signal of quietSignals) {
|
||||
if (normalized.includes(signal)) {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return matches >= 2;
|
||||
}
|
||||
|
||||
function overlapRatio(left, right) {
|
||||
if (left.length === 0 || right.length === 0) {
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -58,3 +58,4 @@
|
|||
{"ts":"2026-05-06T04:25:53Z","type":"architecture","status":"ok","note":"Discord outbound now supports a shared webhook persona pipe for guild channels and threads: agents can override personaName and avatar per message through one reusable per-channel webhook while DMs stay on the base bot identity."}
|
||||
{"ts":"2026-05-06T07:48:31Z","type":"implementation","status":"ok","note":"Backup watchdog now ignores manifest-less backup carcasses, backup cleanup removes incomplete directories on failure, and Qdrant snapshot creation uses a longer curl timeout so larger source collections stop timing out the offsite backup run."}
|
||||
{"ts":"2026-05-06T12:21:16Z","type":"research","status":"ok","note":"CultNet now has a real cross-runtime interop lab in CultNetTS: TS orchestrates TS/Rust/C# peers over the shared cultnet.schema.v0 lane, proving same-machine raw document exchange plus multicast discovery across all three runtimes."}
|
||||
{"ts":"2026-05-07T21:39:16Z","type":"research","status":"ok","note":"Heartbeat Void was not philosophically withdrawn; the canonical moderation state had a legacy CultCache envelope with object payloads that newer strict loaders rejected, killing mood drift and moderation before they could update status. The cache layer now self-heals that shape on read, the moderation runner now writes failed status on preflight death, and the memory organ explicitly downweights repetitive empty-room bookkeeping so 'no new messages / no smoke / no post' stops overpowering live-room hooks."}
|
||||
|
|
|
|||
2
vendor/cultcache-ts
vendored
2
vendor/cultcache-ts
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit be0a96db7b8c3f116e7dca9a73145a9540682ae9
|
||||
Subproject commit 526a47e06a4f040cc40db24de4d969d42eb61fbe
|
||||
Loading…
Reference in a new issue