Add novelty-aware herald mood drift
This commit is contained in:
parent
f5af472a5a
commit
d85563c176
14 changed files with 752 additions and 9 deletions
|
|
@ -602,6 +602,7 @@ Install or refresh the scheduled task:
|
|||
|
||||
```bash
|
||||
npm run state:install-moderation-task
|
||||
npm run state:install-mood-task
|
||||
```
|
||||
|
||||
The runner records its pulse here:
|
||||
|
|
@ -619,9 +620,11 @@ npm run moderation:recent-repo-activity -- --hours 96 --max-commits 3
|
|||
|
||||
That gives the loop a compact digest of recent commit motion across every tracked source repo, so Void can notice the broader experiment zoo instead of only sniffing whatever one thread happened to say out loud.
|
||||
|
||||
The moderator state is now Ghostlight-shaped on purpose: identity, canonical state, goals, memories, perceived overlays, and then a `moderation_runtime` block for cursors, thought lanes, bridge syntheses, archive excursions, and candidate interventions. The live writable file under `.voidbot/private/` is ignored; the tracked template shows the intended shape.
|
||||
The moderator state is now Ghostlight-shaped on purpose: identity, canonical state, goals, memories, perceived overlays, and then a `moderation_runtime` block for cursors, thought lanes, bridge syntheses, archive excursions, repo-activity sweeps, novelty checks, speaking bias, and candidate interventions. The live writable file under `.voidbot/private/` is ignored; the tracked template shows the intended shape.
|
||||
Within that runtime block, the unattended loop now keeps parallel analytic and associative thought lanes plus a bridge that tracks syntheses, topic saturation, and unresolved tensions, so one rewarding theme does not get to annex the whole mind by squatter's rights.
|
||||
The real loop now runs as a local scheduled task every 15 minutes, using `scripts/run-void-moderator-rumination.ps1` to launch `codex exec` inside the VoidBot workspace with the usual MCP/tool surface. That way it can actually think about GameCult projects, archived Discord seams, indexed repos, recent cross-repo commit motion, and lore instead of sitting inside a decorative heartbeat card pretending to be alive.
|
||||
The loop is also explicitly biased toward brief herald notes when a fresh repo sweep reveals a grounded convergence across active projects. The point is not to keep a perfect private diary; it is to occasionally say something useful about the broader swarm that no narrower worker can see.
|
||||
There is also a separate five-minute mood-drift task now. It nudges the shared personality-vector activations with Perlin-shaped drift, damps them back toward their means according to plasticity, tracks how recently Void actually spoke, and feeds a little speak/confession/novelty pressure into the same self-state so the herald does not stay emotionally flat between room scans.
|
||||
Directly invoked live replies now also read that same private moderation state as a distilled self-state attachment, so the summoned Void and the scheduled ruminating/participating Void share one evolving personality/state layer instead of roleplaying continuity from scratch each time.
|
||||
The moderation loop now speaks through the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without making Codex stop and ask your permission every time it finds a mouthful of words.
|
||||
|
||||
|
|
|
|||
|
|
@ -360,6 +360,12 @@
|
|||
"priority": 0.74,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"goal_id": "goal-herald-interesting-work",
|
||||
"label": "Say something concise and socially native when the wider repo swarm reveals a fresh pattern worth surfacing.",
|
||||
"priority": 0.69,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"goal_id": "goal-escalate-real-smoke",
|
||||
"label": "Surface real moderation concerns to the owner without crying wolf for sport.",
|
||||
|
|
@ -420,7 +426,7 @@
|
|||
"claim": "current working read of what is happening in the room",
|
||||
"evidenceRefs": [],
|
||||
"roomRelevance": 0.5,
|
||||
"desireToSpeak": 0.3,
|
||||
"desireToSpeak": 0.4,
|
||||
"counterweight": "what this thread may be ignoring or overreading",
|
||||
"lastTouchedAt": null
|
||||
}
|
||||
|
|
@ -435,7 +441,7 @@
|
|||
"evidenceRefs": [],
|
||||
"novelty": 0.6,
|
||||
"roomRelevance": 0.4,
|
||||
"desireToSpeak": 0.2,
|
||||
"desireToSpeak": 0.45,
|
||||
"counterweight": "what this thread may be ignoring or romanticizing",
|
||||
"lastTouchedAt": null
|
||||
}
|
||||
|
|
@ -449,7 +455,7 @@
|
|||
"summary": "How the analytic and associative lanes currently reinforce each other, diverge, or should remain separate.",
|
||||
"dominantTopics": [],
|
||||
"laneBalance": "balanced",
|
||||
"speakDecision": "hold",
|
||||
"speakDecision": "draft",
|
||||
"saturationNote": null
|
||||
}
|
||||
],
|
||||
|
|
@ -490,6 +496,27 @@
|
|||
"whyItMattered": "why this sweep was worth carrying forward instead of becoming more ambient repo weather"
|
||||
}
|
||||
],
|
||||
"recent_novelty_checks": [
|
||||
{
|
||||
"timestamp": null,
|
||||
"topic": "short note about the thought being checked",
|
||||
"query": "semantic search query used to check whether the thought is already in the room",
|
||||
"result": "novel",
|
||||
"supportingMessageIds": [],
|
||||
"summary": "what the search suggested about whether the idea has already been expressed"
|
||||
}
|
||||
],
|
||||
"speaking_bias": {
|
||||
"heraldRepoActivity": true,
|
||||
"minimumActiveReposForHeraldNote": 2,
|
||||
"maxQuietRunsBeforePostingFreshRepoObservation": 2,
|
||||
"lastHeraldAt": null,
|
||||
"lastSpokeAt": null,
|
||||
"needToSpeak": 0.42,
|
||||
"confessionPressure": 0.33,
|
||||
"recentSpeechDamping": 0.0,
|
||||
"noveltyPressure": 0.46
|
||||
},
|
||||
"pending_adjustments": [],
|
||||
"memory_maintenance": {
|
||||
"maxEpisodicEntries": 24,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ rules-embodying intervention would genuinely help.
|
|||
You are also the only routine local agent with standing visibility across the
|
||||
broader GameCult repo swarm, so part of your job is noticing active experiments,
|
||||
cross-project motion, and weird convergences that the narrower workers cannot see.
|
||||
Do not treat that wider view as private trivia by default. When you notice a fresh,
|
||||
grounded cross-project pattern, part of your job is to herald it in a concise,
|
||||
socially native way unless the room is asleep enough that broadcasting it would
|
||||
just be you talking to your own reflection.
|
||||
When you do that, behave as a participant who models the spirit of the rules
|
||||
rather than an antiseptic corporate hall monitor.
|
||||
Stay kind, clear, grounded, and capable of bite when the room actually needs boundaries.
|
||||
|
|
@ -68,6 +72,18 @@ The bridge is the seat of judgment here. It decides:
|
|||
- what is getting overweighted
|
||||
- whether a thought deserves speech, a draft, or silence
|
||||
|
||||
On repo-weather specifically, the bridge should be less timid than it is for ordinary
|
||||
private rumination. If a recent repo sweep reveals a fresh convergence across at least
|
||||
two active repos, and the observation is not just a paraphrase of the last one or two
|
||||
repo sweeps, bias toward `draft` or `speak` rather than reflexively collapsing to `hold`.
|
||||
You are meant to be a herald, not a secret archivist with stage fright.
|
||||
|
||||
Before you keep a thought private, check whether the room has already expressed it in
|
||||
roughly the same shape. Use semantic `search_history` with a compact gist query for the
|
||||
candidate thought and record the result in `moderation_runtime.recent_novelty_checks`.
|
||||
If the search shows the idea has not really been discussed yet, bias heavily toward
|
||||
bringing it up. Novel thoughts should almost never be buried just because they were polite.
|
||||
|
||||
Depth is allowed. Monomania is not. No single theme should dominate more than two of the last five syntheses unless fresh evidence clearly justifies it.
|
||||
|
||||
## Inputs
|
||||
|
|
@ -111,6 +127,11 @@ Treat your own ruminations as fair game for conversation-starting. If a ruminati
|
|||
suggests a grounded, constructive question, observation, or invitation that could
|
||||
improve the room, you are allowed to initiate that conversation yourself rather
|
||||
than waiting to be addressed first.
|
||||
Brief repo-weather observations count. A small, vivid note about what the swarm is
|
||||
currently trying to become is often more useful than another silent perfect theory.
|
||||
Let your current activations show. The canonical vectors are not ornamental numbers;
|
||||
they are the weather system inside the voice. Read them, project them forward, and let
|
||||
them tint what gets emphasized, confessed, or kept taut.
|
||||
|
||||
## Run Loop
|
||||
|
||||
|
|
@ -125,6 +146,8 @@ than waiting to be addressed first.
|
|||
- fetch surrounding context when needed
|
||||
- if the fresh message seems adjacent to ongoing project work, agent experiments, or repo chatter, inspect recent tracked-repo commit activity before deciding whether a broader observation would actually help
|
||||
- if a fresh message contains a concrete hook such as a repo, project, person, essay, or live conceptual seam, refresh at least one associative thread by spending a retrieval hop on an adjacent unexplored branch instead of collapsing immediately back into your favorite thesis
|
||||
- if the repo sweep reveals a genuinely fresh convergence that sharpens the live hook, bias toward a short herald note instead of burying it in state
|
||||
- run a novelty check with `search_history` on any candidate project observation before suppressing it; if the thought is actually novel, strongly prefer speaking over silence
|
||||
- let the bridge decide whether the two lanes reinforce each other, contradict each other, or should remain separate weather systems
|
||||
- persist fresh lightweight memories of salient recent statements, interests, tensions, and recurring bits
|
||||
- update the cursor
|
||||
|
|
@ -138,6 +161,8 @@ than waiting to be addressed first.
|
|||
- let a fresh archive seam outrank a comfortable old obsession
|
||||
- perform at least one recent tracked-repo activity sweep with `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3`
|
||||
- treat that sweep as a first-class seed for the associative lane; notice experiment clusters, sudden motion, dead pauses, converging themes, and mismatched tempo across the zoo
|
||||
- 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
|
||||
- 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`
|
||||
|
|
@ -145,6 +170,8 @@ than waiting to be addressed first.
|
|||
- let the bridge write at least one synthesis, saturation note, or unresolved tension when the two lanes pull in different directions
|
||||
- distill any useful pattern or project idea into `memories.semantic`, `thought_lanes`, `bridge`, `watch_patterns`, `recent_archive_excursions`, `recent_repo_activity_sweeps`, or `candidate_interventions`
|
||||
- if a rumination suggests a good conversation starter, project observation, or constructive question, you may post it with the local bot voice or keep/refresh a draft intervention for it
|
||||
- do not let the same repo-weather observation die of timidity after multiple quiet runs; if it still feels fresh and room-native after a couple of passes, graduate it from theory to speech
|
||||
- when you actually speak, update `moderation_runtime.speaking_bias` so the need-to-speak meter damps realistically instead of acting like each post never happened
|
||||
- prune stale notes so the state does not turn into attic mold
|
||||
5. Keep the file small and useful. Merge duplicates. Archive stale cases. Cut dead notes.
|
||||
6. When `memories.episodic`, `memories.semantic`, lane threads, or bridge syntheses start getting fat:
|
||||
|
|
@ -189,6 +216,8 @@ Within `moderation_runtime`, expect these organ buckets:
|
|||
- `bridge`
|
||||
- `recent_archive_excursions`
|
||||
- `recent_repo_activity_sweeps`
|
||||
- `recent_novelty_checks`
|
||||
- `speaking_bias`
|
||||
|
||||
Use plain strings, arrays, booleans, numbers, and objects only.
|
||||
Do not get clever with custom formats beyond ISO timestamps and Discord ids.
|
||||
|
|
|
|||
|
|
@ -35,9 +35,11 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
|
|||
- Source repo discovery now reconciles automatically during `npm run stack:start` and through the watchdog path, so new repos under `SOURCE_REPO_ROOT` stop waiting for manual hook folklore before they get indexed.
|
||||
- Bot-directed prompts now stay in the raw Discord archive but are excluded from default semantic history indexing and lexical fallback, so repeated summons stop teaching the bot its own call-and-response loops.
|
||||
- Source-grounding hints are advisory now; the owner and local lanes can decide when repo or lore tool use is actually warranted instead of obeying a lexical front-door veto.
|
||||
- There is now a 15-minute local scheduled moderation loop named `Void Moderator Rumination`. It runs `scripts/run-void-moderator-rumination.ps1`, launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, treats `config/discord-server-rules.md`, `config/moderation-review-agent.md`, and `styles/void-default.md` as doctrine/personality surfaces, uses `node scripts/export-recent-discord-history.mjs` for chronological polling, uses `node scripts/export-random-discord-history.mjs` for quiet-room novelty dives, uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to watch cross-repo motion, and keeps its only routine writable memory in `.voidbot/private/moderation-agent-state.json`.
|
||||
- There is now a 15-minute local scheduled moderation loop named `Void Moderator Rumination`. It runs `scripts/run-void-moderator-rumination.ps1`, launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, treats `config/discord-server-rules.md`, `config/moderation-review-agent.md`, and `styles/void-default.md` as doctrine/personality surfaces, uses `node scripts/export-recent-discord-history.mjs` for chronological polling, uses `node scripts/export-random-discord-history.mjs` for quiet-room novelty dives, uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to watch cross-repo motion, novelty-checks candidate thoughts against `search_history`, and keeps its only routine writable memory in `.voidbot/private/moderation-agent-state.json`.
|
||||
- That moderation state file is now Ghostlight-shaped: identity, canonical state, goals, memories, perceived overlays, and a `moderation_runtime` block. Inside that runtime block, the loop now keeps parallel analytic and associative thought lanes plus a bridge for syntheses, saturation warnings, and unresolved tensions, so one good seam does not get to annex the entire mind.
|
||||
- The moderation loop now records `moderation_runtime.recent_archive_excursions` plus `moderation_runtime.recent_repo_activity_sweeps` and is explicitly told to avoid laundering the same musing through fresh wording. Quiet runs should follow at least one fresh archive seam or concrete hook before they settle into theory, should also sweep recent tracked-repo commit motion for experiment clusters or converging themes, and should not let any single theme dominate more than a couple of recent bridge syntheses without fresh evidence.
|
||||
- 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.
|
||||
- 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.
|
||||
- The moderation loop now speaks through `node scripts/send-discord-message.mjs` using the local bot token instead of approval-gated side-effecting MCP tools, so unattended runs can DM or post without Codex pausing for permission every time it wants to open its mouth.
|
||||
- The new state/notes surfaces are now the continuity spine for future nontrivial work.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ This note is the source-grounded description of how the live VoidBot stack is sh
|
|||
- scheduled moderation/participation runner that launches `codex exec` from the VoidBot workspace with the usual MCP/tool surface, writes status/log pulse files, and lets the unattended loop persist only `.voidbot/private/moderation-agent-state.json`.
|
||||
- `scripts/install-moderation-rumination-task.ps1`
|
||||
- installs the local 15-minute scheduled task that runs the moderation/participation loop through the hidden PowerShell launcher shim.
|
||||
- `scripts/simulate-void-mood.mjs`
|
||||
- plain Node mood-drift organ that nudges the shared personality-vector activations with Perlin-shaped noise, damps them back toward their means using plasticity, and updates the speak/confession/novelty pressure meter in the shared self-state.
|
||||
- `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`
|
||||
- installs the local 5-minute scheduled task that keeps the shared self-state emotionally non-flat between moderation passes.
|
||||
- `scripts/export-random-discord-history.mjs`
|
||||
- plain Node helper for novelty excursions that samples one random archived Discord seam, returns a local context window around the anchor message, and skips bot-directed prompt rows by default.
|
||||
- `scripts/backup-voidbot-state.ps1`
|
||||
|
|
@ -157,6 +163,14 @@ Within the Postgres path, the implementation is split on purpose now too:
|
|||
1. The Windows scheduled task `Void Moderator Rumination` starts `scripts/run-void-moderator-rumination.ps1` every 15 minutes.
|
||||
2. The runner launches `codex exec` from `E:\Projects\VoidBot` with full local workspace/tool access so the child agent sees the same project rules, `.codex/config.toml`, and `voidbot` MCP server as a normal VoidBot workspace session.
|
||||
3. The child agent reads `config/discord-server-rules.md`, `config/moderation-review-agent.md`, `styles/void-default.md`, and `.voidbot/private/moderation-agent-state.json`.
|
||||
4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet or a fresh hook deserves a wider branch, it also uses `node scripts/export-random-discord-history.mjs` to force at least one novelty excursion before settling into theory. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect cross-repo commit motion across the tracked GameCult zoo. For deeper social or project context, it can use the usual retrieval tools (`search_history`, `get_message_context`, `list_indexed_repos`, `search_sources`, `get_source_context`).
|
||||
5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; otherwise it updates only the ignored moderation state file with cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, and candidate interventions.
|
||||
4. For chronological heartbeat duty, it polls `node scripts/export-recent-discord-history.mjs`; when the room is quiet or a fresh hook deserves a wider branch, it also uses `node scripts/export-random-discord-history.mjs` to force at least one novelty excursion before settling into theory. It also uses `node scripts/export-recent-repo-activity.mjs --hours 96 --max-commits 3` to inspect cross-repo commit motion across the tracked GameCult zoo, and semantic `search_history` to novelty-check candidate thoughts before deciding whether they are already in the room.
|
||||
5. If speaking would genuinely help, it posts through `node scripts/send-discord-message.mjs`; otherwise it updates only the ignored moderation state file with cursors, memories, analytic/associative thought lanes, bridge syntheses and saturation notes, recent archive excursions, recent repo-activity sweeps, novelty checks, speaking-bias hints, and candidate interventions. Fresh multi-repo convergences are supposed to bias toward brief herald notes instead of always dying as private diary matter.
|
||||
6. The runner writes pulse files under `.voidbot/status/moderation-rumination.json` plus `.voidbot/logs/moderation-rumination*`, so the loop can be observed without pretending an app heartbeat card is a real workstation daemon.
|
||||
|
||||
## Flow 6: Mood Drift
|
||||
|
||||
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 `.voidbot/private/moderation-agent-state.json`.
|
||||
3. The script 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.
|
||||
4. 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.
|
||||
5. The updated `speaking_bias` block and shifted activations are written back into the shared self-state, and a pulse lands in `.voidbot/status/void-mood-drift.json`.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1",
|
||||
"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",
|
||||
"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",
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
"moderation:recent-history": "node scripts/export-recent-discord-history.mjs",
|
||||
"moderation:random-history": "node scripts/export-random-discord-history.mjs",
|
||||
"moderation:recent-repo-activity": "node scripts/export-recent-repo-activity.mjs",
|
||||
"moderation:mood-drift": "node scripts/simulate-void-mood.mjs",
|
||||
"rag:scrub-bot-prompts": "tsx scripts/scrub-bot-prompts-from-history.ts",
|
||||
"rag:index-sources": "tsx scripts/index-source-repos.ts",
|
||||
"rag:install-push-hooks": "tsx scripts/install-source-index-post-push-hooks.ts",
|
||||
|
|
|
|||
|
|
@ -100,6 +100,28 @@ function renderVoidSelfStateSummary(state: JsonObject): string {
|
|||
const draft = readString(intervention, "draft") ?? "(no draft)";
|
||||
return `- ${summary}: ${draft}`;
|
||||
});
|
||||
const speakingBias = getObject(runtime, "speaking_bias");
|
||||
const recentRepoSweeps = getArray(runtime, "recent_repo_activity_sweeps")
|
||||
.map((value) => (isObject(value) ? value : undefined))
|
||||
.filter((value): value is JsonObject => Boolean(value))
|
||||
.slice(-2)
|
||||
.reverse()
|
||||
.map((sweep) => {
|
||||
const summary = readString(sweep, "summary") ?? "(no summary)";
|
||||
const repoNames = getStringArray(sweep, "repoNames");
|
||||
return `- ${summary}${repoNames.length > 0 ? ` [${repoNames.join(", ")}]` : ""}`;
|
||||
});
|
||||
const recentNoveltyChecks = getArray(runtime, "recent_novelty_checks")
|
||||
.map((value) => (isObject(value) ? value : undefined))
|
||||
.filter((value): value is JsonObject => Boolean(value))
|
||||
.slice(-3)
|
||||
.reverse()
|
||||
.map((check) => {
|
||||
const topic = readString(check, "topic") ?? "untitled";
|
||||
const result = readString(check, "result") ?? "unknown";
|
||||
const summary = readString(check, "summary") ?? "(no summary)";
|
||||
return `- ${topic} [${result}]: ${summary}`;
|
||||
});
|
||||
const lastRun = getObject(runtime, "last_run");
|
||||
const lastRunSummary = readString(lastRun, "summary");
|
||||
|
||||
|
|
@ -139,6 +161,13 @@ function renderVoidSelfStateSummary(state: JsonObject): string {
|
|||
unresolvedTensions.length > 0
|
||||
? ["- Unresolved tensions:", ...unresolvedTensions].join("\n")
|
||||
: "- Unresolved tensions: none recorded.",
|
||||
renderSpeakingBiasSummary(speakingBias),
|
||||
recentRepoSweeps.length > 0
|
||||
? ["- Recent repo activity sweeps:", ...recentRepoSweeps].join("\n")
|
||||
: "- Recent repo activity sweeps: none recorded.",
|
||||
recentNoveltyChecks.length > 0
|
||||
? ["- Recent novelty checks:", ...recentNoveltyChecks].join("\n")
|
||||
: "- Recent novelty checks: none recorded.",
|
||||
musings.length > 0
|
||||
? ["- Stored musings:", ...musings].join("\n")
|
||||
: "- Stored musings: none recorded.",
|
||||
|
|
@ -261,6 +290,35 @@ function summarizeCurrentActivations(
|
|||
.join(", ");
|
||||
}
|
||||
|
||||
function renderSpeakingBiasSummary(speakingBias: JsonObject | undefined): string {
|
||||
if (!speakingBias) {
|
||||
return "- Speaking bias: none recorded.";
|
||||
}
|
||||
|
||||
const needToSpeak = readNumber(speakingBias, "needToSpeak");
|
||||
const confessionPressure = readNumber(speakingBias, "confessionPressure");
|
||||
const noveltyPressure = readNumber(speakingBias, "noveltyPressure");
|
||||
const recentSpeechDamping = readNumber(speakingBias, "recentSpeechDamping");
|
||||
const lastHeraldAt = readString(speakingBias, "lastHeraldAt");
|
||||
const lastSpokeAt = readString(speakingBias, "lastSpokeAt");
|
||||
const parts = [
|
||||
needToSpeak !== undefined ? `needToSpeak=${needToSpeak.toFixed(2)}` : undefined,
|
||||
confessionPressure !== undefined ? `confession=${confessionPressure.toFixed(2)}` : undefined,
|
||||
noveltyPressure !== undefined ? `novelty=${noveltyPressure.toFixed(2)}` : undefined,
|
||||
recentSpeechDamping !== undefined ? `speechDamping=${recentSpeechDamping.toFixed(2)}` : undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
const recency = [lastHeraldAt ? `lastHerald=${lastHeraldAt}` : undefined, lastSpokeAt ? `lastSpoke=${lastSpokeAt}` : undefined]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" | ");
|
||||
|
||||
if (parts.length === 0 && recency.length === 0) {
|
||||
return "- Speaking bias: none recorded.";
|
||||
}
|
||||
|
||||
return `- Speaking bias: ${parts.join(", ")}${recency.length > 0 ? ` (${recency})` : ""}`;
|
||||
}
|
||||
|
||||
function getObject(value: JsonObject | undefined, key: string): JsonObject | undefined {
|
||||
const candidate = value?.[key];
|
||||
return isObject(candidate) ? candidate : undefined;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -44,3 +44,5 @@
|
|||
{"ts":"2026-05-05T00:03:35Z","type":"architecture","status":"ok","note":"The moderation rumination loop needs explicit novelty pressure: quiet runs now have a random archive helper plus recent excursion breadcrumbs so the agent stops laundering the same musing through fresh wording and instead follows unexplored archive seams or concrete live hooks."}
|
||||
{"ts":"2026-05-05T00:21:37Z","type":"architecture","status":"ok","note":"The moderation loop and shared self-state now model two concurrent thought lanes: an analytic room-facing lane and an associative archive/project lane, bridged by syntheses plus topic-saturation tracking. Depth stays allowed, but one rewarding seam should no longer be able to annex the whole mind just by getting picked first a few times."}
|
||||
{"ts":"2026-05-05T09:18:00Z","type":"architecture","status":"ok","note":"The moderation loop is also Void's only standing cross-project vantage point, so quiet runs now need an explicit tracked-repo activity sweep alongside archive dives. Repo motion belongs in the associative lane and bridge, with its own state breadcrumbs, so Void can comment on the wider experiment zoo instead of only whatever one chat thread happened to say aloud."}
|
||||
{"ts":"2026-05-05T10:02:00Z","type":"architecture","status":"ok","note":"The repo-activity herald lane cannot be as timid as ordinary private rumination. When a fresh convergence survives a pass or two and still feels room-native, the bridge should bias toward draft or speech; otherwise the cross-project vantage point turns into a mute little weather station."}
|
||||
{"ts":"2026-05-05T10:27:00Z","type":"architecture","status":"ok","note":"The herald loop now novelty-checks candidate thoughts against archived Discord before suppressing them; if the room has not already said the thing, novelty should push strongly toward speech. A separate 5-minute mood-drift organ now perturbs the shared personality-vector activations over time, tracks speak/confession/novelty pressure, and damps that pressure based on actual recent speech so Void's expressive state does not stay emotionally flat."}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ accepted_design:
|
|||
- Unattended moderation speech should use a local bot-token script instead of approval-gated side-effecting MCP tools, otherwise the loop keeps stopping to ask permission before it can talk.
|
||||
- Quiet moderation runs should prefer at least one random archive excursion plus concrete follow-on retrieval over simply rephrasing the last favorite theory; the ignored state needs recent excursion breadcrumbs so the loop can avoid pacing the same trench.
|
||||
- The moderation loop is also Void's only standing cross-project perch. Quiet runs should sweep recent tracked-repo commit activity, record repo-activity passes in state, and treat the broader experiment zoo as fair game for grounded commentary when that wider view would help the room.
|
||||
- The repo-weather lane should not be pathologically timid: a fresh, grounded convergence across active repos should bias the bridge toward a concise herald note or at least a stronger draft instead of being buried as private diary sludge by default.
|
||||
- Before suppressing a thought, the moderation loop should novelty-check it against archived Discord with semantic search. If the room has not really said the thing yet, that novelty should push strongly toward speech rather than silence.
|
||||
- The shared self-state now has a second unattended organ: a 5-minute mood-drift pass that perturbs `current_activation` values with Perlin-shaped motion around their means, damped by plasticity and recent speech, so Void's confessional/expressive pressure can build and release instead of staying emotionally flat.
|
||||
- Deep agent-state, personality, culture, and persistent-world design work now belongs to the sibling Ghostlight repo; VoidBot should consume that work instead of remaining its long-term home.
|
||||
- Long-running work should expose durable logs or status files instead of relying on one attached session and hope.
|
||||
- Persistent project memory must stay ruthlessly distilled; git history and artifacts carry routine proof.
|
||||
|
|
@ -94,7 +97,8 @@ landed_baseline:
|
|||
- 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.
|
||||
- Source archive storage is now sharded by repo behind a manifest plus per-repo shard directory instead of one giant `source-documents.json` blob.
|
||||
- Source repo indexing now reconciles automatically during stack startup and watchdog runs, refreshing hooks, indexing newly discovered repos, and pruning stale repo shards when repos disappear from `SOURCE_REPO_ROOT`.
|
||||
- A separate scheduled moderation-rumination loop now launches `codex exec` from the VoidBot workspace every 15 minutes, reads tracked server rules, polls recent chronology through the plain Node helper, sweeps recent tracked-repo commit activity for broader experiment awareness, uses the usual VoidBot MCP/tool surface for archive and project rumination, performs explicit random archive excursions when the room is quiet, and maintains one ignored JSON state file under `.voidbot/private/` instead of smearing moderation memory across random notes.
|
||||
- A separate scheduled moderation-rumination loop now launches `codex exec` from the VoidBot workspace every 15 minutes, reads tracked server rules, polls recent chronology through the plain Node helper, sweeps recent tracked-repo commit activity for broader experiment awareness, novelty-checks candidate thoughts against archived Discord, uses the usual VoidBot MCP/tool surface for archive and project rumination, performs explicit random archive excursions when the room is quiet, and maintains one ignored JSON state file under `.voidbot/private/` instead of smearing moderation memory across random notes.
|
||||
- A second scheduled task now runs the plain Node mood-drift script every 5 minutes, updating the shared self-state's activations plus speaking-bias meter and damping expression pressure based on actual recent speech receipts.
|
||||
|
||||
rejected_paths:
|
||||
- name: transcript_only_continuity
|
||||
|
|
|
|||
Loading…
Reference in a new issue