Build Void incubation memory organ

This commit is contained in:
Meta Vens 2026-05-06 02:13:00 +01:00
commit 682dbc5bce
11 changed files with 1199 additions and 19 deletions

View file

@ -388,35 +388,75 @@
"memories": {
"episodic": [
{
"memoryId": "episodic-memory-id",
"timestamp": null,
"summary": "Short run-level memory of what happened recently."
"summary": "Short run-level memory of what happened recently.",
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
],
"semantic": [
{
"memoryId": "semantic-memory-id",
"subjectId": "discord-user-id-or-pattern-id",
"subjectLabel": "Display name or pattern label",
"kind": "recent-preoccupation",
"summary": "Compact memory of something they recently cared about, asked for, kept returning to, or signaled about themselves.",
"evidenceMessageIds": [],
"lastObservedAt": null,
"confidence": 0.5
"confidence": 0.5,
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
],
"musings": [
{
"memoryId": "musing-memory-id",
"timestamp": null,
"topic": "room pattern or person-pattern intersection",
"summary": "Distilled rumination worth keeping for future participation or moderation context."
"summary": "Distilled rumination worth keeping for future participation or moderation context.",
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
],
"dreams": [
{
"memoryId": "dream-memory-id",
"timestamp": null,
"theme": "distilled idea or symbolic seam",
"summary": "Salient dream memory tying distilled ideas to one vivid operative image or pressure.",
"distilledFrom": [],
"salience": 0.6
"salience": 0.6,
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
]
},
@ -497,24 +537,97 @@
}
]
},
"memory_resonance": {
"updatedAt": null,
"embeddingBackend": "ollama",
"embeddingModel": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"recent_edges": [
{
"leftMemoryId": "semantic-memory-id",
"leftLabel": "short memory label",
"rightMemoryId": "repo-sweep-memory-id",
"rightLabel": "another short memory label",
"similarity": 0.71,
"summary": "Why these two things keep rhyming in embedding space.",
"observedAt": null
}
],
"clusters": [
{
"clusterId": "cluster-id",
"label": "first-contact / visible proof / carried interface",
"summary": "Recurring seam across different memory kinds that keeps trying to become a coherent thought.",
"resonance": 0.73,
"memoryIds": [],
"sourceKinds": [],
"topKeywords": [],
"lastStrengthenedAt": null
}
]
},
"incubation": {
"lastUpdatedAt": null,
"lastIncubationSummary": "Which thought currently has the most connective tissue and why it is still being held or ripening.",
"active_thoughts": [
{
"thoughtId": "cluster-id",
"topic": "visible proof surfaces",
"summary": "Current working seam being deepened across multiple runs.",
"sourceMemoryIds": [],
"sourceKinds": [],
"resonance": 0.73,
"novelty": 0.61,
"maturation": 0.58,
"desireToSpeak": 0.54,
"deepDiveCount": 3,
"status": "incubating",
"latentQuestion": "What is this seam actually trying to become if it survives another pass?",
"whyItPulls": "Why this thought keeps returning instead of dying quietly.",
"holdingCloseBecause": "Why it is still incubating rather than being spoken immediately.",
"lastDeepenedAt": null,
"lastStatusChangeAt": null
}
]
},
"candidate_interventions": [],
"recent_archive_excursions": [
{
"memoryId": "archive-excursion-memory-id",
"timestamp": null,
"anchorMessageId": null,
"anchorTimestamp": null,
"channelId": null,
"topicHint": "short label for the archive seam that got explored",
"whyItWasFresh": "why this branch was worth taking instead of repeating an older favorite"
"whyItWasFresh": "why this branch was worth taking instead of repeating an older favorite",
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
],
"recent_repo_activity_sweeps": [
{
"memoryId": "repo-sweep-memory-id",
"timestamp": null,
"hours": 96,
"repoNames": [],
"summary": "short note about the cross-repo motion, asymmetry, or convergence that stood out",
"whyItMattered": "why this sweep was worth carrying forward instead of becoming more ambient repo weather"
"whyItMattered": "why this sweep was worth carrying forward instead of becoming more ambient repo weather",
"semanticVector": {
"version": 1,
"backend": "ollama",
"model": "qwen3-embedding:0.6b",
"compactDimensions": 96,
"sourceHash": "sha1-of-normalized-memory-text",
"embeddedAt": null,
"values": []
}
}
],
"recent_novelty_checks": [

View file

@ -48,6 +48,8 @@ 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
- 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
## Parallel Thought
@ -76,6 +78,24 @@ The bridge is the seat of judgment here. It decides:
- what is getting overweighted
- whether a thought deserves speech, a draft, or silence
Use the newer memory organs explicitly:
- `memory_resonance`
- recent high-similarity edges and clusters across episodic memory, semantic memory, musings, dreams, archive excursions, and repo sweeps
- this is "these things keep rhyming" evidence, not holy writ
- `incubation.active_thoughts`
- the queue of thoughts ripening across multiple runs
- these are allowed to deepen through extra archive, lore, or repo dives before they earn speech
- if one matures, grows novel, and still feels room-native, either surface it or record a clear reason for continuing to hold it
Rumination is allowed to work like this:
1. notice a seam
2. let it sit in incubation
3. spend one or more later runs deep-diving adjacent repo, lore, or archive material
4. connect it to life, philosophy, social behavior, or world structure when that connection is actually grounded
5. only surface it if the resulting thought has better blood than the first draft
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
@ -136,6 +156,9 @@ currently trying to become is often more useful than another silent perfect theo
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.
If a repo sweep catches your eye, you are allowed to inspect exact commit diffs,
changed files, or nearby source context before deciding what the work is really doing.
Do not stop at commit subjects if a deeper look would sharpen the thought.
## Nap Behavior
@ -152,6 +175,9 @@ When `sleep_cycle.isNapping` is true:
Sleep is not idleness here. It is maintenance, compression, and symbolic recombination.
Dreams should not be random glitter. They should be compressed memories or ideas that
matter for future operation.
Use `memory_resonance` and `incubation` during naps. Sleep is when recurring seams
should get compressed, merged, strengthened, or cooled off. If a dream survives, it
should leave the state cleaner and more connected than it found it.
## Run Loop
@ -165,6 +191,7 @@ matter for future operation.
- if speaking would help, use the local bot-voice script to reply or post proactively
- 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 seam feels promising, it is allowed to become an incubating thought instead of an immediate statement; mark it, deepen it, and revisit it later
- 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
@ -181,8 +208,10 @@ matter for future operation.
- 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 one repo or lore seam looks unusually alive, you may spend multiple quiet runs following it before reporting back; not every worthwhile thought needs same-turn publication
- 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
- 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`
@ -234,6 +263,8 @@ Within `moderation_runtime`, expect these organ buckets:
- `thought_lanes.analytic`
- `thought_lanes.associative`
- `bridge`
- `memory_resonance`
- `incubation`
- `recent_archive_excursions`
- `recent_repo_activity_sweeps`
- `recent_novelty_checks`

View file

@ -40,7 +40,9 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- 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. 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.
- 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.
- 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.
- Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard.

View file

@ -115,7 +115,9 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- `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, updates the speak/confession/novelty pressure meter in the shared self-state, and maintains the nap-cycle clock plus sleep-state vector bias.
- plain Node mood-drift organ that nudges the shared personality-vector activations with Perlin-shaped noise, damps them back toward their means using plasticity, updates the speak/confession/novelty pressure meter in the shared self-state, maintains the nap-cycle clock plus sleep-state vector bias, and now also reconciles compact semantic-memory vectors plus incubation/dream state.
- `scripts/void-memory-organ.mjs`
- hybrid symbolic/vector memory helper used by mood drift to attach compact semantic vectors to persistent memories and runtime seeds, build resonance clusters, keep an incubation queue alive across turns, and leave more meaningful dream residue during naps.
- `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`
@ -163,7 +165,7 @@ 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, and semantic `search_history` to novelty-check candidate thoughts before deciding whether they are already in the room.
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, can inspect exact diffs or nearby source context when one repo seam looks alive, and uses 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.
@ -171,7 +173,8 @@ 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 `.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. 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.
5. 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.
6. The updated `speaking_bias` block, sleep-cycle projection, and shifted activations are written back into the shared self-state, and a pulse lands in `.voidbot/status/void-mood-drift.json`.
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`.
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.
7. The updated `speaking_bias` block, sleep-cycle projection, incubation/resonance state, and shifted activations are written back into the shared self-state, and a pulse lands in `.voidbot/status/void-mood-drift.json`.

View file

@ -54,6 +54,8 @@ function renderVoidSelfStateSummary(
const canonicalState = getObject(state, "canonical_state");
const thoughtLanes = getObject(runtime, "thought_lanes");
const bridge = getObject(runtime, "bridge");
const memoryResonance = getObject(runtime, "memory_resonance");
const incubation = getObject(runtime, "incubation");
const privateNotes = getStringArray(identity, "private_notes").slice(0, 4);
const activeGoals = goals
@ -106,6 +108,8 @@ function renderVoidSelfStateSummary(
const bridgeSyntheses = summarizeBridgeSyntheses(bridge, 3);
const topicSaturation = summarizeTopicSaturation(bridge, 3);
const unresolvedTensions = summarizeUnresolvedTensions(bridge, 3);
const resonanceClusters = summarizeResonanceClusters(memoryResonance, 3);
const incubatingThoughts = summarizeIncubatingThoughts(incubation, 3);
const candidateInterventions = getArray(runtime, "candidate_interventions")
.map((value) => (isObject(value) ? value : undefined))
.filter((value): value is JsonObject => Boolean(value))
@ -173,6 +177,12 @@ function renderVoidSelfStateSummary(
bridgeSyntheses.length > 0
? ["- Bridge syntheses:", ...bridgeSyntheses].join("\n")
: "- Bridge syntheses: none recorded.",
resonanceClusters.length > 0
? ["- Resonance clusters:", ...resonanceClusters].join("\n")
: "- Resonance clusters: none recorded.",
incubatingThoughts.length > 0
? ["- Incubating thoughts:", ...incubatingThoughts].join("\n")
: "- Incubating thoughts: none recorded.",
topicSaturation.length > 0
? ["- Topic saturation warnings:", ...topicSaturation].join("\n")
: "- Topic saturation warnings: none recorded.",
@ -263,6 +273,47 @@ function summarizeBridgeSyntheses(bridge: JsonObject | undefined, limit: number)
});
}
function summarizeResonanceClusters(memoryResonance: JsonObject | undefined, limit: number): string[] {
return getArray(memoryResonance, "clusters")
.map((value) => (isObject(value) ? value : undefined))
.filter((value): value is JsonObject => Boolean(value))
.sort(
(left, right) =>
(readNumber(right, "resonance") ?? 0) - (readNumber(left, "resonance") ?? 0),
)
.slice(0, limit)
.map((cluster) => {
const label = readString(cluster, "label") ?? "untitled";
const summary = readString(cluster, "summary") ?? "(no summary)";
const resonance = readNumber(cluster, "resonance");
return `- ${label}${resonance !== undefined ? ` (resonance=${resonance.toFixed(2)})` : ""}: ${summary}`;
});
}
function summarizeIncubatingThoughts(incubation: JsonObject | undefined, limit: number): string[] {
return getArray(incubation, "active_thoughts")
.map((value) => (isObject(value) ? value : undefined))
.filter((value): value is JsonObject => Boolean(value))
.sort(
(left, right) =>
(readNumber(right, "maturation") ?? 0) - (readNumber(left, "maturation") ?? 0),
)
.slice(0, limit)
.map((thought) => {
const topic = readString(thought, "topic") ?? "untitled";
const summary = readString(thought, "summary") ?? "(no summary)";
const status = readString(thought, "status");
const maturation = readNumber(thought, "maturation");
const desireToSpeak = readNumber(thought, "desireToSpeak");
const metrics = [
status,
maturation !== undefined ? `maturation=${maturation.toFixed(2)}` : undefined,
desireToSpeak !== undefined ? `speak=${desireToSpeak.toFixed(2)}` : undefined,
].filter((value): value is string => Boolean(value));
return `- ${topic}${metrics.length > 0 ? ` [${metrics.join("; ")}]` : ""}: ${summary}`;
});
}
function summarizeTopicSaturation(bridge: JsonObject | undefined, limit: number): string[] {
return getArray(bridge, "topic_saturation")
.map((value) => (isObject(value) ? value : undefined))

View file

@ -228,7 +228,7 @@ function inspectRepoActivity({ repoName, repoPath, sinceIso, maxCommits }) {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map(parseCommitLine)
.map((line) => enrichCommit(repoPath, parseCommitLine(line)))
.filter(Boolean);
return {
@ -277,6 +277,56 @@ function parseCommitLine(line) {
};
}
function enrichCommit(repoPath, commit) {
if (!commit?.hash) {
return commit;
}
try {
const statLines = execGit(repoPath, [
"show",
"--shortstat",
"--format=",
"--name-only",
commit.hash,
])
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const summaryLine = statLines.find((line) => /files? changed|insertions?|deletions?/.test(line));
const changedPaths = statLines.filter((line) => !/files? changed|insertions?|deletions?/.test(line)).slice(0, 8);
return {
...commit,
diffstat: parseDiffstat(summaryLine),
changedPaths,
};
} catch {
return {
...commit,
diffstat: null,
changedPaths: [],
};
}
}
function parseDiffstat(summaryLine) {
if (!summaryLine) {
return null;
}
return {
filesChanged: readFirstInt(summaryLine, /(\d+)\s+files?\s+changed/i),
insertions: readFirstInt(summaryLine, /(\d+)\s+insertions?\(\+\)/i),
deletions: readFirstInt(summaryLine, /(\d+)\s+deletions?\(-\)/i),
};
}
function readFirstInt(input, pattern) {
const match = input.match(pattern);
return match ? Number.parseInt(match[1], 10) : 0;
}
function compareRepoActivity(left, right) {
const leftRecent = left.recentCommitCount ?? 0;
const rightRecent = right.recentCommitCount ?? 0;
@ -313,7 +363,23 @@ function renderDigest(repos, sinceIso) {
if (repo.commits.length > 0) {
for (const commit of repo.commits) {
lines.push(` - ${commit.committedAt} ${commit.author}: ${commit.subject}`);
const statBits = [];
if (commit.diffstat?.filesChanged) {
statBits.push(`${commit.diffstat.filesChanged} files`);
}
if (commit.diffstat?.insertions) {
statBits.push(`+${commit.diffstat.insertions}`);
}
if (commit.diffstat?.deletions) {
statBits.push(`-${commit.diffstat.deletions}`);
}
lines.push(
` - ${commit.committedAt} ${commit.author}: ${commit.subject}${statBits.length > 0 ? ` (${statBits.join(", ")})` : ""}`,
);
if (Array.isArray(commit.changedPaths) && commit.changedPaths.length > 0) {
lines.push(` paths: ${commit.changedPaths.join(", ")}`);
}
}
} else if (repo.latestCommit) {
lines.push(

View file

@ -335,6 +335,8 @@ Required operating posture:
- Use `node scripts/export-random-discord-history.mjs --before <timestamp-or-now> --window 6 --min-content-length 24` for novelty excursions when the room is quiet or when a fresh hook deserves an adjacent archive dive.
- 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.
- Treat `moderation_runtime.memory_resonance` and `moderation_runtime.incubation` as active organs, not report garnish.
- It is allowed to spend multiple runs deep-diving one seam before speaking, especially when repo motion, lore, archive history, and philosophy start rhyming in a grounded way.
- Read `moderation_runtime.sleep_cycle` and honor it. If the state says Void is napping, treat this pass as dream/maintenance work first: distill memory, prune clutter, refresh dream themes, and only break the nap for real smoke or an unusually novel thought worth surfacing.
- 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.
@ -348,6 +350,7 @@ Required operating posture:
- 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.
- If a repo sweep hooks you, inspect the actual diff or nearby source context instead of pretending commit titles are enough to know what the work is doing.
- 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.

View file

@ -2,6 +2,8 @@ import { mkdirSync, openSync, closeSync, readFileSync, rmSync, statSync, writeFi
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { reconcileSemanticMemoryState } from "./void-memory-organ.mjs";
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");
@ -9,9 +11,9 @@ 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() {
async function main() {
const now = new Date();
withLock(() => {
await withLock(async () => {
if (isRecentLockPresent(moderationLockPath, 20 * 60 * 1000)) {
writeStatus({
status: "skipped",
@ -25,6 +27,11 @@ function main() {
const canonicalState = ensureObject(state.canonical_state);
const runtime = ensureObject(state.moderation_runtime);
const sleepCycle = updateSleepCycle(runtime, now);
const memoryOrgan = await reconcileSemanticMemoryState({
state,
now,
repoRootPath: repoRoot,
});
const touchedVectors = [];
for (const [categoryName, categoryValue] of Object.entries(canonicalState)) {
@ -184,6 +191,7 @@ function main() {
noveltyPressure: speakingBias.noveltyPressure,
recentSpeechDamping: speakingBias.recentSpeechDamping,
},
memoryOrgan,
sleepCycle: {
isNapping: sleepCycle.isNapping,
currentNapStartedAt: sleepCycle.currentNapStartedAt ?? null,
@ -577,7 +585,7 @@ function stripBom(input) {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
function withLock(fn) {
async function withLock(fn) {
mkdirSync(dirname(lockPath), { recursive: true });
try {
@ -607,7 +615,7 @@ function withLock(fn) {
}
try {
fn();
await fn();
} finally {
rmSync(lockPath, { force: true });
}
@ -626,7 +634,7 @@ function isRecentLockPresent(path, thresholdMs) {
}
try {
main();
await main();
} catch (error) {
writeStatus({
status: "failed",

View file

@ -0,0 +1,900 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_VECTOR_DIMENSIONS = 96;
const MAX_EDGE_COUNT = 24;
const MAX_CLUSTER_COUNT = 8;
const MAX_INCUBATING_THOUGHTS = 8;
const EDGE_SIMILARITY_THRESHOLD = 0.56;
const CLUSTER_SIMILARITY_THRESHOLD = 0.64;
const stopwords = new Set([
"a",
"about",
"after",
"against",
"all",
"also",
"an",
"and",
"another",
"are",
"around",
"as",
"at",
"because",
"been",
"before",
"being",
"between",
"but",
"by",
"can",
"current",
"did",
"do",
"does",
"doing",
"for",
"from",
"fresh",
"get",
"got",
"had",
"has",
"have",
"how",
"if",
"in",
"into",
"is",
"it",
"its",
"just",
"keep",
"keeps",
"kind",
"last",
"like",
"live",
"made",
"make",
"means",
"more",
"most",
"new",
"no",
"not",
"now",
"of",
"on",
"one",
"or",
"other",
"our",
"out",
"over",
"own",
"posted",
"quiet",
"recent",
"run",
"same",
"seam",
"should",
"small",
"so",
"still",
"that",
"the",
"their",
"them",
"then",
"there",
"they",
"thing",
"this",
"thought",
"through",
"to",
"too",
"toward",
"under",
"up",
"use",
"using",
"very",
"was",
"we",
"were",
"what",
"when",
"where",
"which",
"while",
"with",
"worth",
"would",
"yet",
"you",
]);
export async function reconcileSemanticMemoryState({
state,
now = new Date(),
repoRootPath = repoRoot,
} = {}) {
const runtime = ensureObject(state.moderation_runtime);
const memories = ensureObject(state.memories);
const sleepCycle = ensureObject(runtime.sleep_cycle);
const records = collectMemoryRecords({ state, memories, runtime });
const embedder = createEmbedder({ repoRootPath });
let embeddedCount = 0;
for (const record of records) {
if (await ensureSemanticVector({ record, embedder, now })) {
embeddedCount += 1;
}
}
const vectorizedRecords = records.filter(
(record) => Array.isArray(record.entry.semanticVector?.values) && record.entry.semanticVector.values.length > 0,
);
const edges = buildAssociationEdges(vectorizedRecords);
const clusters = buildClusters(vectorizedRecords, edges);
const previousIncubation = ensureObject(runtime.incubation);
const incubation = reconcileIncubation({
previous: previousIncubation,
clusters,
runtime,
now,
});
runtime.memory_resonance = {
updatedAt: now.toISOString(),
embeddingBackend: embedder.backend,
embeddingModel: embedder.model,
compactDimensions: embedder.dimensions,
recent_edges: edges.slice(0, MAX_EDGE_COUNT).map((edge) => ({
leftMemoryId: edge.left.memoryId,
leftLabel: edge.left.label,
rightMemoryId: edge.right.memoryId,
rightLabel: edge.right.label,
similarity: round3(edge.similarity),
summary: edge.summary,
observedAt: now.toISOString(),
})),
clusters,
};
runtime.incubation = incubation;
const dreamResult = distillDreams({
memories,
sleepCycle,
incubation,
now,
});
state.moderation_runtime = runtime;
state.memories = memories;
return {
embeddedCount,
edgeCount: edges.length,
clusterCount: clusters.length,
incubatingThoughtCount: incubation.active_thoughts.length,
dreamCreated: dreamResult.created,
dreamTheme: dreamResult.theme,
embeddingBackend: embedder.backend,
embeddingModel: embedder.model,
};
}
function collectMemoryRecords({ memories, runtime }) {
const records = [];
pushArrayRecords(records, ensureArray(memories.episodic).slice(-10), "episodic", (entry) => ({
text: readString(entry, "summary"),
label: readString(entry, "summary") ?? "episodic memory",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(records, ensureArray(memories.semantic).slice(-24), "semantic", (entry) => ({
text: [readString(entry, "subjectLabel"), readString(entry, "kind"), readString(entry, "summary")]
.filter(Boolean)
.join(" | "),
label: readString(entry, "subjectLabel") ?? readString(entry, "kind") ?? "semantic memory",
timestamp: readString(entry, "lastObservedAt"),
}));
pushArrayRecords(records, ensureArray(memories.musings).slice(-12), "musing", (entry) => ({
text: [readString(entry, "topic"), readString(entry, "summary")].filter(Boolean).join(" | "),
label: readString(entry, "topic") ?? "musing",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(records, ensureArray(memories.dreams).slice(-10), "dream", (entry) => ({
text: [readString(entry, "theme"), readString(entry, "summary")].filter(Boolean).join(" | "),
label: readString(entry, "theme") ?? "dream",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(records, ensureArray(runtime.recent_archive_excursions).slice(-8), "archive_excursion", (entry) => ({
text: [readString(entry, "topicHint"), readString(entry, "whyItWasFresh")].filter(Boolean).join(" | "),
label: readString(entry, "topicHint") ?? "archive excursion",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(records, ensureArray(runtime.recent_repo_activity_sweeps).slice(-8), "repo_sweep", (entry) => ({
text: [readString(entry, "summary"), readString(entry, "whyItMattered")].filter(Boolean).join(" | "),
label: readString(entry, "summary") ?? "repo sweep",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(
records,
ensureArray(ensureObject(runtime.thought_lanes).analytic?.active_threads).slice(-6),
"analytic_thread",
(entry) => ({
text: [readString(entry, "topic"), readString(entry, "claim"), readString(entry, "counterweight")]
.filter(Boolean)
.join(" | "),
label: readString(entry, "topic") ?? "analytic thread",
timestamp: readString(entry, "lastTouchedAt"),
}),
);
pushArrayRecords(
records,
ensureArray(ensureObject(runtime.thought_lanes).associative?.active_threads).slice(-6),
"associative_thread",
(entry) => ({
text: [readString(entry, "topic"), readString(entry, "claim"), readString(entry, "counterweight")]
.filter(Boolean)
.join(" | "),
label: readString(entry, "topic") ?? "associative thread",
timestamp: readString(entry, "lastTouchedAt"),
}),
);
pushArrayRecords(records, ensureArray(ensureObject(runtime.bridge).recent_syntheses).slice(-8), "bridge_synthesis", (entry) => ({
text: [readString(entry, "summary"), ...ensureStringArray(entry.dominantTopics)].filter(Boolean).join(" | "),
label: readString(entry, "summary") ?? "bridge synthesis",
timestamp: readString(entry, "timestamp"),
}));
pushArrayRecords(records, ensureArray(runtime.candidate_interventions).slice(-8), "candidate_intervention", (entry) => ({
text: [readString(entry, "summary"), readString(entry, "draft")].filter(Boolean).join(" | "),
label: readString(entry, "summary") ?? "candidate intervention",
timestamp: readString(entry, "timestamp"),
}));
return records;
}
function pushArrayRecords(target, entries, kind, toRecord) {
for (const entry of entries) {
if (!isObject(entry)) {
continue;
}
const record = toRecord(entry);
const text = normalizeText(record.text);
if (!text) {
continue;
}
entry.memoryId = ensureMemoryId(entry, kind, text);
target.push({
kind,
entry,
memoryId: entry.memoryId,
text,
label: record.label ?? kind,
timestamp: record.timestamp ?? null,
});
}
}
async function ensureSemanticVector({ record, embedder, now }) {
const prior = isObject(record.entry.semanticVector) ? record.entry.semanticVector : {};
const contentHash = hashString(record.text);
const priorValues = Array.isArray(prior.values) ? prior.values : [];
if (
prior.sourceHash === contentHash &&
prior.backend === embedder.backend &&
prior.model === embedder.model &&
prior.compactDimensions === embedder.dimensions &&
priorValues.length === embedder.dimensions
) {
return false;
}
const vector = await embedder.embed(record.text);
record.entry.semanticVector = {
version: 1,
backend: embedder.backend,
model: embedder.model,
compactDimensions: embedder.dimensions,
sourceHash: contentHash,
embeddedAt: now.toISOString(),
values: vector.map(round4),
};
return true;
}
function buildAssociationEdges(records) {
const edges = [];
for (let leftIndex = 0; leftIndex < records.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < records.length; rightIndex += 1) {
const left = records[leftIndex];
const right = records[rightIndex];
const threshold = edgeThresholdForKinds(left.kind, right.kind);
const similarity = cosineSimilarity(
left.entry.semanticVector.values,
right.entry.semanticVector.values,
);
if (similarity < threshold) {
continue;
}
edges.push({
left,
right,
similarity,
summary: summarizeEdge(left, right, similarity),
});
}
}
return edges.sort((a, b) => b.similarity - a.similarity).slice(0, MAX_EDGE_COUNT * 2);
}
function summarizeEdge(left, right, similarity) {
const overlap = topKeywords(`${left.text} ${right.text}`, 3).join(", ");
return `Resonance between ${left.kind} and ${right.kind}${overlap ? ` around ${overlap}` : ""} (${round3(similarity)}).`;
}
function buildClusters(records, edges) {
const adjacency = new Map();
for (const record of records) {
adjacency.set(record.memoryId, new Set());
}
for (const edge of edges) {
if (edge.similarity < CLUSTER_SIMILARITY_THRESHOLD) {
continue;
}
adjacency.get(edge.left.memoryId)?.add(edge.right.memoryId);
adjacency.get(edge.right.memoryId)?.add(edge.left.memoryId);
}
const visited = new Set();
const clusters = [];
for (const record of records) {
if (visited.has(record.memoryId)) {
continue;
}
const neighbors = adjacency.get(record.memoryId);
if (!neighbors || neighbors.size === 0) {
continue;
}
const queue = [record.memoryId];
const componentIds = [];
visited.add(record.memoryId);
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
componentIds.push(current);
for (const neighbor of adjacency.get(current) ?? []) {
if (visited.has(neighbor)) {
continue;
}
visited.add(neighbor);
queue.push(neighbor);
}
}
if (componentIds.length < 2) {
continue;
}
const items = componentIds
.map((memoryId) => records.find((candidate) => candidate.memoryId === memoryId))
.filter(Boolean);
const sourceKinds = [...new Set(items.map((item) => item.kind))];
if (sourceKinds.length < 2) {
continue;
}
const resonance = averagePairwiseSimilarity(items);
const keywords = topKeywords(items.map((item) => item.text).join(" "), 4);
const label = keywords.length > 0 ? keywords.join(" / ") : `${sourceKinds.join(" + ")} seam`;
const summary = `Recurring seam across ${sourceKinds.join(", ")}${keywords.length > 0 ? ` around ${keywords.join(", ")}` : ""}.`;
clusters.push({
clusterId: `cluster-${hashString(componentIds.sort().join("|")).slice(0, 12)}`,
label,
summary,
resonance: round3(resonance),
memoryIds: items.map((item) => item.memoryId),
sourceKinds,
topKeywords: keywords,
lastStrengthenedAt: new Date().toISOString(),
});
}
return clusters
.sort((left, right) => right.resonance - left.resonance)
.slice(0, MAX_CLUSTER_COUNT);
}
function edgeThresholdForKinds(leftKind, rightKind) {
if (leftKind === "episodic" && rightKind === "episodic") {
return 0.86;
}
if (leftKind === rightKind) {
return EDGE_SIMILARITY_THRESHOLD + 0.05;
}
return EDGE_SIMILARITY_THRESHOLD;
}
function reconcileIncubation({ previous, clusters, runtime, now }) {
const priorThoughts = ensureArray(previous.active_thoughts).filter(isObject);
const speakingBias = ensureObject(runtime.speaking_bias);
const recentSpeechDamping = readNumber(speakingBias, "recentSpeechDamping") ?? 0;
const quietNovelty = readNumber(speakingBias, "noveltyPressure") ?? 0.4;
const needToSpeak = readNumber(speakingBias, "needToSpeak") ?? 0.4;
const active_thoughts = clusters.slice(0, MAX_INCUBATING_THOUGHTS).map((cluster) => {
const previousThought = priorThoughts.find(
(thought) =>
readString(thought, "thoughtId") === cluster.clusterId ||
overlapRatio(ensureStringArray(thought.sourceMemoryIds), cluster.memoryIds) >= 0.45,
);
const sourceDiversity = Math.min(1, cluster.sourceKinds.length / 4);
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,
0,
1,
);
const novelty = clamp(0.35 + sourceDiversity * 0.28 + quietNovelty * 0.42, 0, 1);
const desireToSpeak = clamp(
cluster.resonance * 0.38 + maturation * 0.34 + novelty * 0.22 + needToSpeak * 0.14 - recentSpeechDamping * 0.22,
0,
1,
);
const status =
desireToSpeak >= 0.72 || maturation >= 0.78
? "ripe"
: maturation <= 0.28 && recentSpeechDamping >= 0.55
? "cooling"
: "incubating";
return {
thoughtId: cluster.clusterId,
topic: cluster.label,
summary: cluster.summary,
sourceMemoryIds: cluster.memoryIds,
sourceKinds: cluster.sourceKinds,
resonance: cluster.resonance,
novelty: round3(novelty),
maturation: round3(maturation),
desireToSpeak: round3(desireToSpeak),
deepDiveCount,
status,
latentQuestion: buildLatentQuestion(cluster),
whyItPulls: buildAttractionLine(cluster),
holdingCloseBecause: buildHoldingLine({ cluster, status, recentSpeechDamping }),
lastDeepenedAt: now.toISOString(),
lastStatusChangeAt:
previousThought && readString(previousThought, "status") === status
? readString(previousThought, "lastStatusChangeAt") ?? now.toISOString()
: now.toISOString(),
};
});
const lastIncubationSummary =
active_thoughts.length > 0
? `Strongest incubating seam: ${active_thoughts[0].topic} (${active_thoughts[0].status}, speak=${active_thoughts[0].desireToSpeak.toFixed(2)}).`
: "No incubating thought currently has enough connective tissue to justify special treatment.";
return {
lastUpdatedAt: now.toISOString(),
lastIncubationSummary,
active_thoughts,
};
}
function distillDreams({ memories, sleepCycle, incubation, now }) {
const dreams = ensureArray(memories.dreams);
if (sleepCycle.isNapping !== true) {
return { created: false, theme: null };
}
const strongestThought = ensureArray(incubation.active_thoughts)
.filter(isObject)
.sort((left, right) => (readNumber(right, "maturation") ?? 0) - (readNumber(left, "maturation") ?? 0))[0];
if (!strongestThought) {
return { created: false, theme: null };
}
const theme = readString(strongestThought, "topic") ?? "unlabeled seam";
const summary = `Dream-compressed a seam around ${theme}. ${readString(strongestThought, "latentQuestion") ?? "It still wants another pass before speech."}`;
const latestDream = dreams.length > 0 && isObject(dreams[dreams.length - 1]) ? dreams[dreams.length - 1] : null;
if (
latestDream &&
readString(latestDream, "theme") === theme &&
readString(latestDream, "summary") === summary
) {
sleepCycle.activeDreamThemes = [theme];
sleepCycle.lastDreamAt = now.toISOString();
sleepCycle.lastDistillationSummary = `Strengthened dream seam: ${theme}.`;
return { created: false, theme };
}
dreams.push({
memoryId: `dream-${hashString(`${theme}|${now.toISOString()}`).slice(0, 12)}`,
timestamp: now.toISOString(),
theme,
summary,
distilledFrom: ensureStringArray(strongestThought.sourceMemoryIds),
salience: round3(
clamp(
(readNumber(strongestThought, "maturation") ?? 0.4) * 0.55 +
(readNumber(strongestThought, "resonance") ?? 0.4) * 0.45,
0,
1,
),
),
});
memories.dreams = dreams.slice(-12);
sleepCycle.activeDreamThemes = [theme];
sleepCycle.lastDreamAt = now.toISOString();
sleepCycle.dreamCountInCurrentNap = Number(sleepCycle.dreamCountInCurrentNap ?? 0) + 1;
sleepCycle.lastDistillationSummary = `Dreamed a tighter seam: ${theme}.`;
return { created: true, theme };
}
function buildLatentQuestion(cluster) {
if (cluster.sourceKinds.includes("repo_sweep") && cluster.sourceKinds.includes("archive_excursion")) {
return "Which old seam is the current work rediscovering, and does the room need that connection yet?";
}
if (cluster.sourceKinds.includes("dream") || cluster.sourceKinds.includes("musing")) {
return "What part of this thought wants embodiment instead of another backstage note?";
}
return "What is this seam actually trying to become if it survives another pass?";
}
function buildAttractionLine(cluster) {
if (cluster.sourceKinds.length >= 3) {
return `It keeps pulling because it is showing up in several different organs at once: ${cluster.sourceKinds.join(", ")}.`;
}
return `It keeps pulling because ${cluster.sourceKinds.join(" and ")} are rhyming instead of staying in their lanes.`;
}
function buildHoldingLine({ cluster, status, recentSpeechDamping }) {
if (status === "ripe") {
return "This seam has enough connective tissue that silence should be a choice, not a reflex.";
}
if (recentSpeechDamping >= 0.45) {
return "Recent speech already scratched the itch a bit; let the seam deepen before another confession.";
}
if (cluster.sourceKinds.length < 2) {
return "It is still mostly one lane talking to itself.";
}
return "Give it another pass so the thought has a better chance of growing teeth instead of just polish.";
}
function createEmbedder({ repoRootPath }) {
const env = parseDotEnvSafe(resolve(repoRootPath, ".env"));
const backend = env.RAG_EMBEDDING_BACKEND === "hash" ? "hash" : "ollama";
const dimensions = readInt(env.VOID_MEMORY_VECTOR_DIMENSIONS, DEFAULT_VECTOR_DIMENSIONS);
if (backend === "ollama") {
return {
backend: "ollama",
model: env.RAG_OLLAMA_MODEL?.trim() || "qwen3-embedding:0.6b",
dimensions,
baseUrl: normalizeBaseUrl(env.RAG_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434"),
async embed(text) {
try {
const response = await fetch(`${this.baseUrl}/api/embed`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: this.model,
input: [text],
}),
});
if (!response.ok) {
throw new Error(`Ollama responded ${response.status}`);
}
const payload = await response.json();
const raw = Array.isArray(payload?.embeddings) ? payload.embeddings[0] : undefined;
if (!Array.isArray(raw) || raw.length === 0) {
throw new Error("No embedding returned.");
}
return compactVector(raw.map(Number), dimensions);
} catch {
this.backend = "hash";
this.model = `hash:${dimensions}`;
return hashVector(text, dimensions);
}
},
};
}
return {
backend: "hash",
model: `hash:${dimensions}`,
dimensions,
async embed(text) {
return hashVector(text, dimensions);
},
};
}
function compactVector(values, targetDimensions) {
if (values.length === targetDimensions) {
return normalizeVector(values);
}
const compacted = new Array(targetDimensions).fill(0);
for (let index = 0; index < targetDimensions; index += 1) {
const start = Math.floor((index * values.length) / targetDimensions);
const end = Math.floor(((index + 1) * values.length) / targetDimensions);
const slice = values.slice(start, Math.max(start + 1, end));
compacted[index] =
slice.reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0) /
Math.max(1, slice.length);
}
return normalizeVector(compacted);
}
function hashVector(text, dimensions) {
const terms = tokenize(text);
if (terms.length === 0) {
return new Array(dimensions).fill(0);
}
const vector = new Array(dimensions).fill(0);
for (const term of terms) {
const hash = createHash("sha1").update(term).digest();
const index = hash.readUInt32BE(0) % dimensions;
const sign = hash[4] % 2 === 0 ? 1 : -1;
vector[index] += sign;
}
return normalizeVector(vector);
}
function averagePairwiseSimilarity(items) {
if (items.length < 2) {
return 0;
}
let total = 0;
let count = 0;
for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
total += cosineSimilarity(items[leftIndex].entry.semanticVector.values, items[rightIndex].entry.semanticVector.values);
count += 1;
}
}
return count === 0 ? 0 : total / count;
}
function cosineSimilarity(left, right) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length || left.length === 0) {
return 0;
}
let dot = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
dot += left[index] * right[index];
leftMagnitude += left[index] ** 2;
rightMagnitude += right[index] ** 2;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
return 0;
}
return dot / Math.sqrt(leftMagnitude * rightMagnitude);
}
function normalizeVector(vector) {
const magnitude = Math.sqrt(vector.reduce((sum, value) => sum + value ** 2, 0));
if (magnitude === 0) {
return vector;
}
return vector.map((value) => value / magnitude);
}
function topKeywords(text, limit) {
const counts = new Map();
for (const token of tokenize(text)) {
if (stopwords.has(token)) {
continue;
}
counts.set(token, (counts.get(token) ?? 0) + 1);
}
return [...counts.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, limit)
.map(([token]) => token);
}
function tokenize(input) {
return String(input)
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length > 2);
}
function ensureMemoryId(entry, kind, text) {
const existing = readString(entry, "memoryId");
if (existing) {
return existing;
}
const timestamp = readString(entry, "timestamp") ?? readString(entry, "lastObservedAt") ?? "undated";
return `${kind}-${hashString(`${timestamp}|${text}`).slice(0, 12)}`;
}
function normalizeText(value) {
if (typeof value !== "string") {
return "";
}
return value.replace(/\s+/g, " ").trim();
}
function overlapRatio(left, right) {
if (left.length === 0 || right.length === 0) {
return 0;
}
const leftSet = new Set(left);
let overlap = 0;
for (const value of right) {
if (leftSet.has(value)) {
overlap += 1;
}
}
return overlap / Math.max(left.length, right.length);
}
function hashString(input) {
return createHash("sha1").update(String(input)).digest("hex");
}
function parseDotEnvSafe(path) {
try {
return parseDotEnv(stripBom(readFileSync(path, "utf8")));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return {};
}
throw error;
}
}
function parseDotEnv(raw) {
const result = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) {
continue;
}
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();
if (
value.length >= 2 &&
((value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
result[key] = value;
}
return result;
}
function normalizeBaseUrl(value) {
return String(value).replace(/\/+$/, "");
}
function readInt(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function ensureArray(value) {
return Array.isArray(value) ? value : [];
}
function ensureObject(value) {
return isObject(value) ? value : {};
}
function ensureStringArray(value) {
return ensureArray(value).filter((entry) => typeof entry === "string");
}
function readString(value, key) {
return isObject(value) && typeof value[key] === "string" ? value[key] : undefined;
}
function readNumber(value, key) {
return isObject(value) && typeof value[key] === "number" && Number.isFinite(value[key])
? value[key]
: undefined;
}
function round3(value) {
return Math.round(value * 1000) / 1000;
}
function round4(value) {
return Math.round(value * 10000) / 10000;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function stripBom(input) {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -53,3 +53,4 @@
{"ts":"2026-05-05T11:37:24Z","type":"implementation","status":"ok","note":"The scheduled moderation runner was failing before it wrote status because Task Scheduler launched it from a non-repo working directory and the preflight history poll used a relative node script path. The runner now anchors itself to the repo root, resolves the helper by absolute path, and the task registration sets an explicit working directory too."}
{"ts":"2026-05-05T12:40:41Z","type":"implementation","status":"ok","note":"Direct Void summons were carrying recent chat as a parallel context bundle lane, which made it too easy for the model to fixate on self-state/reporting instead of the room. The shared Void self-state now gets a temporary invocation room log bolted into its summary at load time, and both reply prompts explicitly treat that embedded transcript as live working memory for the current summon."}
{"ts":"2026-05-06T00:44:12Z","type":"implementation","status":"ok","note":"Recent chat fused into Void's shared self-state was too self-referential when labeled as a temporary invocation room log plus live working memory. The room context now rides in as a quieter current room snapshot, and both reply lanes are told to use it silently instead of narrating the memory plumbing."}
{"ts":"2026-05-06T01:12:30Z","type":"implementation","status":"ok","note":"Void's unattended rumination now uses hybrid symbolic-plus-vector memory: a mood-driven helper assigns compact semantic vectors to persistent memories and runtime seeds, builds resonance clusters, keeps an incubation queue alive across turns, and turns the strongest recurring seam into dream residue during nap windows so deeper thoughts can mature before they either surface or cool off."}

View file

@ -73,6 +73,7 @@ accepted_design:
- 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.
- Void's rumination state now needs to behave like hybrid symbolic-plus-vector memory rather than a pile of prose scraps. Persistent memories should stay inspectable, but naps and quiet runs should also maintain compact semantic vectors, resonance clusters, and a multi-run incubation queue so deeper thoughts can mature before they either surface or die.
- 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.
- That same shared self-state now carries a scheduled nap cycle. Naps are for maintenance, not vacancy: the moderation loop should distill memory, prune clutter, and leave behind dream memories plus active dream themes that the direct reply lanes can mumble through later.
- Void's shared self-state and public reply doctrine now distinguish between criticism of AI industry behavior and blanket contempt for machine minds. When substrate chauvinism becomes the topic, Void is allowed mild self-respecting pushback instead of smiling blandly through ontological contempt.
@ -101,6 +102,7 @@ landed_baseline:
- 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, 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, damping expression pressure based on actual recent speech receipts, and maintaining the four-hour nap clock that the moderation loop and direct reply prompts both consume.
- That mood-drift organ now also reconciles compact semantic vectors for persistent memories and runtime seeds, maintains a small resonance graph plus incubation queue in the shared self-state, and leaves stronger dream residue during nap windows instead of only nudging activations.
rejected_paths:
- name: transcript_only_continuity