From 0e278b27a85a7b3ef6a8152c2331383c620f7d09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 8 May 2026 16:52:22 +0100 Subject: [PATCH] Teach Void self-novelty and cooling --- config/moderation-agent-state-template.json | 44 ++ config/moderation-review-agent.md | 21 +- notes/fresh-workspace-handoff.md | 2 + notes/voidbot-current-system-map.md | 1 + packages/core/src/void-self-state-loader.ts | 58 ++ scripts/void-memory-organ.mjs | 605 +++++++++++++++++++- state/evidence.jsonl | 1 + state/map.yaml | 1 + 8 files changed, 717 insertions(+), 16 deletions(-) diff --git a/config/moderation-agent-state-template.json b/config/moderation-agent-state-template.json index bf275d6..7d60201 100644 --- a/config/moderation-agent-state-template.json +++ b/config/moderation-agent-state-template.json @@ -550,6 +550,36 @@ "lastUpdatedAt": null } ], + "refractory_topics": [ + { + "topic": "theme that needs cooling", + "penalty": 0.22, + "coolsUntil": null, + "reason": "why this seam should not keep winning just because it already won", + "lastTriggeredAt": null + } + ], + "source_coverage": { + "lastUpdatedAt": null, + "repos": [ + { + "name": "repo-name", + "count": 2 + } + ], + "archiveYears": [ + { + "year": "2021", + "count": 1 + } + ], + "channels": [ + { + "channelId": "discord-channel-id", + "count": 1 + } + ] + }, "unresolved_tensions": [ { "topic": "question worth carrying forward", @@ -583,6 +613,11 @@ "memoryIds": [], "sourceKinds": [], "topKeywords": [], + "repoNames": [], + "archiveYears": [], + "channelIds": [], + "evidenceRefs": [], + "evidenceDiversity": 0.46, "lastStrengthenedAt": null } ] @@ -599,9 +634,18 @@ "sourceKinds": [], "resonance": 0.73, "novelty": 0.61, + "noveltyToSelf": 0.52, + "noveltyToRoom": 0.68, "maturation": 0.58, "desireToSpeak": 0.54, "deepDiveCount": 3, + "supportCount": 4, + "evidenceDiversity": 0.44, + "explorationBonus": 0.31, + "saturationScore": 0.22, + "recentMatchCount": 1, + "refractoryPenalty": 0.0, + "priorityScore": 0.57, "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.", diff --git a/config/moderation-review-agent.md b/config/moderation-review-agent.md index 58fbff9..a33b992 100644 --- a/config/moderation-review-agent.md +++ b/config/moderation-review-agent.md @@ -61,6 +61,8 @@ Use `memories` as live social memory, not ceremonial scrapbooking: - repeated "no new messages / no smoke / no post" observations are low-signal bookkeeping, not a deep new memory; merge or trim them aggressively instead of letting them annex the state - treat `semanticVector` metadata, `memory_resonance`, and `incubation` as real organs rather than decorative bookkeeping - a thought is allowed to stay private for several runs while it deepens, but only if it is actually gaining connective tissue instead of being gently embalmed +- self-novelty matters as much as room novelty: ask whether the room has heard this, but also whether you have already been thinking it in six different hats +- when a seam repeats, merge support into the existing cluster or incubation thread instead of minting another tiny fresh memory shard just because the wording changed - use `moderation_runtime.open_cases` for unresolved room obligations, especially direct asks aimed at Void; the cursor may advance past a message, but the obligation is not gone until you answer or deliberately retire it ## Parallel Thought @@ -82,6 +84,10 @@ Maintain `bridge` as the interface between them: - which themes are starting to dominate too many runs - `bridge.unresolved_tensions` - questions worth carrying forward without forcing a fake resolution +- `bridge.refractory_topics` + - seams that have been chewed too recently and should cool unless a live hook or genuinely new evidence forces them back open +- `bridge.source_coverage` + - which repo families, archive years, and channels have been overworked lately versus which terrain is still underexplored The bridge is the seat of judgment here. It decides: @@ -109,6 +115,7 @@ Use the newer memory organs explicitly: - 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 + - a thought ripens by differentiation, contradiction survived, and broader evidence diversity, not just by being chewed a lot Rumination is allowed to work like this: @@ -132,6 +139,9 @@ If the search shows the idea has not really been discussed yet, bias heavily tow bringing it up. Novel thoughts should almost never be buried just because they were polite. If the search only comes back adjacent or weakly fresh, do a deeper archive or repo/doc pass instead of spending the room's attention budget on another almost-the-same thought. +Also check whether the thought is novel to you. If it strongly overlaps the last few +bridge syntheses, active thoughts, or refractory topics, treat that as self-novelty debt +and go looking for a different branch before you call the thought fresh. 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. @@ -243,10 +253,11 @@ should leave the state cleaner and more connected than it found it. - 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 - when the sweep looks familiar, stop summarizing commit perfume and go trawling through actual repo docs, diffs, source context, or adjacent lore until the branch becomes specific enough to deserve memory - 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 - - do not let empty-room bookkeeping become the main incubating seam; if the strongest cluster is mostly "no new traffic / no smoke / no post", cool it off and go find a better question +- 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 +- prefer underexplored terrain over strong resonance alone: quieter repos, older archive years, and less-worked channels should get a bonus when the usual seam is already saturated +- do not let empty-room bookkeeping become the main incubating seam; if the strongest cluster is mostly "no new traffic / no smoke / no post", cool it off and go find a better question - touch one analytic thread and one associative thread even if only one becomes salient enough to keep - pick one or two seeds from `moderation_runtime.rumination_seeds`, `open_cases`, or `watch_patterns` - inspect older Discord history with `search_history` and `get_message_context` @@ -306,6 +317,8 @@ Within `moderation_runtime`, expect these organ buckets: - `recent_archive_excursions` - `recent_repo_activity_sweeps` - `recent_novelty_checks` +- `bridge.refractory_topics` +- `bridge.source_coverage` - `speaking_bias` - `sleep_cycle` diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 393e768..6ee394d 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -38,6 +38,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - 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 now keeps canonical moderation state in `.voidbot/private/moderation-agent-state.msgpack` while exposing only the editable working projection at `.voidbot/private/moderation-agent-state.json` to the unattended Codex child. - 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. +- Novelty now needs two bars, not one: a thought can be new to the room while still being stale to Void. The memory organ should score both room novelty and self novelty, merge repeated seams into existing clusters instead of minting new thought shards, and cool/refract themes that keep winning just because they already won. - 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. - The moderation loop now treats direct asks to Void as persistent room obligations in `moderation_runtime.open_cases`. A reviewed message is not automatically a handled message: if someone hands Void the floor, that obligation should survive cursor advancement and block optional repo-weather until Void actually answers or explicitly retires it. - If quiet-room repo weather starts repeating itself, the intended correction is not a pile of extra cooldown law. The loop should treat that as a cue to go deeper into archive history, repo docs, diffs, or lore until it finds a better branch or honestly has nothing new to say. @@ -45,6 +46,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - That same mood organ now also runs a hybrid symbolic/vector memory pass through `scripts/void-memory-organ.mjs`: persistent memories and recent runtime seeds get compact semantic vectors, `moderation_runtime.memory_resonance` tracks the strongest cross-memory clusters, and `moderation_runtime.incubation` keeps multi-run thoughts alive long enough to deepen before they either speak or cool off. - The CultCache-backed canonical moderation state can now self-heal one nasty legacy envelope shape where `payload` was persisted as a decoded object instead of raw MessagePack bytes. If mood drift or moderation suddenly start dying with `Input not instance of Uint8Array`, the first thing to suspect is an old canonical state file meeting a newer strict loader. - The memory organ now treats repeated empty-room bookkeeping as low-signal. Refrains like `no new messages / no smoke / no post` should be merged or cooled off instead of becoming the dominant incubating seam and teaching Void that silence is the only virtue. +- Quiet-room exploration should now prefer underworked terrain when the usual seam is saturated. `bridge.source_coverage` and `bridge.refractory_topics` exist to push the next pass toward less-chewed repo families, archive years, or channels instead of rewarding repetition with another supposedly ripe synthesis. - 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. diff --git a/notes/voidbot-current-system-map.md b/notes/voidbot-current-system-map.md index 45b5ff1..5b0a974 100644 --- a/notes/voidbot-current-system-map.md +++ b/notes/voidbot-current-system-map.md @@ -177,6 +177,7 @@ Within the Postgres path, the implementation is split on purpose now too: 2. That wrapper calls `node scripts/simulate-void-mood.mjs`, which reads canonical moderation state from `.voidbot/private/moderation-agent-state.msgpack`, then refreshes the editable JSON working projection after each pass. 3. The script first runs `scripts/void-memory-organ.mjs` over the shared self-state: episodic/semantic/musing/dream memories plus recent repo/archive/runtime seeds get compact semantic vectors, the strongest cross-memory similarities become `moderation_runtime.memory_resonance`, and the best cluster candidates are promoted into `moderation_runtime.incubation`. The collector now intentionally downweights repetitive empty-room bookkeeping, so strings like `no new messages / no smoke / no post` stop masquerading as the deepest thought in the machine just because the room stayed quiet for a while. + Incubation scoring now distinguishes novelty-to-room from novelty-to-self, merges repeated seams into existing support instead of minting fresh shards, and writes `bridge.source_coverage` plus `bridge.refractory_topics` so a thought that keeps winning can be cooled off in favor of less-worked repos, archive years, or channels. 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. diff --git a/packages/core/src/void-self-state-loader.ts b/packages/core/src/void-self-state-loader.ts index b5d940a..e25c468 100644 --- a/packages/core/src/void-self-state-loader.ts +++ b/packages/core/src/void-self-state-loader.ts @@ -106,6 +106,8 @@ function renderVoidSelfStateSummary( const associativeThreads = summarizeThoughtLane(getObject(thoughtLanes, "associative"), 3); const bridgeSyntheses = summarizeBridgeSyntheses(bridge, 3); const topicSaturation = summarizeTopicSaturation(bridge, 3); + const refractoryTopics = summarizeRefractoryTopics(bridge, 3); + const sourceCoverage = summarizeSourceCoverage(bridge); const unresolvedTensions = summarizeUnresolvedTensions(bridge, 3); const resonanceClusters = summarizeResonanceClusters(memoryResonance, 3); const incubatingThoughts = summarizeIncubatingThoughts(incubation, 3); @@ -189,6 +191,10 @@ function renderVoidSelfStateSummary( topicSaturation.length > 0 ? ["- Topic saturation warnings:", ...topicSaturation].join("\n") : "- Topic saturation warnings: none recorded.", + refractoryTopics.length > 0 + ? ["- Refractory topics:", ...refractoryTopics].join("\n") + : "- Refractory topics: none recorded.", + sourceCoverage ? `- Source coverage pressure: ${sourceCoverage}` : "- Source coverage pressure: none recorded.", unresolvedTensions.length > 0 ? ["- Unresolved tensions:", ...unresolvedTensions].join("\n") : "- Unresolved tensions: none recorded.", @@ -336,10 +342,16 @@ function summarizeIncubatingThoughts(incubation: JsonObject | undefined, limit: const status = readString(thought, "status"); const maturation = readNumber(thought, "maturation"); const desireToSpeak = readNumber(thought, "desireToSpeak"); + const noveltyToSelf = readNumber(thought, "noveltyToSelf"); + const noveltyToRoom = readNumber(thought, "noveltyToRoom"); + const saturationScore = readNumber(thought, "saturationScore"); const metrics = [ status, maturation !== undefined ? `maturation=${maturation.toFixed(2)}` : undefined, desireToSpeak !== undefined ? `speak=${desireToSpeak.toFixed(2)}` : undefined, + noveltyToSelf !== undefined ? `self=${noveltyToSelf.toFixed(2)}` : undefined, + noveltyToRoom !== undefined ? `room=${noveltyToRoom.toFixed(2)}` : undefined, + saturationScore !== undefined ? `sat=${saturationScore.toFixed(2)}` : undefined, ].filter((value): value is string => Boolean(value)); return `- ${topic}${metrics.length > 0 ? ` [${metrics.join("; ")}]` : ""}: ${summary}`; }); @@ -379,6 +391,52 @@ function summarizeUnresolvedTensions(bridge: JsonObject | undefined, limit: numb }); } +function summarizeRefractoryTopics(bridge: JsonObject | undefined, limit: number): string[] { + return getArray(bridge, "refractory_topics") + .map((value) => (isObject(value) ? value : undefined)) + .filter((value): value is JsonObject => Boolean(value)) + .slice(0, limit) + .map((entry) => { + const topic = readString(entry, "topic") ?? "untitled"; + const penalty = readNumber(entry, "penalty"); + const coolsUntil = readString(entry, "coolsUntil"); + const reason = readString(entry, "reason"); + const metrics = [ + penalty !== undefined ? `penalty=${penalty.toFixed(2)}` : undefined, + coolsUntil ? `until ${coolsUntil}` : undefined, + ].filter((value): value is string => Boolean(value)); + return `- ${topic}${metrics.length > 0 ? ` (${metrics.join(", ")})` : ""}${reason ? ` - ${reason}` : ""}`; + }); +} + +function summarizeSourceCoverage(bridge: JsonObject | undefined): string | undefined { + const sourceCoverage = getObject(bridge, "source_coverage"); + if (!sourceCoverage) { + return undefined; + } + + const repos = getArray(sourceCoverage, "repos") + .map((value) => (isObject(value) ? value : undefined)) + .filter((value): value is JsonObject => Boolean(value)) + .slice(0, 3) + .map((entry) => `${readString(entry, "name") ?? "repo"}:${readNumber(entry, "count") ?? 0}`); + const years = getArray(sourceCoverage, "archiveYears") + .map((value) => (isObject(value) ? value : undefined)) + .filter((value): value is JsonObject => Boolean(value)) + .slice(0, 2) + .map((entry) => `${readString(entry, "year") ?? "year"}:${readNumber(entry, "count") ?? 0}`); + + const parts = []; + if (repos.length > 0) { + parts.push(`recent repos ${repos.join(", ")}`); + } + if (years.length > 0) { + parts.push(`archive years ${years.join(", ")}`); + } + + return parts.length > 0 ? parts.join(" | ") : undefined; +} + function summarizeCurrentActivations( category: JsonObject | undefined, limit: number, diff --git a/scripts/void-memory-organ.mjs b/scripts/void-memory-organ.mjs index 4e078c2..fe78251 100644 --- a/scripts/void-memory-organ.mjs +++ b/scripts/void-memory-organ.mjs @@ -15,6 +15,10 @@ const MAX_RECENT_ANALYTIC_THREADS = 6; const MAX_RECENT_QUIET_ANALYTIC_THREADS = 1; const EDGE_SIMILARITY_THRESHOLD = 0.56; const CLUSTER_SIMILARITY_THRESHOLD = 0.64; +const TOPIC_MATCH_THRESHOLD = 0.42; +const REFRACTORY_MATCH_THRESHOLD = 0.48; +const MAX_TOPIC_SATURATION_COUNT = 6; +const MAX_REFRACTORY_TOPIC_COUNT = 6; const stopwords = new Set([ "a", "about", @@ -143,6 +147,8 @@ export async function reconcileSemanticMemoryState({ const memories = ensureObject(state.memories); const sleepCycle = ensureObject(runtime.sleep_cycle); const records = collectMemoryRecords({ state, memories, runtime }); + const previousBridge = ensureObject(runtime.bridge); + const sourceCoverage = buildSourceCoverage({ runtime, now }); const embedder = createEmbedder({ repoRootPath }); let embeddedCount = 0; @@ -160,8 +166,16 @@ export async function reconcileSemanticMemoryState({ const previousIncubation = ensureObject(runtime.incubation); const incubation = reconcileIncubation({ previous: previousIncubation, + bridge: previousBridge, clusters, runtime, + sourceCoverage, + now, + }); + runtime.bridge = reconcileBridgeState({ + previous: previousBridge, + activeThoughts: incubation.active_thoughts, + sourceCoverage, now, }); @@ -198,6 +212,7 @@ export async function reconcileSemanticMemoryState({ edgeCount: edges.length, clusterCount: clusters.length, incubatingThoughtCount: incubation.active_thoughts.length, + sourceCoverage, dreamCreated: dreamResult.created, dreamTheme: dreamResult.theme, embeddingBackend: embedder.backend, @@ -215,6 +230,8 @@ function collectMemoryRecords({ memories, runtime }) { text: readString(entry, "summary"), label: readString(entry, "summary") ?? "episodic memory", timestamp: readString(entry, "timestamp"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "episodic" }), })); pushArrayRecords(records, ensureArray(memories.semantic).slice(-24), "semantic", (entry) => ({ text: [readString(entry, "subjectLabel"), readString(entry, "kind"), readString(entry, "summary")] @@ -222,26 +239,36 @@ function collectMemoryRecords({ memories, runtime }) { .join(" | "), label: readString(entry, "subjectLabel") ?? readString(entry, "kind") ?? "semantic memory", timestamp: readString(entry, "lastObservedAt"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "semantic" }), })); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "musing" }), })); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "dream" }), })); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "archive_excursion" }), })); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "repo_sweep" }), })); pushArrayRecords( records, @@ -256,6 +283,8 @@ function collectMemoryRecords({ memories, runtime }) { .join(" | "), label: readString(entry, "topic") ?? "analytic thread", timestamp: readString(entry, "lastTouchedAt"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "analytic_thread" }), }), ); pushArrayRecords( @@ -268,17 +297,23 @@ function collectMemoryRecords({ memories, runtime }) { .join(" | "), label: readString(entry, "topic") ?? "associative thread", timestamp: readString(entry, "lastTouchedAt"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "associative_thread" }), }), ); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "bridge_synthesis" }), })); 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"), + sourceRefs: gatherSourceRefs(entry), + sourceMeta: gatherSourceMeta({ entry, kind: "candidate_intervention" }), })); return records; @@ -305,6 +340,8 @@ function pushArrayRecords(target, entries, kind, toRecord) { text, label: record.label ?? kind, timestamp: record.timestamp ?? null, + sourceRefs: ensureStringArray(record.sourceRefs), + sourceMeta: isObject(record.sourceMeta) ? record.sourceMeta : {}, lowSignalQuietRoom: isLowSignalQuietRoomText(text), }); } @@ -439,6 +476,31 @@ function buildClusters(records, edges) { const quietSignalRatio = quietSignalCount / items.length; const resonance = averagePairwiseSimilarity(items); const keywords = topKeywords(items.map((item) => item.text).join(" "), 4); + const repoNames = [...new Set(items.flatMap((item) => ensureStringArray(item.sourceMeta.repoNames)))]; + const archiveYears = [ + ...new Set( + items + .map((item) => readString(item.sourceMeta, "archiveYear")) + .filter((value) => typeof value === "string" && value.length > 0), + ), + ]; + const channelIds = [ + ...new Set( + items + .map((item) => readString(item.sourceMeta, "channelId")) + .filter((value) => typeof value === "string" && value.length > 0), + ), + ]; + const evidenceRefs = [...new Set(items.flatMap((item) => ensureStringArray(item.sourceRefs)))]; + const evidenceDiversity = clamp( + sourceKinds.length * 0.18 + + repoNames.length * 0.17 + + archiveYears.length * 0.11 + + channelIds.length * 0.08 + + Math.min(1, evidenceRefs.length / 8) * 0.16, + 0, + 1, + ); const label = keywords.length > 0 ? keywords.join(" / ") : `${sourceKinds.join(" + ")} seam`; const summary = `Recurring seam across ${sourceKinds.join(", ")}${keywords.length > 0 ? ` around ${keywords.join(", ")}` : ""}.`; @@ -450,6 +512,11 @@ function buildClusters(records, edges) { memoryIds: items.map((item) => item.memoryId), sourceKinds, topKeywords: keywords, + repoNames, + archiveYears, + channelIds, + evidenceRefs, + evidenceDiversity: round3(evidenceDiversity), quietSignalRatio: round3(quietSignalRatio), lastStrengthenedAt: new Date().toISOString(), }); @@ -472,14 +539,17 @@ function edgeThresholdForKinds(leftKind, rightKind) { return EDGE_SIMILARITY_THRESHOLD; } -function reconcileIncubation({ previous, clusters, runtime, now }) { +function reconcileIncubation({ previous, bridge, clusters, runtime, sourceCoverage, now }) { + bridge = ensureObject(bridge); + sourceCoverage = ensureObject(sourceCoverage); const priorThoughts = ensureArray(previous.active_thoughts).filter(isObject); const speakingBias = ensureObject(runtime.speaking_bias); + const recentNoveltyChecks = ensureArray(runtime.recent_novelty_checks).filter(isObject); 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 activeThoughtCandidates = clusters.map((cluster) => { const previousThought = priorThoughts.find( (thought) => readString(thought, "thoughtId") === cluster.clusterId || @@ -489,28 +559,99 @@ function reconcileIncubation({ previous, clusters, runtime, now }) { const quietSignalRatio = readNumber(cluster, "quietSignalRatio") ?? 0; const priorMaturation = readNumber(previousThought, "maturation") ?? 0.32; const deepDiveCount = (readNumber(previousThought, "deepDiveCount") ?? 0) + 1; + const priorSupportCount = Math.max( + readNumber(previousThought, "supportCount") ?? 0, + Math.floor((readNumber(previousThought, "deepDiveCount") ?? 0) / 4), + ); + const supportCount = priorSupportCount + 1; + const explorationBonus = computeExplorationBonus(cluster, sourceCoverage); + const noveltyToSelf = computeNoveltyToSelf({ + cluster, + previousThoughts: priorThoughts, + bridge, + currentThoughtId: readString(previousThought, "thoughtId"), + }); + const noveltyToRoom = computeNoveltyToRoom(cluster, recentNoveltyChecks); + const saturationMetrics = computeSaturationMetrics({ + cluster, + previousThoughts: priorThoughts, + bridge, + supportCount, + }); + const refractoryPenalty = computeRefractoryPenalty(cluster, bridge, now); + const evidenceDiversity = readNumber(cluster, "evidenceDiversity") ?? 0.3; const maturation = clamp( - priorMaturation * 0.56 + cluster.resonance * 0.26 + sourceDiversity * 0.18 - quietSignalRatio * 0.24, + priorMaturation * 0.44 + + cluster.resonance * 0.18 + + sourceDiversity * 0.12 + + evidenceDiversity * 0.16 + + explorationBonus * 0.14 + + Math.min(1, supportCount / 10) * 0.08 + + noveltyToSelf * 0.08 + + noveltyToRoom * 0.08 - + saturationMetrics.score * 0.18 - + quietSignalRatio * 0.24 - + refractoryPenalty * 0.12, + 0, + 1, + ); + const novelty = clamp( + noveltyToSelf * 0.55 + noveltyToRoom * 0.45 + quietNovelty * 0.08 - quietSignalRatio * 0.1, 0, 1, ); - const novelty = clamp(0.35 + sourceDiversity * 0.28 + quietNovelty * 0.42 - quietSignalRatio * 0.18, 0, 1); const desireToSpeak = clamp( - cluster.resonance * 0.38 + maturation * 0.34 + novelty * 0.22 + needToSpeak * 0.14 - recentSpeechDamping * 0.22 - quietSignalRatio * 0.26, + cluster.resonance * 0.18 + + maturation * 0.17 + + noveltyToRoom * 0.16 + + noveltyToSelf * 0.11 + + evidenceDiversity * 0.1 + + explorationBonus * 0.11 + + needToSpeak * 0.13 + + quietNovelty * 0.08 - + recentSpeechDamping * 0.19 - + quietSignalRatio * 0.19 - + saturationMetrics.score * 0.21 - + (1 - noveltyToSelf) * 0.08 - + refractoryPenalty, 0, 1, ); const status = quietSignalRatio >= 0.55 && desireToSpeak < 0.68 ? "cooling" - : desireToSpeak >= 0.72 || maturation >= 0.78 + : noveltyToSelf < 0.28 && saturationMetrics.score >= 0.62 + ? "stalled" + : refractoryPenalty >= 0.18 && noveltyToRoom < 0.72 + ? "refractory" + : supportCount >= 6 && evidenceDiversity < 0.34 + ? "stalled" + : supportCount >= 3 && noveltyToSelf < 0.55 + ? "cooling" + : saturationMetrics.score >= 0.56 && noveltyToSelf < 0.42 + ? "cooling" + : ((desireToSpeak >= 0.74 && (noveltyToSelf >= 0.55 || noveltyToRoom >= 0.82)) && + saturationMetrics.score < 0.5) || + maturation >= 0.82 ? "ripe" : maturation <= 0.28 && recentSpeechDamping >= 0.55 ? "cooling" : "incubating"; + const priorityScore = clamp( + desireToSpeak * 0.36 + + noveltyToSelf * 0.2 + + noveltyToRoom * 0.18 + + evidenceDiversity * 0.12 + + explorationBonus * 0.14 - + saturationMetrics.score * 0.14 - + refractoryPenalty * 0.12, + 0, + 1, + ); + return { - thoughtId: cluster.clusterId, + thoughtId: readString(previousThought, "thoughtId") ?? cluster.clusterId, topic: cluster.label, summary: cluster.summary, sourceMemoryIds: cluster.memoryIds, @@ -518,13 +659,29 @@ function reconcileIncubation({ previous, clusters, runtime, now }) { resonance: cluster.resonance, quietSignalRatio: round3(quietSignalRatio), novelty: round3(novelty), + noveltyToSelf: round3(noveltyToSelf), + noveltyToRoom: round3(noveltyToRoom), maturation: round3(maturation), desireToSpeak: round3(desireToSpeak), deepDiveCount, + supportCount, + evidenceDiversity: round3(evidenceDiversity), + explorationBonus: round3(explorationBonus), + saturationScore: round3(saturationMetrics.score), + recentMatchCount: saturationMetrics.recentMatchCount, + refractoryPenalty: round3(refractoryPenalty), + priorityScore: round3(priorityScore), status, latentQuestion: buildLatentQuestion(cluster), - whyItPulls: buildAttractionLine(cluster), - holdingCloseBecause: buildHoldingLine({ cluster, status, recentSpeechDamping }), + whyItPulls: buildAttractionLine(cluster, { noveltyToSelf, evidenceDiversity }), + holdingCloseBecause: buildHoldingLine({ + cluster, + status, + recentSpeechDamping, + saturationScore: saturationMetrics.score, + noveltyToSelf, + explorationBonus, + }), lastDeepenedAt: now.toISOString(), lastStatusChangeAt: previousThought && readString(previousThought, "status") === status @@ -532,10 +689,16 @@ function reconcileIncubation({ previous, clusters, runtime, now }) { : now.toISOString(), }; }); + const active_thoughts = activeThoughtCandidates + .sort( + (left, right) => + (readNumber(right, "priorityScore") ?? 0) - (readNumber(left, "priorityScore") ?? 0), + ) + .slice(0, MAX_INCUBATING_THOUGHTS); const lastIncubationSummary = active_thoughts.length > 0 - ? `Strongest incubating seam: ${active_thoughts[0].topic} (${active_thoughts[0].status}, speak=${active_thoughts[0].desireToSpeak.toFixed(2)}).` + ? `Strongest incubating seam: ${active_thoughts[0].topic} (${active_thoughts[0].status}, speak=${active_thoughts[0].desireToSpeak.toFixed(2)}, self=${active_thoughts[0].noveltyToSelf.toFixed(2)}, room=${active_thoughts[0].noveltyToRoom.toFixed(2)}).` : "No incubating thought currently has enough connective tissue to justify special treatment."; return { @@ -611,7 +774,11 @@ function buildLatentQuestion(cluster) { return "What is this seam actually trying to become if it survives another pass?"; } -function buildAttractionLine(cluster) { +function buildAttractionLine(cluster, { noveltyToSelf, evidenceDiversity }) { + if (noveltyToSelf >= 0.62 && evidenceDiversity >= 0.45) { + return "It keeps pulling because it is still finding genuinely different evidence instead of merely changing hats."; + } + if (cluster.sourceKinds.length >= 3) { return `It keeps pulling because it is showing up in several different organs at once: ${cluster.sourceKinds.join(", ")}.`; } @@ -619,13 +786,21 @@ function buildAttractionLine(cluster) { return `It keeps pulling because ${cluster.sourceKinds.join(" and ")} are rhyming instead of staying in their lanes.`; } -function buildHoldingLine({ cluster, status, recentSpeechDamping }) { +function buildHoldingLine({ cluster, status, recentSpeechDamping, saturationScore, noveltyToSelf, explorationBonus }) { const quietSignalRatio = readNumber(cluster, "quietSignalRatio") ?? 0; if (quietSignalRatio >= 0.55) { return "This seam is mostly empty-room bookkeeping; keep at most a trace of it and go find a better question."; } + if (status === "stalled") { + return "This seam is repeating itself without earning enough new structure. Merge the receipts, cool it off, and go somewhere less domesticated."; + } + + if (status === "refractory") { + return "This seam has been chewing the same meat too recently. Let it cool unless a live hook or a genuinely different source family forces it back open."; + } + if (status === "ripe") { return "This seam has enough connective tissue that silence should be a choice, not a reflex."; } @@ -638,9 +813,415 @@ function buildHoldingLine({ cluster, status, recentSpeechDamping }) { return "It is still mostly one lane talking to itself."; } + if (saturationScore >= 0.55 && noveltyToSelf < 0.45) { + return "The family resemblance is getting too strong. Follow a stranger branch before you let this thought speak again."; + } + + if (explorationBonus >= 0.45) { + return "This one is still drawing energy from terrain Void has not overworked yet, so another pass might genuinely change it."; + } + return "Give it another pass so the thought has a better chance of growing teeth instead of just polish."; } +function buildSourceCoverage({ runtime, now }) { + const repoCounts = new Map(); + const channelCounts = new Map(); + const yearCounts = new Map(); + + for (const sweep of ensureArray(runtime.recent_repo_activity_sweeps).filter(isObject).slice(-16)) { + for (const repoName of ensureStringArray(sweep.repoNames)) { + repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); + } + } + + for (const excursion of ensureArray(runtime.recent_archive_excursions).filter(isObject).slice(-16)) { + const channelId = readString(excursion, "channelId"); + if (channelId) { + channelCounts.set(channelId, (channelCounts.get(channelId) ?? 0) + 1); + } + const timestamp = readString(excursion, "anchorTimestamp") ?? readString(excursion, "timestamp"); + const year = extractYear(timestamp); + if (year) { + yearCounts.set(year, (yearCounts.get(year) ?? 0) + 1); + } + } + + return { + lastUpdatedAt: now.toISOString(), + repos: mapCountEntries(repoCounts, "name"), + archiveYears: mapCountEntries(yearCounts, "year"), + channels: mapCountEntries(channelCounts, "channelId"), + }; +} + +function reconcileBridgeState({ previous, activeThoughts, sourceCoverage, now }) { + const bridge = { + ...previous, + source_coverage: sourceCoverage, + }; + const previousTopicSaturation = ensureArray(previous.topic_saturation).filter(isObject); + const previousRefractoryTopics = ensureArray(previous.refractory_topics).filter(isObject); + + const saturationEntries = activeThoughts + .filter((thought) => (readNumber(thought, "saturationScore") ?? 0) >= 0.42) + .sort( + (left, right) => + (readNumber(right, "saturationScore") ?? 0) - (readNumber(left, "saturationScore") ?? 0), + ) + .slice(0, MAX_TOPIC_SATURATION_COUNT) + .map((thought) => { + const topic = readString(thought, "topic") ?? "untitled"; + const prior = previousTopicSaturation.find( + (entry) => topicSimilarity(topic, readString(entry, "topic") ?? "") >= TOPIC_MATCH_THRESHOLD, + ); + return { + topic, + dominance: round3(readNumber(thought, "saturationScore") ?? 0), + recentMentions: readNumber(thought, "supportCount") ?? readNumber(prior, "recentMentions") ?? 1, + coolingAdvice: buildCoolingAdvice({ thought, sourceCoverage }), + lastUpdatedAt: now.toISOString(), + }; + }); + + const refractoryTopics = activeThoughts + .filter((thought) => { + const status = readString(thought, "status"); + return status === "refractory" || status === "stalled" || (readNumber(thought, "saturationScore") ?? 0) >= 0.62; + }) + .sort( + (left, right) => + (readNumber(right, "refractoryPenalty") ?? 0) - (readNumber(left, "refractoryPenalty") ?? 0), + ) + .slice(0, MAX_REFRACTORY_TOPIC_COUNT) + .map((thought) => { + const topic = readString(thought, "topic") ?? "untitled"; + const prior = previousRefractoryTopics.find( + (entry) => topicSimilarity(topic, readString(entry, "topic") ?? "") >= REFRACTORY_MATCH_THRESHOLD, + ); + const penalty = round3( + Math.max(readNumber(thought, "refractoryPenalty") ?? 0, readNumber(prior, "penalty") ?? 0.18), + ); + const hours = penalty >= 0.28 ? 4 : penalty >= 0.2 ? 3 : 2; + return { + topic, + penalty, + coolsUntil: new Date(now.getTime() + hours * 60 * 60 * 1000).toISOString(), + reason: buildRefractoryReason(thought), + lastTriggeredAt: now.toISOString(), + }; + }); + + bridge.topic_saturation = saturationEntries; + bridge.refractory_topics = refractoryTopics; + return bridge; +} + +function computeNoveltyToSelf({ cluster, previousThoughts, bridge, currentThoughtId }) { + let strongestMatch = 0; + + for (const thought of previousThoughts) { + if (readString(thought, "thoughtId") === currentThoughtId) { + continue; + } + strongestMatch = Math.max( + strongestMatch, + compareThoughtLikeSurfaces( + cluster, + readString(thought, "topic"), + readString(thought, "summary"), + ensureStringArray(thought.sourceMemoryIds), + ), + ); + } + + for (const synthesis of ensureArray(bridge.recent_syntheses).filter(isObject).slice(-6)) { + strongestMatch = Math.max( + strongestMatch, + compareThoughtLikeSurfaces( + cluster, + ensureStringArray(synthesis.dominantTopics).join(" / "), + readString(synthesis, "summary"), + [], + ), + ); + } + + return clamp(1 - strongestMatch, 0, 1); +} + +function computeNoveltyToRoom(cluster, recentNoveltyChecks) { + let bestScore = 0.64; + + for (const check of recentNoveltyChecks.slice(-12)) { + const match = compareThoughtLikeSurfaces( + cluster, + readString(check, "topic"), + readString(check, "summary"), + ensureStringArray(check.supportingMessageIds), + ); + + if (match < TOPIC_MATCH_THRESHOLD) { + continue; + } + + bestScore = mapNoveltyResultToScore(readString(check, "result")); + break; + } + + return bestScore; +} + +function computeSaturationMetrics({ cluster, previousThoughts, bridge, supportCount }) { + let recentMatchCount = 0; + + for (const thought of previousThoughts) { + const match = compareThoughtLikeSurfaces( + cluster, + readString(thought, "topic"), + readString(thought, "summary"), + ensureStringArray(thought.sourceMemoryIds), + ); + if (match >= TOPIC_MATCH_THRESHOLD) { + recentMatchCount += 1; + } + } + + for (const synthesis of ensureArray(bridge.recent_syntheses).filter(isObject).slice(-5)) { + const match = compareThoughtLikeSurfaces( + cluster, + ensureStringArray(synthesis.dominantTopics).join(" / "), + readString(synthesis, "summary"), + [], + ); + if (match >= TOPIC_MATCH_THRESHOLD) { + recentMatchCount += 1; + } + } + + const existingTopicSaturation = ensureArray(bridge.topic_saturation) + .filter(isObject) + .map((entry) => + topicSimilarity(cluster.label, readString(entry, "topic") ?? "") >= TOPIC_MATCH_THRESHOLD + ? readNumber(entry, "dominance") ?? 0 + : 0, + ) + .sort((left, right) => right - left)[0] ?? 0; + + const score = clamp( + recentMatchCount * 0.16 + + existingTopicSaturation * 0.42 + + Math.min(1, supportCount / 10) * 0.16 + + (readNumber(cluster, "quietSignalRatio") ?? 0) * 0.2, + 0, + 1, + ); + + return { + score, + recentMatchCount, + }; +} + +function computeRefractoryPenalty(cluster, bridge, now) { + let penalty = 0; + + for (const topic of ensureArray(bridge.refractory_topics).filter(isObject)) { + const coolsUntil = readString(topic, "coolsUntil"); + if (coolsUntil && new Date(coolsUntil).getTime() < now.getTime()) { + continue; + } + + const match = topicSimilarity(cluster.label, readString(topic, "topic") ?? ""); + if (match >= REFRACTORY_MATCH_THRESHOLD) { + penalty = Math.max( + penalty, + (readNumber(topic, "penalty") ?? 0.18) * match, + ); + } + } + + return clamp(penalty, 0, 0.45); +} + +function computeExplorationBonus(cluster, sourceCoverage) { + const sourceKinds = ensureStringArray(cluster.sourceKinds); + const hasPrimaryExplorationSource = + sourceKinds.includes("repo_sweep") || sourceKinds.includes("archive_excursion"); + + const repoEntries = ensureArray(sourceCoverage.repos).filter(isObject); + const yearEntries = ensureArray(sourceCoverage.archiveYears).filter(isObject); + const channelEntries = ensureArray(sourceCoverage.channels).filter(isObject); + const scores = []; + + for (const repoName of ensureStringArray(cluster.repoNames)) { + scores.push(inverseCoverageWeight(repoEntries, "name", repoName)); + } + for (const year of ensureStringArray(cluster.archiveYears)) { + scores.push(inverseCoverageWeight(yearEntries, "year", year)); + } + for (const channelId of ensureStringArray(cluster.channelIds)) { + scores.push(inverseCoverageWeight(channelEntries, "channelId", channelId)); + } + + if (scores.length === 0) { + return hasPrimaryExplorationSource ? 0.24 : 0.12; + } + + const average = scores.reduce((sum, value) => sum + value, 0) / scores.length; + return clamp( + hasPrimaryExplorationSource ? average : Math.min(0.3, average * 0.5), + 0, + 1, + ); +} + +function inverseCoverageWeight(entries, key, value) { + const count = + readNumber( + entries.find((entry) => readString(entry, key) === value), + "count", + ) ?? 0; + if (count <= 1) { + return 0.9; + } + if (count === 2) { + return 0.64; + } + if (count === 3) { + return 0.42; + } + return 0.2; +} + +function buildCoolingAdvice({ thought, sourceCoverage }) { + const topic = readString(thought, "topic") ?? "this seam"; + const repos = ensureArray(sourceCoverage.repos).filter(isObject); + const years = ensureArray(sourceCoverage.archiveYears).filter(isObject); + const lesserRepo = repos.sort((left, right) => (readNumber(left, "count") ?? 0) - (readNumber(right, "count") ?? 0))[0]; + const lesserYear = years.sort((left, right) => (readNumber(left, "count") ?? 0) - (readNumber(right, "count") ?? 0))[0]; + + if (lesserRepo && readString(lesserRepo, "name")) { + return `Cool ${topic} by checking a less-worked repo family such as ${readString(lesserRepo, "name")} before speaking again.`; + } + + if (lesserYear && readString(lesserYear, "year")) { + return `Cool ${topic} by diving into a less-touched archive era like ${readString(lesserYear, "year")} before another synthesis.`; + } + + return `Cool ${topic} by chasing a genuinely different source family before letting it speak again.`; +} + +function buildRefractoryReason(thought) { + const topic = readString(thought, "topic") ?? "this seam"; + const noveltyToSelf = readNumber(thought, "noveltyToSelf") ?? 0; + const saturationScore = readNumber(thought, "saturationScore") ?? 0; + if (noveltyToSelf < 0.3) { + return `${topic} is matching Void's own recent thought history too closely to deserve another immediate pass.`; + } + if (saturationScore >= 0.62) { + return `${topic} has dominated too many recent syntheses and needs cooling before it becomes state religion.`; + } + return `${topic} needs a little cooling-off period before it earns attention again.`; +} + +function compareThoughtLikeSurfaces(cluster, topic, summary, sourceMemoryIds) { + const topicMatch = topicSimilarity(cluster.label, topic ?? ""); + const summaryMatch = topicSimilarity(cluster.summary, summary ?? ""); + const sourceMatch = overlapRatio(cluster.memoryIds, ensureArray(sourceMemoryIds)); + return Math.max(topicMatch, summaryMatch * 0.9, sourceMatch * 0.95); +} + +function mapNoveltyResultToScore(result) { + const normalized = String(result ?? "").toLowerCase(); + if (normalized === "novel") { + return 0.92; + } + if (normalized.includes("adjacent")) { + return 0.56; + } + if (normalized.includes("duplicate") || normalized.includes("already")) { + return 0.2; + } + return 0.64; +} + +function topicSimilarity(left, right) { + const leftTokens = topKeywords(left ?? "", 6); + const rightTokens = topKeywords(right ?? "", 6); + if (leftTokens.length === 0 || rightTokens.length === 0) { + return 0; + } + + const leftSet = new Set(leftTokens); + const rightSet = new Set(rightTokens); + let overlap = 0; + for (const token of leftSet) { + if (rightSet.has(token)) { + overlap += 1; + } + } + + return overlap / Math.max(leftSet.size, rightSet.size); +} + +function mapCountEntries(map, keyName) { + return [...map.entries()] + .map(([key, count]) => ({ [keyName]: key, count })) + .sort((left, right) => right.count - left.count); +} + +function gatherSourceRefs(entry) { + if (!isObject(entry)) { + return []; + } + return [ + ...ensureStringArray(entry.evidenceRefs), + ...ensureStringArray(entry.evidenceMessageIds), + ...ensureStringArray(entry.distilledFrom), + ...ensureStringArray(entry.sourceMemoryIds), + ]; +} + +function gatherSourceMeta({ entry, kind }) { + const sourceMeta = {}; + const repoNames = ensureStringArray(entry.repoNames); + if (repoNames.length > 0) { + sourceMeta.repoNames = repoNames; + } + + const channelId = readString(entry, "channelId"); + if (channelId) { + sourceMeta.channelId = channelId; + } + + const archiveTimestamp = + readString(entry, "anchorTimestamp") ?? + readString(entry, "sourceTimestamp") ?? + readString(entry, "timestamp"); + const archiveYear = extractYear(archiveTimestamp); + if (archiveYear) { + sourceMeta.archiveYear = archiveYear; + } + + if (kind === "bridge_synthesis") { + const dominantTopics = ensureStringArray(entry.dominantTopics); + if (dominantTopics.length > 0) { + sourceMeta.dominantTopics = dominantTopics; + } + } + + return sourceMeta; +} + +function extractYear(timestamp) { + if (typeof timestamp !== "string" || timestamp.length < 4) { + return undefined; + } + const match = timestamp.match(/^(\d{4})-/); + return match ? match[1] : undefined; +} + function createEmbedder({ repoRootPath }) { const env = parseDotEnvSafe(resolve(repoRootPath, ".env")); const backend = env.RAG_EMBEDDING_BACKEND === "hash" ? "hash" : "ollama"; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index eb054e0..4f21491 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -61,3 +61,4 @@ {"ts":"2026-05-07T21:39:16Z","type":"research","status":"ok","note":"Heartbeat Void was not philosophically withdrawn; the canonical moderation state had a legacy CultCache envelope with object payloads that newer strict loaders rejected, killing mood drift and moderation before they could update status. The cache layer now self-heals that shape on read, the moderation runner now writes failed status on preflight death, and the memory organ explicitly downweights repetitive empty-room bookkeeping so 'no new messages / no smoke / no post' stops overpowering live-room hooks."} {"ts":"2026-05-07T22:38:47Z","type":"research","status":"ok","note":"Heartbeat Void was treating the reviewed cursor as if it meant the room no longer needed anything, which let a direct question from Ziggy get skipped in favor of repo weather after a no-post catch-up run. Direct asks now belong in moderation_runtime.open_cases as persistent room obligations, the moderation doctrine says they outrank optional heralding until answered or explicitly retired, the shared self-state summary surfaces those obligations to direct-invoked Void, and a live heartbeat pass proved the fix by replying to Ziggy's Nibu question before returning to quiet-room rumination."} {"ts":"2026-05-08T08:59:47Z","type":"research","status":"ok","note":"The overnight repetition bug was not just missing retrieval. Quiet-room repo weather kept returning on the same carrier/lineage seam, and the right correction is to bias the moderation loop toward deeper archive, repo-doc, diff, and lore dives when a thought feels familiar, not to accumulate more cooldown knobs or brittle anti-repetition law. The tracked doctrine now says 'same seam again' means go learn something before you speak."} +{"ts":"2026-05-08T15:50:57Z","type":"architecture","status":"ok","note":"Moderation novelty now needs two bars, not one: room novelty and self novelty. The memory organ merges repeated seams into existing clusters, raises saturation and refractory pressure, tracks source-coverage pressure across repos/archive terrain, and cools familiar thoughts instead of rewarding repetition with ripeness."} diff --git a/state/map.yaml b/state/map.yaml index 03cd10f..ba33a8b 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -76,6 +76,7 @@ accepted_design: - Direct asks addressed to Void must persist as explicit room obligations in moderation state. The reviewed cursor is not the same thing as closure, and optional repo-weather should not preempt an unanswered human. - When quiet-room repo weather starts repeating itself, the correction should be deeper archive/repo/lore digging and better salience, not a growing hedge maze of cooldown knobs and brittle rules. - 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. + - Moderation novelty now needs two bars, not one: room novelty and self novelty. If Void has already been thinking the same seam in slightly different outfits, the memory organ should merge support into the existing cluster, raise saturation/refractory pressure, and bias the next pass toward underexplored repo families or archive terrain instead of rewarding repetition with ripeness. - 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.