Build Void incubation memory organ
This commit is contained in:
parent
de64d6aa1a
commit
682dbc5bce
11 changed files with 1199 additions and 19 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
900
scripts/void-memory-organ.mjs
Normal file
900
scripts/void-memory-organ.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue