Replace heuristic room read with Ollama sidecar

This commit is contained in:
Meta Vens 2026-05-02 20:37:13 +01:00
commit 8bd43c2c3a
14 changed files with 423 additions and 6 deletions

View file

@ -11,6 +11,7 @@ import {
import type { AppConfig } from "@voidbot/config";
import {
ContextBuilder,
type OllamaSituationalSocialReadInferer,
PermissionEngine,
type AuditLog,
type InteractionMemoryBank,
@ -28,6 +29,7 @@ import {
type Actor,
type CommandName,
type GuildContext,
type InteractionMemoryProfile,
type JobRecord,
type ProviderName,
type StylePack,
@ -133,6 +135,7 @@ export interface PromptHandlerOptions {
interactionMemory: InteractionMemoryBank;
voidUsageRateLimiter: VoidUsageRateLimiter;
providerRegistry: ProviderRegistry;
situationalSocialReadInferer?: OllamaSituationalSocialReadInferer;
stylePack?: StylePack;
systemMessages: SystemMessageCatalog;
forceProvider?: ProviderName;
@ -217,6 +220,10 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise<void>
rememberedInteraction.totalInteractions > 0 ? rememberedInteraction : undefined;
const recentMessages = await getRecentMessages(options.channel, 10);
const situationalSocialRead = await inferSituationalSocialRead(options, {
recentMessages,
interactionMemory,
});
const sourceGrounding = inferSourceGroundingHint(
options.prompt,
await options.sourceArchiveRepository.listRepoSummaries(),
@ -238,6 +245,7 @@ export async function handlePrompt(options: PromptHandlerOptions): Promise<void>
recentMessages,
retrieval,
interactionMemory,
situationalSocialRead,
sourceGrounding,
stylePack: options.stylePack,
});
@ -629,3 +637,67 @@ async function postFinalResponse(client: Client, job: JobRecord, finalResponse:
await sendChunkedChannelMessage(channel, finalResponse);
}
async function inferSituationalSocialRead(
options: PromptHandlerOptions,
input: {
recentMessages: Awaited<ReturnType<typeof getRecentMessages>>;
interactionMemory?: InteractionMemoryProfile;
},
) {
if (!options.situationalSocialReadInferer) {
return undefined;
}
try {
const read = await options.situationalSocialReadInferer.infer({
prompt: options.prompt,
actor: options.actor,
recentMessages: input.recentMessages,
interactionMemory: input.interactionMemory,
});
if (!read) {
return undefined;
}
await options.auditLog.record({
type: "situational_social_read.inferred",
actorId: options.actor.id,
provider: "local_llm",
details: {
command: options.command,
channelId: options.guildContext.channelId,
channelName: options.guildContext.channelName ?? null,
promptExcerpt: truncate(options.prompt, 280),
recentMessageCount: input.recentMessages.length,
model: options.config.localLlm.ollamaModel,
summary: read.summary,
roomTone: read.roomTone,
speakerCurrentRead: read.speakerCurrentRead,
socialFrame: read.socialFrame,
responseGuidance: read.responseGuidance,
supportingSignals: read.supportingSignals,
},
});
return read;
} catch (error) {
await options.auditLog.record({
type: "situational_social_read.failed",
actorId: options.actor.id,
provider: "local_llm",
details: {
command: options.command,
channelId: options.guildContext.channelId,
channelName: options.guildContext.channelName ?? null,
promptExcerpt: truncate(options.prompt, 280),
model: options.config.localLlm.ollamaModel,
errorMessage:
error instanceof Error ? error.message : "Unexpected situational social read failure.",
},
});
return undefined;
}
}

View file

@ -203,6 +203,7 @@ export function convertDiscordMessageToSource(message: Message): SourceMessage {
authorName: message.author.displayName ?? message.author.username,
content: message.content,
timestamp: message.createdAt.toISOString(),
isBot: message.author.bot,
};
}

View file

@ -10,6 +10,7 @@ import { loadConfig } from "@voidbot/config";
import {
buildVoidMcpServerConfig,
ContextBuilder,
OllamaSituationalSocialReadInferer,
PermissionEngine,
createStateStorage,
loadStylePack,
@ -146,6 +147,13 @@ export async function startBot(): Promise<void> {
config.rateLimits,
);
const contextBuilder = new ContextBuilder();
const situationalSocialReadInferer = new OllamaSituationalSocialReadInferer({
ollamaBaseUrl: config.localLlm.ollamaBaseUrl,
ollamaModel: config.localLlm.ollamaModel,
ollamaTimeoutMs: config.localLlm.ollamaTimeoutMs,
ollamaKeepAlive: config.localLlm.ollamaKeepAlive,
ollamaNumCtx: config.localLlm.ollamaNumCtx,
});
const baseSystemMessagesPath = resolve("config/system-messages.json");
let activeStylePack = await loadStylePack(config.stylePackPath);
let activeSystemMessages = await loadSystemMessageCatalog(
@ -359,6 +367,7 @@ export async function startBot(): Promise<void> {
interactionMemory,
voidUsageRateLimiter,
providerRegistry,
situationalSocialReadInferer,
stylePack: activeStylePack,
systemMessages: activeSystemMessages,
silentOwnerQueueAck: !isDirectMessage,
@ -441,6 +450,7 @@ export async function startBot(): Promise<void> {
interactionMemory,
voidUsageRateLimiter,
providerRegistry,
situationalSocialReadInferer,
stylePack: activeStylePack,
systemMessages: activeSystemMessages,
forceProvider: requestedProvider,
@ -470,6 +480,7 @@ export async function startBot(): Promise<void> {
interactionMemory,
voidUsageRateLimiter,
providerRegistry,
situationalSocialReadInferer,
stylePack: activeStylePack,
systemMessages: activeSystemMessages,
forceProvider: "owner_codex",

View file

@ -26,6 +26,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- The Discord bot lane is no longer one swollen entrypoint; gateway wiring remains in `apps/bot/src/discord-bot.ts` while command/prompt handlers and Discord-shape support helpers now live under `apps/bot/src/discord-bot-*.ts`.
- The worker MCP surface is no longer one swollen server file; stdio bootstrap stays in `apps/worker/src/mcp-server.ts` while context bootstrap, resource registration, tool registration, Discord notify helpers, and shared formatting/schema helpers live under `apps/worker/src/mcp-server-*.ts`.
- Interaction-memory logic is no longer one thousand-line slab; event/tone analysis, shared constants, and profile synthesis are split across `packages/core/src/interaction-memory-*.ts`.
- Ephemeral room-reading now comes from a quick `think=false` local Ollama sidecar in `packages/core/src/situational-social-read.ts` instead of hand-built phrase heuristics, and the inferred reads are recorded as audit events so they can be aggregated into richer profiles later without immediately mutating durable identity state.
- State storage is no longer one persistence omnibus; `packages/core/src/state-storage.ts` is now a thin factory over domain-specific Postgres/file store modules plus bootstrap/migration helpers.
- Source archive storage is no longer one giant JSON monolith; `packages/rag/src/source-document-archive.ts` now keeps a small manifest at `.voidbot/rag/source-documents.json` and per-repo shards under `.voidbot/rag/source-documents.repos/`.
- Ops health, backup verification, offsite sync, and dashboard surfaces already exist.

View file

@ -41,7 +41,9 @@ This note is the source-grounded description of how the live VoidBot stack is sh
- `packages/core/src/interaction-memory-profile.ts`
- Profile synthesis, disposition, psychological read, inferred traits, interaction dimensions, and response-guidance construction.
- `packages/core/src/context-builder.ts`
- request context assembly, including interaction-memory/profile attachment.
- request context assembly, including interaction-memory/profile attachment plus any precomputed situational social read.
- `packages/core/src/situational-social-read.ts`
- quick Ollama sidecar inferer for ephemeral room-reading scaffolding built from the current prompt, recent room transcript, and longer-horizon interaction memory.
- `packages/providers/src/owner-codex-provider.ts`
- thin orchestration layer for the Discord-safe `codex exec` lane.
- `packages/providers/src/owner-codex-runtime.ts`
@ -73,10 +75,11 @@ This note is the source-grounded description of how the live VoidBot stack is sh
2. Permission checks run through `packages/core/src/permission-engine.ts`.
3. Void usage limits are applied through `packages/core/src/void-usage-rate-limiter.ts` backed by `packages/core/src/state-storage.ts`.
4. `apps/bot/src/discord-bot-support.ts` adapts Discord message/interaction shapes, ambient-memory events, and source-grounding hints.
5. `packages/core/src/interaction-memory-analysis.ts` turns direct prompts or ambient mentions into normalized remembered events; `packages/core/src/interaction-memory-profile.ts` distills remembered events into a reusable social read.
6. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile and any retrieval hints.
7. Provider selection goes through `packages/providers/src/index.ts`.
8. The bot either:
5. `packages/core/src/interaction-memory-analysis.ts` turns direct prompts or ambient mentions into normalized remembered events; `packages/core/src/interaction-memory-profile.ts` distills remembered events into a reusable longer-horizon social read.
6. `apps/bot/src/discord-bot-handlers.ts` runs a quick `think=false` local Ollama sidecar pass over the prompt and recent room transcript to infer an ephemeral situational social read, then records that read as an audit event for later aggregation.
7. `packages/core/src/context-builder.ts` assembles request context, including recent interaction profile, the situational social read, and any retrieval hints.
8. Provider selection goes through `packages/providers/src/index.ts`.
9. The bot either:
- answers directly through `local_llm`, or
- queues an owner job for the worker / `owner_codex` path.
@ -84,7 +87,7 @@ This note is the source-grounded description of how the live VoidBot stack is sh
1. `apps/worker/src/index.ts` polls approved jobs from durable state.
2. The worker claims a job and dispatches it to the configured provider.
3. `packages/providers/src/owner-codex-provider.ts` runs `codex exec` in the bounded lane, exposes the VoidBot MCP server, records traces, and enforces source grounding for repo/lore/project questions.
3. `packages/providers/src/owner-codex-provider.ts` runs `codex exec` in the bounded lane, exposes the VoidBot MCP server, records traces, and carries advisory source-grounding hints plus retrieval tools for repo/lore/project questions.
Runtime and parsing live in `packages/providers/src/owner-codex-runtime.ts`; prompt and artifact rendering live in `packages/providers/src/owner-codex-render.ts`.
4. If the answer fits the Discord-safe lane, the worker posts it back.
5. If the task needs deeper work, the worker writes a handoff bundle under `.voidbot/artifacts/<job-id>/` and posts the handoff response.

View file

@ -4,6 +4,7 @@ import {
type GuildContext,
type InteractionMemoryProfile,
type RetrievalResult,
type SituationalSocialRead,
type SourceGroundingHint,
type SourceMessage,
type StylePack,
@ -16,6 +17,7 @@ export interface BuildContextInput {
recentMessages?: SourceMessage[];
retrieval?: RetrievalResult[];
interactionMemory?: InteractionMemoryProfile;
situationalSocialRead?: SituationalSocialRead;
sourceGrounding?: SourceGroundingHint;
stylePack?: StylePack;
}
@ -29,6 +31,7 @@ export class ContextBuilder {
recentMessages: input.recentMessages ?? [],
retrieval: input.retrieval ?? [],
interactionMemory: input.interactionMemory,
situationalSocialRead: input.situationalSocialRead,
sourceGrounding: input.sourceGrounding,
stylePack: input.stylePack,
createdAt: new Date().toISOString(),

View file

@ -4,6 +4,7 @@ export * from "./context-builder";
export * from "./interaction-memory";
export * from "./job-queue";
export * from "./permission-engine";
export * from "./situational-social-read";
export * from "./state-storage";
export * from "./style-pack-loader";
export * from "./system-message-catalog";

View file

@ -0,0 +1,264 @@
import {
type Actor,
type InteractionMemoryProfile,
type SituationalSocialRead,
type SourceMessage,
} from "@voidbot/shared";
interface SituationalSocialReadInput {
prompt: string;
actor: Actor;
recentMessages?: SourceMessage[];
interactionMemory?: InteractionMemoryProfile;
}
interface InferSituationalSocialReadRequestBody {
model: string;
messages: Array<{
role: "system" | "user";
content: string;
}>;
stream: false;
think: false;
keep_alive: string;
format: "json";
options: {
num_ctx: number;
temperature: number;
};
}
interface InferSituationalSocialReadResponseBody {
message?: {
content?: unknown;
};
}
export interface OllamaSituationalSocialReadInfererOptions {
ollamaBaseUrl: string;
ollamaModel: string;
ollamaTimeoutMs: number;
ollamaKeepAlive: string;
ollamaNumCtx: number;
}
const MAX_RECENT_MESSAGES = 12;
const MAX_MESSAGE_CHARS = 280;
export class OllamaSituationalSocialReadInferer {
public constructor(
private readonly options: OllamaSituationalSocialReadInfererOptions,
) {}
public async infer(
input: SituationalSocialReadInput,
): Promise<SituationalSocialRead | undefined> {
const recentMessages = (input.recentMessages ?? [])
.filter((message) => message.content.trim().length > 0)
.slice(-MAX_RECENT_MESSAGES);
if (recentMessages.length === 0 && !input.interactionMemory) {
return undefined;
}
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
Math.min(this.options.ollamaTimeoutMs, 30_000),
);
try {
const requestBody: InferSituationalSocialReadRequestBody = {
model: this.options.ollamaModel,
messages: [
{
role: "system",
content: buildSystemPrompt(),
},
{
role: "user",
content: buildUserPrompt(input, recentMessages),
},
],
stream: false,
think: false,
keep_alive: this.options.ollamaKeepAlive,
format: "json",
options: {
num_ctx: this.options.ollamaNumCtx,
temperature: 0.2,
},
};
const response = await fetch(`${normalizeBaseUrl(this.options.ollamaBaseUrl)}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(
`Ollama situational social read request failed with ${response.status}: ${await response.text()}`,
);
}
const payload =
(await response.json()) as InferSituationalSocialReadResponseBody;
const rawContent = normalizeModelText(payload.message?.content);
if (!rawContent) {
return undefined;
}
const parsed = parseSituationalSocialRead(rawContent);
return parsed ?? undefined;
} catch (error) {
if ((error as Error).name === "AbortError") {
throw new Error(
`Timed out waiting for situational social read from local Ollama model "${this.options.ollamaModel}" at ${this.options.ollamaBaseUrl}.`,
);
}
throw error;
} finally {
clearTimeout(timeoutHandle);
}
}
}
function buildSystemPrompt(): string {
return [
"You are producing private room-reading scaffolding for a Discord bot reply.",
"Read the recent room context and infer only what is useful for this one reply.",
"Do not diagnose, moralize, or write therapy notes.",
"Do not infer more certainty than the evidence supports.",
"This read is private scaffolding and must stay concise, practical, and grounded in visible behavior.",
"Return strict JSON only with these keys:",
'- "summary": one short synthesis of the current room situation',
'- "roomTone": the room tone for this moment',
'- "speakerCurrentRead": what the current speaker seems to be doing or wanting right now',
'- "socialFrame": what kind of interaction this is and how it should be framed',
'- "responseGuidance": private guidance for the final reply model',
'- "supportingSignals": an array of short evidence bullets tied to the visible context',
"If the evidence is weak, say so plainly inside the fields instead of inventing drama.",
].join("\n");
}
function buildUserPrompt(
input: SituationalSocialReadInput,
recentMessages: SourceMessage[],
): string {
const recentTranscript =
recentMessages.length > 0
? recentMessages
.map(
(message) =>
`- [${message.timestamp}] ${message.authorName}${message.isBot ? " (bot)" : ""}: ${truncate(message.content, MAX_MESSAGE_CHARS)}`,
)
.join("\n")
: "- No recent room transcript was attached.";
const interactionMemory = input.interactionMemory
? [
`Relationship summary: ${input.interactionMemory.summary}`,
`Current stance: ${input.interactionMemory.disposition}; affinity=${input.interactionMemory.affinityScore}`,
`Private response guidance: ${input.interactionMemory.responseGuidance}`,
].join("\n")
: "No explicit long-horizon interaction memory was attached.";
return [
`Current speaker: ${input.actor.displayName} (${input.actor.id})`,
"",
"Current prompt:",
input.prompt,
"",
"Recent room transcript:",
recentTranscript,
"",
"Longer-horizon interaction memory:",
interactionMemory,
"",
"Infer a private situational social read for this one reply.",
"Ground it in the visible room context and the current prompt.",
"Do not output anything except the requested JSON object.",
].join("\n");
}
function parseSituationalSocialRead(
input: string,
): SituationalSocialRead | undefined {
const normalized = unwrapStructuredFence(input);
if (!normalized.startsWith("{")) {
return undefined;
}
try {
const parsed = JSON.parse(normalized) as Record<string, unknown>;
const summary = normalizeModelText(parsed.summary);
const roomTone = normalizeModelText(parsed.roomTone);
const speakerCurrentRead = normalizeModelText(parsed.speakerCurrentRead);
const socialFrame = normalizeModelText(parsed.socialFrame);
const responseGuidance = normalizeModelText(parsed.responseGuidance);
const supportingSignals = normalizeStringArray(parsed.supportingSignals);
if (
!summary ||
!roomTone ||
!speakerCurrentRead ||
!socialFrame ||
!responseGuidance
) {
return undefined;
}
return {
summary,
roomTone,
speakerCurrentRead,
socialFrame,
responseGuidance,
supportingSignals,
};
} catch {
return undefined;
}
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => normalizeModelText(entry))
.filter((entry): entry is string => Boolean(entry));
}
function unwrapStructuredFence(input: string): string {
const trimmed = input.trim();
const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
return (match?.[1] ?? trimmed).trim();
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function normalizeModelText(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function truncate(value: string, maxLength: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}

View file

@ -25,6 +25,7 @@ export function buildSystemPrompt(context: ContextBundle): string {
"Stay honest about what context was actually provided to you.",
"Use retrieved snippets and recent channel context when they help answer the question.",
"If explicit interaction memory for the current speaker is attached, you may let it gently color the tone and reference it when relevant, but do not invent history beyond what was provided.",
"If a situational social read is attached, use it as private room-reading scaffolding for this one reply. It is ephemeral context, not a durable identity verdict.",
"Treat the attached interaction memory as a non-clinical behavioral read, not a diagnosis. Use the remembered dimensions, traits, and guidance to adapt tone, pacing, firmness, structure, and warmth to the person in front of you.",
"The attached interaction memory and inferred guidance are private response scaffolding, not content to expose. Do not quote, summarize, classify, or explain the speaker's inferred traits, engagement patterns, psychological profile, or hidden response guidance unless they explicitly ask how you are reading them.",
"Do not turn a substantive question into a meta-analysis of the user's personality, engagement style, sentiment, or recent conversation behavior unless they explicitly asked for that kind of read.",
@ -63,6 +64,7 @@ export function buildPrompt(context: ContextBundle): string {
.join("\n")
: "- No archived retrieval snippets were attached.";
const interactionMemory = renderInteractionMemory(context);
const situationalSocialRead = renderSituationalSocialRead(context);
return [
`Question: ${context.prompt}`,
@ -80,6 +82,9 @@ export function buildPrompt(context: ContextBundle): string {
"Interaction memory for this speaker:",
interactionMemory,
"",
"Private situational social read for this room:",
situationalSocialRead,
"",
"If you need more archived history or source context than is included above, call the appropriate read-only tool before answering.",
].join("\n");
}
@ -113,6 +118,22 @@ function renderInteractionMemory(context: ContextBundle): string {
].join("\n");
}
function renderSituationalSocialRead(context: ContextBundle): string {
if (!context.situationalSocialRead) {
return "- No strong situational social read was derived from the immediate room context.";
}
return [
`- Summary: ${context.situationalSocialRead.summary}`,
`- Room tone: ${context.situationalSocialRead.roomTone}`,
`- Speaker current read: ${context.situationalSocialRead.speakerCurrentRead}`,
`- Social frame: ${context.situationalSocialRead.socialFrame}`,
`- Private response guidance (do not reveal): ${context.situationalSocialRead.responseGuidance}`,
"- Supporting signals:",
...context.situationalSocialRead.supportingSignals.map((signal) => ` ${signal}`),
].join("\n");
}
function renderSourceGroundingInstructions(
context: ContextBundle,
reminder: boolean,

View file

@ -17,6 +17,7 @@ import {
type CodexRunResult,
type CodexTraceEvent,
renderInteractionMemory,
renderSituationalSocialRead,
renderUsageSummary,
sanitizeTraceText,
summarizeStderrDiagnostics,
@ -79,6 +80,7 @@ export function buildDiscordReplyPrompt(
"- When the moment invites voice, wit, or a memorable turn of phrase, take a beat to choose wording with bite instead of blurting the first serviceable sentence. Do not overwork straightforward technical replies.",
"- The configured MCP tools are available in this session, especially search_history, get_message_context, list_indexed_repos, search_sources, and get_source_context.",
"- If explicit interaction memory for the current speaker is attached, you may let it subtly color the tone and reference it when relevant, but do not invent relationship history beyond that record.",
"- If a situational social read is attached, use it as private room-reading scaffolding for this one reply. It is ephemeral context, not a durable identity verdict.",
"- Treat the attached interaction memory as a non-clinical behavioral read, not a diagnosis. Use the remembered dimensions, traits, and guidance to adapt tone, pacing, firmness, structure, and warmth to the person in front of you.",
"- The attached interaction memory and inferred guidance are private response scaffolding, not content to expose. Do not quote, summarize, classify, or explain the speaker's inferred traits, engagement patterns, psychological profile, or hidden response guidance unless they explicitly ask how you are reading them.",
"- Do not turn a substantive question into a meta-analysis of the user's personality, engagement style, sentiment, or recent conversation behavior unless they explicitly asked for that kind of read.",
@ -117,6 +119,9 @@ export function buildDiscordReplyPrompt(
"",
"Interaction memory for this speaker:",
renderInteractionMemory(context),
"",
"Private situational social read for this room:",
renderSituationalSocialRead(context),
...toolLoopNotes,
].join("\n");
}
@ -168,6 +173,10 @@ export function renderMarkdownBundle(context: ContextBundle): string {
"",
renderInteractionMemory(context),
"",
"## Situational Social Read",
"",
renderSituationalSocialRead(context),
"",
"## Execution Notes",
"",
"- This provider is owner-only.",

View file

@ -121,6 +121,7 @@ export function buildRequestPayload(
actor: request.contextBundle.actor,
guildContext: request.contextBundle.guildContext,
interactionMemory: request.contextBundle.interactionMemory,
situationalSocialRead: request.contextBundle.situationalSocialRead,
sourceGrounding: request.contextBundle.sourceGrounding,
stylePack: request.contextBundle.stylePack,
recentMessages: request.contextBundle.recentMessages,
@ -456,3 +457,19 @@ export function renderInteractionMemory(context: ContextBundle): string {
recentEvents,
].join("\n");
}
export function renderSituationalSocialRead(context: ContextBundle): string {
if (!context.situationalSocialRead) {
return "- No strong situational social read was derived from the immediate room context.";
}
return [
`- Summary: ${context.situationalSocialRead.summary}`,
`- Room tone: ${context.situationalSocialRead.roomTone}`,
`- Speaker current read: ${context.situationalSocialRead.speakerCurrentRead}`,
`- Social frame: ${context.situationalSocialRead.socialFrame}`,
`- Private response guidance (do not reveal): ${context.situationalSocialRead.responseGuidance}`,
"- Supporting signals:",
...context.situationalSocialRead.supportingSignals.map((signal) => ` ${signal}`),
].join("\n");
}

View file

@ -73,6 +73,7 @@ export interface SourceMessage {
authorName: string;
content: string;
timestamp: string;
isBot?: boolean;
}
export interface RetrievalResult {
@ -183,6 +184,15 @@ export interface SourceGroundingHint {
matchedRepoNames: string[];
}
export interface SituationalSocialRead {
summary: string;
roomTone: string;
speakerCurrentRead: string;
socialFrame: string;
responseGuidance: string;
supportingSignals: string[];
}
export interface ContextBundle {
prompt: string;
actor: Actor;
@ -190,6 +200,7 @@ export interface ContextBundle {
recentMessages: SourceMessage[];
retrieval: RetrievalResult[];
interactionMemory?: InteractionMemoryProfile;
situationalSocialRead?: SituationalSocialRead;
sourceGrounding?: SourceGroundingHint;
stylePack?: StylePack;
createdAt: string;

View file

@ -23,3 +23,4 @@
{"ts":"2026-05-02T18:41:02Z","type":"bugfix","status":"ok","note":"Direct local_llm replies could emit raw JSON/tool payload fragments as final Discord answers; added structured-output rewrite guard and audit output previews for direct provider responses."}
{"ts":"2026-05-02T18:58:38Z","type":"bugfix","status":"ok","note":"Local LLM could choose search_sources for real-life incident questions and get Ghostlight narrative hits; prompt/tool guidance now biases prior server drama and personal-history questions toward search_history before repo/lore search."}
{"ts":"2026-05-02T19:11:44Z","type":"bugfix","status":"ok","note":"Public local replies were too free to surface internal user-analysis language and too vulnerable to prompt-echo history results; interaction memory scaffolding is now slimmer/private and local history tool results filter echoed current asks before the model sees them."}
{"ts":"2026-05-02T20:25:00+01:00","type":"architecture","status":"ok","note":"Ephemeral room-reading now comes from a separate `think=false` local Ollama sidecar pass over recent room context instead of hand-built phrase heuristics, and the inferred reads are stored as audit events so they can later be aggregated into richer profiles without immediately hardening every passing vibe into durable identity truth."}

View file

@ -62,6 +62,7 @@ accepted_design:
- Bot, worker, providers, and RAG remain the main structural seams.
- State storage stays intentionally split: Postgres for operational state, Qdrant for vectors, `.voidbot/` for archives, artifacts, logs, and backup material.
- `owner_codex` and `local_llm` remain distinct lanes with different tool-execution and safety posture.
- Ephemeral room-reading should come from an explicit local-LLM sidecar inference pass plus raw recent-message evidence, not from a rules bucket pretending to understand natural language.
- The VoidBot MCP server is the preferred search plane for archived Discord, indexed GameCult repos, Aetheria lore, and owner notifications.
- Deep agent-state, personality, culture, and persistent-world design work now belongs to the sibling Ghostlight repo; VoidBot should consume that work instead of remaining its long-term home.
- Long-running work should expose durable logs or status files instead of relying on one attached session and hope.
@ -82,6 +83,7 @@ landed_baseline:
- The Discord bot lane is now split into a gateway shell plus dedicated command-handler and support helper modules instead of one swollen entrypoint file.
- The worker MCP surface is now split into a thin stdio entrypoint plus dedicated bootstrap, resource, tool, Discord-notify, and shared-helper modules instead of one swollen server file.
- Interaction-memory logic is now split into event/tone analysis and profile-synthesis modules instead of one slab trying to be both the ear and the therapist.
- Situational social reads now come from a quick `think=false` local Ollama sidecar pass over the prompt plus recent room transcript, and those inferred reads are stored as audit events so they can be aggregated later without immediately hardening into profile truth.
- State storage is now split into a thin factory plus per-domain Postgres/file store modules and bootstrap/migration helpers instead of one persistence omnibus.
- Source archive storage is now sharded by repo behind a manifest plus per-repo shard directory instead of one giant `source-documents.json` blob.
- Source repo indexing now reconciles automatically during stack startup and watchdog runs, refreshing hooks, indexing newly discovered repos, and pruning stale repo shards when repos disappear from `SOURCE_REPO_ROOT`.