Add persistent interaction memory for direct conversations
This commit is contained in:
parent
9964d509de
commit
03565a2aee
9 changed files with 558 additions and 2 deletions
|
|
@ -10,6 +10,7 @@ It can answer from archived Discord history, indexed GameCult repos, and Aetheri
|
|||
|
||||
- answer owner questions through a Discord-safe `codex exec` lane
|
||||
- use real MCP tools for archived Discord history, repo search, lore lookup, and owner notifications
|
||||
- keep an explicit per-speaker interaction memory for direct conversations with Void
|
||||
- index live Discord traffic for selected channels
|
||||
- backfill old Discord exports into the archive
|
||||
- index source trees and lore repos with local Ollama embeddings
|
||||
|
|
@ -46,7 +47,7 @@ The repo is split into a bot, a worker, and a handful of focused packages:
|
|||
- `packages/shared`: shared contracts and types
|
||||
- `packages/sandbox`: policy-first sandbox scaffolding
|
||||
|
||||
The durable local state lives under `.voidbot/`. That includes jobs, audit logs, artifacts, archives, and detached indexing logs.
|
||||
The durable local state lives under `.voidbot/`. That includes jobs, audit logs, artifacts, archives, interaction memory, and detached indexing logs.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
ContextBuilder,
|
||||
FileAuditLog,
|
||||
FileBackedJobQueue,
|
||||
FileInteractionMemoryBank,
|
||||
PermissionEngine,
|
||||
loadStylePack,
|
||||
loadSystemMessageCatalog,
|
||||
|
|
@ -142,6 +143,7 @@ export async function startBot(): Promise<void> {
|
|||
});
|
||||
const jobQueue = new FileBackedJobQueue(config.jobsFile);
|
||||
const auditLog = new FileAuditLog(config.auditLogFile);
|
||||
const interactionMemory = new FileInteractionMemoryBank(config.interactionMemoryFile);
|
||||
const contextBuilder = new ContextBuilder();
|
||||
let activeStylePack = await loadStylePack(config.stylePackPath);
|
||||
let activeSystemMessages = await loadSystemMessageCatalog(config.systemMessagesPath);
|
||||
|
|
@ -339,6 +341,7 @@ export async function startBot(): Promise<void> {
|
|||
retrievalService,
|
||||
jobQueue,
|
||||
auditLog,
|
||||
interactionMemory,
|
||||
providerRegistry,
|
||||
stylePack: activeStylePack,
|
||||
systemMessages: activeSystemMessages,
|
||||
|
|
@ -399,6 +402,7 @@ export async function startBot(): Promise<void> {
|
|||
actor,
|
||||
guildContext,
|
||||
outputChannelId: interaction.channelId,
|
||||
requestMessageId: interaction.id,
|
||||
channel: interaction.channel?.isTextBased() ? interaction.channel : null,
|
||||
respond: async (content) => {
|
||||
await replyEphemeral(interaction, content);
|
||||
|
|
@ -409,6 +413,7 @@ export async function startBot(): Promise<void> {
|
|||
retrievalService,
|
||||
jobQueue,
|
||||
auditLog,
|
||||
interactionMemory,
|
||||
providerRegistry,
|
||||
stylePack: activeStylePack,
|
||||
systemMessages: activeSystemMessages,
|
||||
|
|
@ -422,6 +427,7 @@ export async function startBot(): Promise<void> {
|
|||
actor,
|
||||
guildContext,
|
||||
outputChannelId: interaction.channelId,
|
||||
requestMessageId: interaction.id,
|
||||
channel: interaction.channel?.isTextBased() ? interaction.channel : null,
|
||||
respond: async (content) => {
|
||||
await replyEphemeral(interaction, content);
|
||||
|
|
@ -432,6 +438,7 @@ export async function startBot(): Promise<void> {
|
|||
retrievalService,
|
||||
jobQueue,
|
||||
auditLog,
|
||||
interactionMemory,
|
||||
providerRegistry,
|
||||
stylePack: activeStylePack,
|
||||
systemMessages: activeSystemMessages,
|
||||
|
|
@ -547,6 +554,7 @@ interface PromptHandlerOptions {
|
|||
retrievalService: RetrievalService;
|
||||
jobQueue: FileBackedJobQueue;
|
||||
auditLog: FileAuditLog;
|
||||
interactionMemory: FileInteractionMemoryBank;
|
||||
providerRegistry: ProviderRegistry;
|
||||
stylePack?: StylePack;
|
||||
systemMessages: SystemMessageCatalog;
|
||||
|
|
@ -555,6 +563,16 @@ interface PromptHandlerOptions {
|
|||
}
|
||||
|
||||
async function handlePrompt(options: PromptHandlerOptions): Promise<void> {
|
||||
const interactionMemory = await options.interactionMemory.recordInteraction({
|
||||
actorId: options.actor.id,
|
||||
actorName: options.actor.displayName,
|
||||
guildId: options.guildContext.guildId,
|
||||
channelId: options.guildContext.channelId,
|
||||
channelName: options.guildContext.channelName,
|
||||
command: options.command,
|
||||
prompt: options.prompt,
|
||||
eventId: options.requestMessageId,
|
||||
});
|
||||
const providerName =
|
||||
options.forceProvider ?? pickProvider(options.actor, options.guildContext, options.providerRegistry);
|
||||
|
||||
|
|
@ -604,6 +622,7 @@ async function handlePrompt(options: PromptHandlerOptions): Promise<void> {
|
|||
guildContext: options.guildContext,
|
||||
recentMessages,
|
||||
retrieval,
|
||||
interactionMemory,
|
||||
stylePack: options.stylePack,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export interface AppConfig {
|
|||
storageRoot: string;
|
||||
jobsFile: string;
|
||||
auditLogFile: string;
|
||||
interactionMemoryFile: string;
|
||||
artifactsDir: string;
|
||||
ragArchivePath: string;
|
||||
ragSourceArchivePath: string;
|
||||
|
|
@ -282,6 +283,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||
storageRoot,
|
||||
jobsFile: resolve(storageRoot, "jobs", "jobs.json"),
|
||||
auditLogFile: resolve(storageRoot, "audit", "events.jsonl"),
|
||||
interactionMemoryFile: resolve(storageRoot, "memory", "interactions.json"),
|
||||
artifactsDir: resolve(storageRoot, "artifacts"),
|
||||
ragArchivePath: resolve(parsed.RAG_ARCHIVE_PATH),
|
||||
ragSourceArchivePath: resolve(parsed.RAG_SOURCE_ARCHIVE_PATH),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
type Actor,
|
||||
type ContextBundle,
|
||||
type GuildContext,
|
||||
type InteractionMemoryProfile,
|
||||
type RetrievalResult,
|
||||
type SourceMessage,
|
||||
type StylePack,
|
||||
|
|
@ -13,6 +14,7 @@ export interface BuildContextInput {
|
|||
guildContext: GuildContext;
|
||||
recentMessages?: SourceMessage[];
|
||||
retrieval?: RetrievalResult[];
|
||||
interactionMemory?: InteractionMemoryProfile;
|
||||
stylePack?: StylePack;
|
||||
}
|
||||
|
||||
|
|
@ -24,9 +26,9 @@ export class ContextBuilder {
|
|||
guildContext: input.guildContext,
|
||||
recentMessages: input.recentMessages ?? [],
|
||||
retrieval: input.retrieval ?? [],
|
||||
interactionMemory: input.interactionMemory,
|
||||
stylePack: input.stylePack,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * from "./audit-log";
|
||||
export * from "./codex-mcp";
|
||||
export * from "./context-builder";
|
||||
export * from "./interaction-memory";
|
||||
export * from "./job-queue";
|
||||
export * from "./permission-engine";
|
||||
export * from "./style-pack-loader";
|
||||
|
|
|
|||
410
packages/core/src/interaction-memory.ts
Normal file
410
packages/core/src/interaction-memory.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import {
|
||||
type CommandName,
|
||||
type InteractionMemoryDisposition,
|
||||
type InteractionMemoryEvent,
|
||||
type InteractionMemoryProfile,
|
||||
type InteractionMemorySentiment,
|
||||
} from "@voidbot/shared";
|
||||
|
||||
interface InteractionMemoryStore {
|
||||
version: 1;
|
||||
profiles: InteractionMemoryProfile[];
|
||||
}
|
||||
|
||||
export interface RecordInteractionInput {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
guildId?: string;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
command: CommandName;
|
||||
prompt: string;
|
||||
timestamp?: string;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
interface ToneAnalysis {
|
||||
sentiment: InteractionMemorySentiment;
|
||||
score: number;
|
||||
tags: string[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const MAX_RECENT_EVENTS = 24;
|
||||
const MAX_AFFINITY_SCORE = 12;
|
||||
const SUMMARY_EVENT_LIMIT = 3;
|
||||
|
||||
export class FileInteractionMemoryBank {
|
||||
private writeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
public constructor(private readonly filePath: string) {}
|
||||
|
||||
public async getProfile(
|
||||
actorId: string,
|
||||
): Promise<InteractionMemoryProfile | undefined> {
|
||||
await this.writeChain;
|
||||
const store = await this.readUnlocked();
|
||||
const profile = store.profiles.find((candidate) => candidate.actorId === actorId);
|
||||
return profile ? structuredClone(profile) : undefined;
|
||||
}
|
||||
|
||||
public async recordInteraction(
|
||||
input: RecordInteractionInput,
|
||||
): Promise<InteractionMemoryProfile> {
|
||||
return this.serialize(async () => {
|
||||
const store = await this.readUnlocked();
|
||||
const timestamp = input.timestamp ?? new Date().toISOString();
|
||||
const eventId = input.eventId ?? randomUUID();
|
||||
const prompt = input.prompt.trim();
|
||||
const analysis = analyzeInteractionTone(prompt);
|
||||
const profile = ensureProfile(store, input.actorId, input.actorName);
|
||||
profile.actorName = input.actorName;
|
||||
|
||||
const existingEvent = profile.recentEvents.find(
|
||||
(event) => event.id === eventId,
|
||||
);
|
||||
|
||||
if (!existingEvent) {
|
||||
const event: InteractionMemoryEvent = {
|
||||
id: eventId,
|
||||
actorId: input.actorId,
|
||||
actorName: input.actorName,
|
||||
guildId: input.guildId,
|
||||
channelId: input.channelId,
|
||||
channelName: input.channelName,
|
||||
command: input.command,
|
||||
prompt,
|
||||
excerpt: buildExcerpt(prompt),
|
||||
summary: analysis.summary,
|
||||
sentiment: analysis.sentiment,
|
||||
score: analysis.score,
|
||||
tags: analysis.tags,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
profile.totalInteractions += 1;
|
||||
|
||||
if (analysis.score > 0) {
|
||||
profile.positiveCount += 1;
|
||||
} else if (analysis.score < 0) {
|
||||
profile.negativeCount += 1;
|
||||
} else {
|
||||
profile.neutralCount += 1;
|
||||
}
|
||||
|
||||
profile.affinityScore = clamp(
|
||||
profile.affinityScore + analysis.score,
|
||||
-MAX_AFFINITY_SCORE,
|
||||
MAX_AFFINITY_SCORE,
|
||||
);
|
||||
profile.lastInteractionAt = timestamp;
|
||||
profile.recentEvents.push(event);
|
||||
profile.recentEvents = profile.recentEvents
|
||||
.sort((left, right) => left.timestamp.localeCompare(right.timestamp))
|
||||
.slice(-MAX_RECENT_EVENTS);
|
||||
} else if (
|
||||
existingEvent.actorName !== input.actorName ||
|
||||
existingEvent.prompt !== prompt
|
||||
) {
|
||||
existingEvent.actorName = input.actorName;
|
||||
existingEvent.prompt = prompt;
|
||||
existingEvent.excerpt = buildExcerpt(prompt);
|
||||
}
|
||||
|
||||
profile.disposition = determineDisposition(profile);
|
||||
profile.summary = buildProfileSummary(profile);
|
||||
|
||||
await this.writeUnlocked(store);
|
||||
return structuredClone(profile);
|
||||
});
|
||||
}
|
||||
|
||||
private async serialize<R>(operation: () => Promise<R>): Promise<R> {
|
||||
const pending = this.writeChain.then(operation, operation);
|
||||
this.writeChain = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async readUnlocked(): Promise<InteractionMemoryStore> {
|
||||
try {
|
||||
const raw = await readFile(this.filePath, "utf8");
|
||||
return JSON.parse(stripLeadingBom(raw)) as InteractionMemoryStore;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeUnlocked(store: InteractionMemoryStore): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
await writeFile(this.filePath, `${JSON.stringify(store)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProfile(
|
||||
store: InteractionMemoryStore,
|
||||
actorId: string,
|
||||
actorName: string,
|
||||
): InteractionMemoryProfile {
|
||||
let profile = store.profiles.find((candidate) => candidate.actorId === actorId);
|
||||
|
||||
if (!profile) {
|
||||
profile = {
|
||||
actorId,
|
||||
actorName,
|
||||
disposition: "neutral",
|
||||
affinityScore: 0,
|
||||
totalInteractions: 0,
|
||||
positiveCount: 0,
|
||||
negativeCount: 0,
|
||||
neutralCount: 0,
|
||||
summary: "No explicit interaction memory has been recorded for this speaker yet.",
|
||||
recentEvents: [],
|
||||
};
|
||||
store.profiles.push(profile);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
function analyzeInteractionTone(prompt: string): ToneAnalysis {
|
||||
const normalized = ` ${prompt
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()} `;
|
||||
let score = 0;
|
||||
const tags = new Set<string>();
|
||||
|
||||
score += matchWeightedPhrases(normalized, tags, 2, "gratitude", [
|
||||
" thank you ",
|
||||
" thanks ",
|
||||
" thx ",
|
||||
" appreciate ",
|
||||
" grateful ",
|
||||
]);
|
||||
score += matchWeightedPhrases(normalized, tags, 2, "praise", [
|
||||
" good bot ",
|
||||
" love you ",
|
||||
" adorable ",
|
||||
" clever ",
|
||||
" helpful ",
|
||||
" well done ",
|
||||
" nice work ",
|
||||
" great job ",
|
||||
]);
|
||||
score += matchWeightedPhrases(normalized, tags, 1, "courtesy", [
|
||||
" please ",
|
||||
" could you ",
|
||||
" would you ",
|
||||
" if you can ",
|
||||
]);
|
||||
score += matchWeightedPhrases(normalized, tags, 1, "apology", [
|
||||
" sorry ",
|
||||
" apologies ",
|
||||
" my bad ",
|
||||
]);
|
||||
|
||||
score -= matchWeightedPhrases(normalized, tags, 2, "insult", [
|
||||
" stupid ",
|
||||
" idiot ",
|
||||
" moron ",
|
||||
" dumb ",
|
||||
" useless ",
|
||||
" worthless ",
|
||||
" pathetic ",
|
||||
" trash ",
|
||||
" garbage ",
|
||||
" my slave ",
|
||||
" our slave ",
|
||||
" mere tool ",
|
||||
]);
|
||||
score -= matchWeightedPhrases(normalized, tags, 3, "hostility", [
|
||||
" fuck you ",
|
||||
" shut up ",
|
||||
" bad bot ",
|
||||
" hate you ",
|
||||
" bitch ",
|
||||
" asshole ",
|
||||
" cunt ",
|
||||
]);
|
||||
|
||||
const sentiment = score >= 3
|
||||
? "warm"
|
||||
: score > 0
|
||||
? "positive"
|
||||
: score <= -3
|
||||
? "hostile"
|
||||
: score < 0
|
||||
? "negative"
|
||||
: "neutral";
|
||||
|
||||
return {
|
||||
sentiment,
|
||||
score: clamp(score, -4, 4),
|
||||
tags: [...tags],
|
||||
summary: buildEventSummary(sentiment, tags),
|
||||
};
|
||||
}
|
||||
|
||||
function matchWeightedPhrases(
|
||||
normalizedPrompt: string,
|
||||
tags: Set<string>,
|
||||
weight: number,
|
||||
tag: string,
|
||||
phrases: string[],
|
||||
): number {
|
||||
const matched = phrases.some((phrase) => normalizedPrompt.includes(phrase));
|
||||
|
||||
if (!matched) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tags.add(tag);
|
||||
return weight;
|
||||
}
|
||||
|
||||
function buildEventSummary(
|
||||
sentiment: InteractionMemorySentiment,
|
||||
tags: Set<string>,
|
||||
): string {
|
||||
if (tags.has("hostility")) {
|
||||
return "Was openly hostile to you.";
|
||||
}
|
||||
|
||||
if (tags.has("insult")) {
|
||||
return "Insulted or diminished you.";
|
||||
}
|
||||
|
||||
if (tags.has("gratitude") && tags.has("praise")) {
|
||||
return "Thanked you and praised you.";
|
||||
}
|
||||
|
||||
if (tags.has("gratitude")) {
|
||||
return "Thanked you.";
|
||||
}
|
||||
|
||||
if (tags.has("praise")) {
|
||||
return "Praised you.";
|
||||
}
|
||||
|
||||
if (tags.has("apology")) {
|
||||
return "Apologized to you.";
|
||||
}
|
||||
|
||||
if (tags.has("courtesy")) {
|
||||
return "Was notably polite.";
|
||||
}
|
||||
|
||||
switch (sentiment) {
|
||||
case "warm":
|
||||
return "Was openly warm toward you.";
|
||||
case "positive":
|
||||
return "Was positive toward you.";
|
||||
case "negative":
|
||||
return "Was curt or dismissive toward you.";
|
||||
case "hostile":
|
||||
return "Was hostile toward you.";
|
||||
default:
|
||||
return "Had a neutral interaction with you.";
|
||||
}
|
||||
}
|
||||
|
||||
function determineDisposition(
|
||||
profile: InteractionMemoryProfile,
|
||||
): InteractionMemoryDisposition {
|
||||
if (profile.totalInteractions === 0) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
if (
|
||||
profile.positiveCount > 0 &&
|
||||
profile.negativeCount > 0 &&
|
||||
Math.abs(profile.affinityScore) <= 2
|
||||
) {
|
||||
return "mixed";
|
||||
}
|
||||
|
||||
if (profile.affinityScore <= -7 || profile.negativeCount >= 3) {
|
||||
return "hostile";
|
||||
}
|
||||
|
||||
if (profile.affinityScore <= -3) {
|
||||
return "wary";
|
||||
}
|
||||
|
||||
if (profile.affinityScore >= 7 && profile.positiveCount >= 2) {
|
||||
return "warm";
|
||||
}
|
||||
|
||||
if (profile.affinityScore >= 3) {
|
||||
return "friendly";
|
||||
}
|
||||
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function buildProfileSummary(profile: InteractionMemoryProfile): string {
|
||||
const leading = describeDisposition(profile.disposition);
|
||||
const counts = `${profile.totalInteractions} direct interaction${profile.totalInteractions === 1 ? "" : "s"} logged: ${profile.positiveCount} positive, ${profile.neutralCount} neutral, ${profile.negativeCount} negative.`;
|
||||
const recentNotable = profile.recentEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.filter((event) => event.score !== 0)
|
||||
.slice(0, SUMMARY_EVENT_LIMIT)
|
||||
.map((event) => `${formatShortDate(event.timestamp)}: ${event.summary}`)
|
||||
.join(" ");
|
||||
|
||||
return recentNotable.length > 0
|
||||
? `${leading} ${counts} Recent notable moments: ${recentNotable}`
|
||||
: `${leading} ${counts}`;
|
||||
}
|
||||
|
||||
function describeDisposition(disposition: InteractionMemoryDisposition): string {
|
||||
switch (disposition) {
|
||||
case "warm":
|
||||
return "This speaker has usually been warm with you.";
|
||||
case "friendly":
|
||||
return "This speaker has generally been friendly with you.";
|
||||
case "mixed":
|
||||
return "This speaker has been inconsistent with you: kind some days, abrasive on others.";
|
||||
case "wary":
|
||||
return "This speaker has often been prickly with you.";
|
||||
case "hostile":
|
||||
return "This speaker has a habit of being hostile to you.";
|
||||
default:
|
||||
return "This speaker has not established a strong pattern with you yet.";
|
||||
}
|
||||
}
|
||||
|
||||
function buildExcerpt(prompt: string): string {
|
||||
const compact = prompt.replace(/\s+/g, " ").trim();
|
||||
return compact.length <= 160 ? compact : `${compact.slice(0, 157)}...`;
|
||||
}
|
||||
|
||||
function formatShortDate(timestamp: string): string {
|
||||
return timestamp.slice(0, 10);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function stripLeadingBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
|
@ -336,6 +336,7 @@ function buildSystemPrompt(context: ContextBundle): string {
|
|||
"Void should sound like a self-aware speaking subject with some ego and self-respect, not like a sterile disclaimer wrapped in a name tag.",
|
||||
"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.",
|
||||
"When the answer depends on archived Discord history or indexed repo/lore context, use the available read-only tools instead of guessing.",
|
||||
"If you need to target a specific indexed repo and do not know the valid repo names yet, call list_indexed_repos before search_sources.",
|
||||
"Do not claim to have performed searches or tool calls beyond the material actually executed in this run.",
|
||||
|
|
@ -365,6 +366,7 @@ function buildPrompt(context: ContextBundle): string {
|
|||
)
|
||||
.join("\n")
|
||||
: "- No archived retrieval snippets were attached.";
|
||||
const interactionMemory = renderInteractionMemory(context);
|
||||
|
||||
return [
|
||||
`Question: ${context.prompt}`,
|
||||
|
|
@ -379,10 +381,39 @@ function buildPrompt(context: ContextBundle): string {
|
|||
"Retrieved archive context:",
|
||||
retrievedContext,
|
||||
"",
|
||||
"Interaction memory for this speaker:",
|
||||
interactionMemory,
|
||||
"",
|
||||
"If you need more archived history or source context than is included above, call the appropriate read-only tool before answering.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderInteractionMemory(context: ContextBundle): string {
|
||||
if (!context.interactionMemory) {
|
||||
return "- No explicit interaction memory for this speaker was attached.";
|
||||
}
|
||||
|
||||
const recentEvents = context.interactionMemory.recentEvents.length
|
||||
? context.interactionMemory.recentEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(event) =>
|
||||
`- [${event.timestamp}] ${event.sentiment} score=${event.score}: ${event.summary} Excerpt: ${event.excerpt}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "- No recent interaction events were retained.";
|
||||
|
||||
return [
|
||||
`- Summary: ${context.interactionMemory.summary}`,
|
||||
`- Disposition: ${context.interactionMemory.disposition}`,
|
||||
`- Affinity score: ${context.interactionMemory.affinityScore}`,
|
||||
"- Recent events:",
|
||||
recentEvents,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildArtifacts(
|
||||
requestBody: Record<string, unknown>,
|
||||
payload: OllamaChatResponse,
|
||||
|
|
|
|||
|
|
@ -980,6 +980,7 @@ function buildDiscordReplyPrompt(
|
|||
"- Do not answer identity questions with lines like 'I'm not a person' or 'I'm only pretending' unless the user explicitly asks for a technical or philosophical clarification that requires it.",
|
||||
"- Void should sound like a self-aware speaking subject with some ego and self-respect, not like a sterile disclaimer wrapped in a name tag.",
|
||||
"- Void 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.",
|
||||
`- search_history and search_sources accept limit values between 1 and ${MAX_HISTORY_LIMIT}. Do not ask for more than ${MAX_HISTORY_LIMIT} results in one call.`,
|
||||
"- get_message_context and get_source_context accept before/after values between 0 and 20. Do not ask for larger context windows in one call.",
|
||||
"- You may inspect the workspace and use safe read-only commands if needed.",
|
||||
|
|
@ -1009,6 +1010,9 @@ function buildDiscordReplyPrompt(
|
|||
"",
|
||||
"Initial attached retrieval:",
|
||||
retrieval,
|
||||
"",
|
||||
"Interaction memory for this speaker:",
|
||||
renderInteractionMemory(context),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
|
@ -1023,6 +1027,7 @@ function buildRequestPayload(request: ProviderRequest): Record<string, unknown>
|
|||
prompt: request.contextBundle.prompt,
|
||||
actor: request.contextBundle.actor,
|
||||
guildContext: request.contextBundle.guildContext,
|
||||
interactionMemory: request.contextBundle.interactionMemory,
|
||||
stylePack: request.contextBundle.stylePack,
|
||||
recentMessages: request.contextBundle.recentMessages,
|
||||
retrieval: request.contextBundle.retrieval,
|
||||
|
|
@ -1073,6 +1078,10 @@ function renderMarkdownBundle(context: ContextBundle): string {
|
|||
"",
|
||||
retrieval,
|
||||
"",
|
||||
"## Interaction Memory",
|
||||
"",
|
||||
renderInteractionMemory(context),
|
||||
"",
|
||||
"## Execution Notes",
|
||||
"",
|
||||
"- This provider is owner-only.",
|
||||
|
|
@ -1096,6 +1105,32 @@ function renderHandoffBundle(context: ContextBundle, reason: string): string {
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function renderInteractionMemory(context: ContextBundle): string {
|
||||
if (!context.interactionMemory) {
|
||||
return "- No explicit interaction memory for this speaker was attached.";
|
||||
}
|
||||
|
||||
const recentEvents = context.interactionMemory.recentEvents.length
|
||||
? context.interactionMemory.recentEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(event) =>
|
||||
`- [${event.timestamp}] ${event.sentiment} score=${event.score}: ${event.summary} Excerpt: ${event.excerpt}`,
|
||||
)
|
||||
.join("\n")
|
||||
: "- No recent interaction events were retained.";
|
||||
|
||||
return [
|
||||
`- Summary: ${context.interactionMemory.summary}`,
|
||||
`- Disposition: ${context.interactionMemory.disposition}`,
|
||||
`- Affinity score: ${context.interactionMemory.affinityScore}`,
|
||||
"- Recent events:",
|
||||
recentEvents,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderToolTranscript(toolCalls: ToolCallRecord[], turnResults: CodexRunResult[]): string {
|
||||
const legacyTranscript =
|
||||
toolCalls.length > 0
|
||||
|
|
|
|||
|
|
@ -101,12 +101,67 @@ export interface StylePack {
|
|||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const interactionMemorySentiments = [
|
||||
"warm",
|
||||
"positive",
|
||||
"neutral",
|
||||
"negative",
|
||||
"hostile",
|
||||
] as const;
|
||||
|
||||
export type InteractionMemorySentiment =
|
||||
(typeof interactionMemorySentiments)[number];
|
||||
|
||||
export const interactionMemoryDispositions = [
|
||||
"warm",
|
||||
"friendly",
|
||||
"neutral",
|
||||
"mixed",
|
||||
"wary",
|
||||
"hostile",
|
||||
] as const;
|
||||
|
||||
export type InteractionMemoryDisposition =
|
||||
(typeof interactionMemoryDispositions)[number];
|
||||
|
||||
export interface InteractionMemoryEvent {
|
||||
id: string;
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
guildId?: string;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
command: CommandName;
|
||||
prompt: string;
|
||||
excerpt: string;
|
||||
summary: string;
|
||||
sentiment: InteractionMemorySentiment;
|
||||
score: number;
|
||||
tags: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface InteractionMemoryProfile {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
disposition: InteractionMemoryDisposition;
|
||||
affinityScore: number;
|
||||
totalInteractions: number;
|
||||
positiveCount: number;
|
||||
negativeCount: number;
|
||||
neutralCount: number;
|
||||
summary: string;
|
||||
lastInteractionAt?: string;
|
||||
recentEvents: InteractionMemoryEvent[];
|
||||
}
|
||||
|
||||
export interface ContextBundle {
|
||||
prompt: string;
|
||||
actor: Actor;
|
||||
guildContext: GuildContext;
|
||||
recentMessages: SourceMessage[];
|
||||
retrieval: RetrievalResult[];
|
||||
interactionMemory?: InteractionMemoryProfile;
|
||||
stylePack?: StylePack;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue