joel memories

This commit is contained in:
2026-02-01 17:55:21 +01:00
parent c13ffc93c0
commit 0c0efa645a
22 changed files with 2463 additions and 304 deletions

View File

@@ -5,6 +5,7 @@
import { createLogger } from "../../core/logger";
import { OpenRouterProvider } from "./openrouter";
import type { AiProvider, AiResponse, MessageStyle } from "./types";
import type { ToolContext } from "./tools";
const logger = createLogger("AI:Service");
@@ -27,6 +28,23 @@ export class AiService {
return this.provider.ask({ prompt, systemPrompt });
}
/**
* Generate a response with tool calling support
* The AI can look up memories, save new ones, etc.
*/
async generateResponseWithTools(
prompt: string,
systemPrompt: string,
context: ToolContext
): Promise<AiResponse> {
if (this.provider.askWithTools) {
logger.debug("Generating response with tools", { promptLength: prompt.length });
return this.provider.askWithTools({ prompt, systemPrompt, context });
}
// Fallback to regular response if tools not supported
return this.generateResponse(prompt, systemPrompt);
}
/**
* Classify a message to determine the appropriate response style
*/
@@ -37,6 +55,19 @@ export class AiService {
// Default to snarky if provider doesn't support classification
return "snarky";
}
/**
* Extract and save memorable information from a message
*/
async extractMemories(
message: string,
authorName: string,
context: ToolContext
): Promise<void> {
if (this.provider.extractMemories) {
return this.provider.extractMemories(message, authorName, context);
}
}
}
// Singleton instance
@@ -50,3 +81,5 @@ export function getAiService(): AiService {
}
export type { AiProvider, AiResponse, MessageStyle } from "./types";
export type { ToolContext, ToolCall, ToolResult } from "./tools";
export { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS } from "./tools";

View File

@@ -3,15 +3,21 @@
*/
import OpenAI from "openai";
import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions";
import { config } from "../../core/config";
import { createLogger } from "../../core/logger";
import type { AiProvider, AiResponse, AskOptions, MessageStyle } from "./types";
import type { AiProvider, AiResponse, AskOptions, AskWithToolsOptions, MessageStyle } from "./types";
import { JOEL_TOOLS, MEMORY_EXTRACTION_TOOLS, type ToolCall, type ToolContext } from "./tools";
import { executeTools } from "./tool-handlers";
const logger = createLogger("AI:OpenRouter");
// Style classification options
const STYLE_OPTIONS: MessageStyle[] = ["story", "snarky", "insult", "explicit", "helpful"];
// Maximum tool call iterations to prevent infinite loops
const MAX_TOOL_ITERATIONS = 5;
export class OpenRouterProvider implements AiProvider {
private client: OpenAI;
@@ -61,6 +67,148 @@ export class OpenRouterProvider implements AiProvider {
}
}
/**
* Generate a response with tool calling support
* The AI can call tools (like looking up memories) during response generation
*/
async askWithTools(options: AskWithToolsOptions): Promise<AiResponse> {
const { prompt, systemPrompt, context, maxTokens, temperature } = options;
const messages: ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt },
];
let iterations = 0;
while (iterations < MAX_TOOL_ITERATIONS) {
iterations++;
try {
const completion = await this.client.chat.completions.create({
model: config.ai.model,
messages,
tools: JOEL_TOOLS,
tool_choice: "auto",
max_tokens: maxTokens ?? config.ai.maxTokens,
temperature: temperature ?? config.ai.temperature,
});
const choice = completion.choices[0];
const message = choice?.message;
if (!message) {
logger.warn("No message in completion");
return { text: "" };
}
// Check if the AI wants to call tools
if (message.tool_calls && message.tool_calls.length > 0) {
logger.debug("AI requested tool calls", {
count: message.tool_calls.length,
tools: message.tool_calls.map(tc => tc.function.name)
});
// Add the assistant's message with tool calls
messages.push(message);
// Parse and execute tool calls
const toolCalls: ToolCall[] = message.tool_calls.map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || "{}"),
}));
const results = await executeTools(toolCalls, context);
// Add tool results as messages
for (let i = 0; i < toolCalls.length; i++) {
messages.push({
role: "tool",
tool_call_id: toolCalls[i].id,
content: results[i].result,
});
}
// Continue the loop to get the AI's response after tool execution
continue;
}
// No tool calls - we have a final response
const text = message.content ?? "";
logger.debug("AI response generated", {
iterations,
textLength: text.length
});
return { text: text.slice(0, 1900) };
} catch (error: unknown) {
logger.error("Failed to generate response with tools", error);
throw error;
}
}
logger.warn("Max tool iterations reached");
return { text: "I got stuck in a loop thinking about that..." };
}
/**
* Analyze a message to extract memorable information
*/
async extractMemories(
message: string,
authorName: string,
context: ToolContext
): Promise<void> {
const systemPrompt = `You are analyzing a Discord message to determine if it contains any memorable or useful information about the user "${authorName}".
Look for:
- Personal information (name, age, location, job, hobbies)
- Preferences (likes, dislikes, favorites)
- Embarrassing admissions or confessions
- Strong opinions or hot takes
- Achievements or accomplishments
- Relationships or social information
- Recurring patterns or habits
If you find something worth remembering, use the extract_memory tool. Only extract genuinely interesting or useful information - don't save trivial things.
The user's Discord ID is: ${context.userId}`;
try {
const completion = await this.client.chat.completions.create({
model: config.ai.classificationModel,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Analyze this message for memorable content:\n\n"${message}"` },
],
tools: MEMORY_EXTRACTION_TOOLS,
tool_choice: "auto",
max_tokens: 200,
temperature: 0.3,
});
const toolCalls = completion.choices[0]?.message?.tool_calls;
if (toolCalls && toolCalls.length > 0) {
const parsedCalls: ToolCall[] = toolCalls.map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments || "{}"),
}));
await executeTools(parsedCalls, context);
logger.debug("Memory extraction complete", {
extracted: parsedCalls.length,
authorName
});
}
} catch (error) {
// Don't throw - memory extraction is non-critical
logger.error("Memory extraction failed", error);
}
}
/**
* Classify a message to determine the appropriate response style
*/

View File

@@ -0,0 +1,233 @@
/**
* Tool handler implementations
* Executes the actual logic when the AI calls a tool
*/
import { createLogger } from "../../core/logger";
import { memoryRepository, type MemoryCategory } from "../../database";
import type { ToolHandler, ToolContext, ToolCall, ToolResult } from "./tools";
const logger = createLogger("AI:ToolHandlers");
/**
* Registry of tool handlers
*/
const toolHandlers: Record<string, ToolHandler> = {
/**
* Look up memories about a specific user
*/
async lookup_user_memories(args, context): Promise<string> {
const userId = (args.user_id as string) || context.userId;
const limit = (args.limit as number) || 10;
const category = args.category as MemoryCategory | undefined;
logger.debug("Looking up memories", { userId, limit, category });
let userMemories;
if (category) {
userMemories = await memoryRepository.findByCategory(userId, category, limit);
} else {
userMemories = await memoryRepository.findByUserId(userId, limit);
}
if (userMemories.length === 0) {
return `No memories found for this user. You don't know anything about them yet.`;
}
const memoryList = userMemories
.map((m, i) => {
const cat = m.category || "general";
const imp = m.importance || 5;
return `${i + 1}. [${cat}|★${imp}] ${m.content}`;
})
.join("\n");
return `Found ${userMemories.length} memories about this user:\n${memoryList}`;
},
/**
* Save a new memory about a user
*/
async save_memory(args, context): Promise<string> {
const userId = (args.user_id as string) || context.userId;
const content = args.content as string;
const category = (args.category as MemoryCategory) || "general";
const importance = (args.importance as number) || 5;
if (!content || content.trim().length === 0) {
return "Error: No content provided to remember.";
}
// Check for duplicate memories using new similarity check
const similar = await memoryRepository.findSimilar(userId, content);
if (similar.length > 0) {
return "Already knew something similar. Memory not saved (duplicate).";
}
logger.info("Saving new memory", {
userId,
category,
importance,
contentLength: content.length
});
await memoryRepository.create({
userId,
guildId: context.guildId,
content,
category,
importance,
});
return `Memory saved [${category}|★${importance}]: "${content.slice(0, 100)}${content.length > 100 ? "..." : ""}"`;
},
/**
* Search memories by keyword/topic
*/
async search_memories(args, context): Promise<string> {
const query = args.query as string;
const guildId = args.guild_id as string | undefined;
const category = args.category as MemoryCategory | undefined;
const minImportance = args.min_importance as number | undefined;
if (!query || query.trim().length === 0) {
return "Error: No search query provided.";
}
logger.debug("Searching memories", { query, guildId, category, minImportance });
const results = await memoryRepository.search({
query,
guildId,
category,
minImportance,
limit: 15,
});
if (results.length === 0) {
return `No memories found matching "${query}".`;
}
const memoryList = results
.map((m, i) => {
const cat = m.category || "general";
const imp = m.importance || 5;
return `${i + 1}. [User ${m.user_id?.slice(0, 8)}...] [${cat}|★${imp}] ${m.content}`;
})
.join("\n");
return `Found ${results.length} memories matching "${query}":\n${memoryList}`;
},
/**
* Delete all memories about a user
*/
async forget_user(args, context): Promise<string> {
const userId = (args.user_id as string) || context.userId;
logger.warn("Forgetting user memories", { userId, requestedBy: context.userId });
const deleted = await memoryRepository.deleteByUserId(userId);
return `Deleted ${deleted} memories about user ${userId}.`;
},
/**
* Extract memory from conversation (used by memory extraction system)
*/
async extract_memory(args, context): Promise<string> {
const content = args.content as string;
const category = (args.category as MemoryCategory) || "general";
const importance = (args.importance as number) || 5;
// Only save if importance is high enough
if (importance < 5) {
return `Memory not important enough (${importance}/10). Skipped.`;
}
// Check for duplicates
const similar = await memoryRepository.findSimilar(context.userId, content);
if (similar.length > 0) {
return "Similar memory already exists. Skipped.";
}
logger.info("Extracting memory from conversation", {
userId: context.userId,
category,
importance,
});
await memoryRepository.create({
userId: context.userId,
guildId: context.guildId,
content,
category,
importance,
});
return `Memory extracted [${category}|★${importance}]: "${content.slice(0, 50)}..."`;
},
/**
* Get statistics about a user's memories
*/
async get_memory_stats(args, context): Promise<string> {
const userId = (args.user_id as string) || context.userId;
const stats = await memoryRepository.getStats(userId);
if (stats.total === 0) {
return `No memories stored for this user.`;
}
const categoryBreakdown = Object.entries(stats.byCategory)
.map(([cat, count]) => ` - ${cat}: ${count}`)
.join("\n");
return `Memory stats for user:\n` +
`Total: ${stats.total} memories\n` +
`Average importance: ${stats.avgImportance.toFixed(1)}/10\n` +
`By category:\n${categoryBreakdown}`;
},
};
/**
* Execute a tool call and return the result
*/
export async function executeTool(
toolCall: ToolCall,
context: ToolContext
): Promise<ToolResult> {
const handler = toolHandlers[toolCall.name];
if (!handler) {
logger.warn("Unknown tool called", { name: toolCall.name });
return {
name: toolCall.name,
result: `Error: Unknown tool "${toolCall.name}"`,
};
}
try {
const result = await handler(toolCall.arguments, context);
logger.debug("Tool executed", { name: toolCall.name, resultLength: result.length });
return { name: toolCall.name, result };
} catch (error) {
logger.error("Tool execution failed", { name: toolCall.name, error });
return {
name: toolCall.name,
result: `Error executing tool: ${(error as Error).message}`,
};
}
}
/**
* Execute multiple tool calls
*/
export async function executeTools(
toolCalls: ToolCall[],
context: ToolContext
): Promise<ToolResult[]> {
return Promise.all(toolCalls.map((tc) => executeTool(tc, context)));
}

199
src/services/ai/tools.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* AI Tool definitions for function calling
* These tools allow the AI to interact with the bot's systems
*/
import type { ChatCompletionTool } from "openai/resources/chat/completions";
/**
* Tool call result from execution
*/
export interface ToolResult {
name: string;
result: string;
}
/**
* Tool call request from the AI
*/
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
/**
* Context provided to tool handlers
*/
export interface ToolContext {
userId: string;
guildId: string;
channelId: string;
authorName: string;
}
/**
* Tool handler function type
*/
export type ToolHandler = (
args: Record<string, unknown>,
context: ToolContext
) => Promise<string>;
/**
* Available tools for the AI to use
*/
export const JOEL_TOOLS: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "lookup_user_memories",
description: "Look up what you remember about a specific user. Use this when you want to personalize your response with things you know about them, or when they ask if you remember something.",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "The Discord user ID to look up memories for. Use the current user's ID if looking up the person you're talking to.",
},
category: {
type: "string",
enum: ["personal", "opinion", "fact", "preference", "event", "relationship", "general"],
description: "Filter by category (optional)",
},
limit: {
type: "number",
description: "Maximum number of memories to retrieve (default: 10)",
},
},
required: ["user_id"],
},
},
},
{
type: "function",
function: {
name: "save_memory",
description: "Save something important or interesting about a user for later. Use this when someone reveals personal information, preferences, embarrassing facts, or anything you can use against them later.",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "The Discord user ID this memory is about",
},
content: {
type: "string",
description: "The fact or information to remember. Be concise but include context.",
},
category: {
type: "string",
enum: ["personal", "opinion", "fact", "preference", "event", "relationship", "general"],
description: "Category of the memory for organization",
},
importance: {
type: "number",
description: "How important this memory is from 1-10. Only memories 5+ will be saved.",
},
},
required: ["user_id", "content"],
},
},
},
{
type: "function",
function: {
name: "search_memories",
description: "Search through all memories for specific topics or keywords. Use this when you want to find if you know anything about a particular subject.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query - keywords or topics to search for",
},
guild_id: {
type: "string",
description: "Limit search to a specific server (optional)",
},
category: {
type: "string",
enum: ["personal", "opinion", "fact", "preference", "event", "relationship", "general"],
description: "Filter by category (optional)",
},
min_importance: {
type: "number",
description: "Minimum importance score to include (1-10)",
},
},
required: ["query"],
},
},
},
{
type: "function",
function: {
name: "get_memory_stats",
description: "Get statistics about memories stored for a user - total count, breakdown by category, average importance.",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "The Discord user ID to get stats for",
},
},
required: ["user_id"],
},
},
},
{
type: "function",
function: {
name: "forget_user",
description: "Delete all memories about a user. Only use if explicitly asked to forget someone.",
parameters: {
type: "object",
properties: {
user_id: {
type: "string",
description: "The Discord user ID to forget",
},
},
required: ["user_id"],
},
},
},
];
/**
* Subset of tools for memory extraction (lightweight)
*/
export const MEMORY_EXTRACTION_TOOLS: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "extract_memory",
description: "Extract and save a memorable fact from the conversation. Only call this if there's something genuinely worth remembering.",
parameters: {
type: "object",
properties: {
content: {
type: "string",
description: "The fact to remember, written in third person (e.g., 'Loves pineapple on pizza')",
},
category: {
type: "string",
enum: ["personal", "opinion", "fact", "preference", "event", "relationship", "general"],
description: "What type of information this is",
},
importance: {
type: "number",
description: "How important/memorable this is from 1-10. Be honest - mundane chat is 1-3, interesting facts are 4-6, juicy secrets are 7-10.",
},
},
required: ["content", "category", "importance"],
},
},
},
];

View File

@@ -3,6 +3,8 @@
* Allows swapping AI providers (Replicate, OpenAI, etc.) without changing business logic
*/
import type { ToolContext } from "./tools";
export interface AiResponse {
text: string;
}
@@ -18,6 +20,11 @@ export interface AiProvider {
*/
ask(options: AskOptions): Promise<AiResponse>;
/**
* Generate a response with tool calling support
*/
askWithTools?(options: AskWithToolsOptions): Promise<AiResponse>;
/**
* Check if the AI service is healthy
*/
@@ -27,6 +34,15 @@ export interface AiProvider {
* Classify a message to determine response style
*/
classifyMessage?(message: string): Promise<MessageStyle>;
/**
* Extract memorable information from a message
*/
extractMemories?(
message: string,
authorName: string,
context: ToolContext
): Promise<void>;
}
export interface AskOptions {
@@ -35,3 +51,7 @@ export interface AskOptions {
maxTokens?: number;
temperature?: number;
}
export interface AskWithToolsOptions extends AskOptions {
context: ToolContext;
}