Compare commits

...

33 Commits

Author SHA1 Message Date
95d2854548 Added a summarizaton system to help with long threads - the bot will summarize a long thread in a few sentences but keep the most recent posts as verbose, so it can keep track of what is going on longer before it starts acting weird and repeating itself 2025-08-03 22:47:25 -07:00
2430047d45 Impelmented inital memory system 2025-08-03 22:36:00 -07:00
834e415f11 added emote reaction support, better emote support in reactions, implemented llm refusal and retry logic, improved some inline documentation 2025-08-03 22:19:24 -07:00
733a41a35c revert to more "chat" based api calls 2025-08-04 00:21:10 +00:00
ed3467b213 bump version 2025-08-03 23:32:48 +00:00
0f178fcfa9 beta release conversation context 2025-08-03 23:31:56 +00:00
0bfff52fd0 Merge branch 'main' into implement-conversation-context 2025-08-03 21:25:04 +00:00
8e90e8b71e add context response type 2025-08-03 21:24:40 +00:00
566d6ae518 update types 2025-08-03 20:59:53 +00:00
2ec367f203 fix typo 2025-08-03 20:05:21 +00:00
a04cb9a6ad some abstraction and I'm gonna kill myself 2025-08-03 19:43:05 +00:00
2111a47411 update readme 2025-08-03 18:57:07 +00:00
11c1332757 add systemd service example 2025-08-03 18:56:44 +00:00
aaf4adcf06 don't reply when not addressed 2025-08-03 18:56:05 +00:00
b6ad54f40a way better responsiveness, better system prompt 2025-08-03 14:37:23 +00:00
2f3d16dbc5 slight update to input processing 2025-08-03 14:24:50 +00:00
150e2d638e add configurable ad-hoc post interval 2025-08-02 23:24:35 +00:00
0c7c176bae I don't remember 2025-08-02 22:19:13 +00:00
c3d4f1b1ff delete notification on whitelist check fail 2025-07-07 18:32:03 +00:00
57ab59d342 separation of concerns 2025-07-07 18:26:09 +00:00
71ae54930c change response visibility logic 2025-07-07 01:15:17 +00:00
3466a984ac add isComplete column to response model for better sanity checking 2025-07-06 15:09:13 +00:00
cbf6b1d3eb its called we do a little abstraction 2025-07-06 14:53:48 +00:00
e2ce397118 add instance custom emojis to responses 2025-07-06 14:39:45 +00:00
9a7cd118b3 we do a little hackering on the 4channel 2025-07-06 03:38:07 +00:00
7a60a672d4 little more error checking/correction/QOL 2025-07-06 02:58:50 +00:00
419285487a typography 2025-07-06 02:50:35 +00:00
09722507c6 housekeeping 2025-07-06 02:47:35 +00:00
41317301bf bump version, other small change 2025-07-06 02:38:40 +00:00
00a2eb63bc add domain whitelist functionality 2025-07-06 02:36:16 +00:00
6c8f779294 update type with some more annotations 2025-07-06 02:11:30 +00:00
ff5c7506ff refactor 2025-07-05 13:34:24 +00:00
5c51acc8d1 update dependencies, description 2025-07-05 12:51:00 +00:00
14 changed files with 1700 additions and 191 deletions

View File

@ -1,9 +1,13 @@
DATABASE_URL="file:../dev.db" # SQLite database relative to the ./prisma path
PLEROMA_INSTANCE_URL="https://instance.tld" # Pleroma instance full URL including scheme
PLEROMA_INSTANCE_DOMAIN="instance.tld" # used if you want to only want to respond to people from a particular instance
ONLY_LOCAL_REPLIES="true" # reply to only users locally on your instance
PLEROMA_ACCOUNT_ID="" # obtained from /api/v1/accounts/{nickname} - used so we don't spam mentions when not directly addressed
REPLY_WITH_CONTEXT="" # set to true or false whether you want the bot to fetch context or not
ONLY_WHITELIST="true" # change to "false" if you want to accept prompts from any and all domains - *** USE WITH CAUTION ***
WHITELISTED_DOMAINS="" # comma separated list of domains you want to allow the bot to accept prompts from (i.e. poa.st,nicecrew.digital,detroitriotcity.com,decayable.ink)
OLLAMA_URL="http://localhost:11434" # OLLAMA connection URL
OLLAMA_SYSTEM_PROMPT="" # system prompt - used to help tune the responses from the AI
OLLAMA_MODEL="" # Ollama model for responses e.g dolphin-mistral:latest
FETCH_INTERVAL="" # interval for fetching new notifications from the instance, in milliseconds, recommend at least 15000
RANDOM_POST_INTERVAL="" # interval for ad-hoc posts in milliseconds
INSTANCE_BEARER_TOKEN="" # instance auth/bearer token (check the "verify_credentials" endpoint request headers in Chrome DevTools if on Soapbox)

1
.gitignore vendored
View File

@ -4,5 +4,6 @@ node_modules
*.log
*.db
/dist
screenlog*
/generated/prisma

View File

@ -8,8 +8,6 @@
6. Run `npx prisma migrate dev --name init`
7. To start, run `npm run start`
I recommend using `screen` to run this in the background until a `systemd` service can be created. I just haven't bothered to do it yet.
### Database Migrations
If you add stuff to the schema, follow the [Prisma development workflow](https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production). This will apply the new schema to the database and generate a new Prisma client with type safety.

13
package-lock.json generated
View File

@ -1,21 +1,21 @@
{
"name": "pleroma-ollama-bot",
"version": "1.0.0",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pleroma-ollama-bot",
"version": "1.0.0",
"version": "1.0.5",
"dependencies": {
"@prisma/client": "^6.10.1",
"@types/node": "^24.0.5",
"dotenv": "^17.0.0",
"striptags": "^3.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/node": "^24.0.10",
"@types/ws": "^8.18.1",
"prisma": "^6.10.1"
}
@ -164,10 +164,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.5.tgz",
"integrity": "sha512-CXEG9E7GCTOZIre0WdDznmnhvF7xi7AmnP/zF496trmLiqlfdtxp9nPRgLVqfmJ8jgtcKcs0EcvOu2yDZSuvTg==",
"license": "MIT",
"version": "24.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
"integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
"dependencies": {
"undici-types": "~7.8.0"
}

View File

@ -1,6 +1,6 @@
{
"name": "pleroma-ollama-bot",
"version": "1.0.5",
"version": "1.1.0",
"main": "index.js",
"scripts": {
"start": "tsc && node -r dotenv/config dist/main.js",
@ -9,16 +9,16 @@
"type": "module",
"keywords": [],
"author": "NiceCrew",
"description": "A simple bot that responds to activities from Pleroma instances using Ollama's API.",
"description": "A simple bot that responds to activities from Pleroma instances using Ollama's API at a configurable interval.",
"dependencies": {
"@prisma/client": "^6.10.1",
"@types/node": "^24.0.5",
"dotenv": "^17.0.0",
"striptags": "^3.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/node": "^24.0.10",
"@types/ws": "^8.18.1",
"prisma": "^6.10.1"
}

View File

@ -0,0 +1,19 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Response" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pleromaNotificationId" TEXT NOT NULL DEFAULT 'null',
"to" TEXT NOT NULL DEFAULT 'null',
"request" TEXT NOT NULL DEFAULT 'null',
"response" TEXT NOT NULL DEFAULT 'null',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" DATETIME,
"isProcessing" BOOLEAN NOT NULL DEFAULT true,
"isComplete" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Response" ("createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", "request", "response", "to") SELECT "createdAt", "id", "isProcessing", "pleromaNotificationId", "processedAt", "request", "response", "to" FROM "Response";
DROP TABLE "Response";
ALTER TABLE "new_Response" RENAME TO "Response";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -20,10 +20,56 @@ model Response {
createdAt DateTime @default(now())
processedAt DateTime?
isProcessing Boolean @default(true)
isComplete Boolean @default(true)
}
model User {
id Int @id @default(autoincrement())
userFqn String @unique
lastRespondedTo DateTime?
memory UserMemory?
}
model Reaction {
id Int @id @default(autoincrement())
statusId String // The Pleroma status ID we reacted to
emojiName String // The emoji we used to react
reactedAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([statusId]) // Prevent multiple reactions to same status
@@map("reactions")
}
model UserMemory {
id Int @id @default(autoincrement())
userFqn String @unique
personalityTraits String @default("[]") // JSON string of personality observations
runningGags String @default("[]") // JSON string of running jokes/gags
relationships String @default("[]") // JSON string of relationship dynamics with bot
interests String @default("[]") // JSON string of user interests
backstory String @default("[]") // JSON string of biographical elements
lastInteractionSummary String? // Brief summary of last chat
interactionCount Int @default(0)
lastUpdated DateTime @default(now()) @updatedAt
createdAt DateTime @default(now())
// Relation to existing User model
user User @relation(fields: [userFqn], references: [userFqn])
@@map("user_memories")
}
model InteractionLog {
id Int @id @default(autoincrement())
userFqn String
conversationSnapshot String // Key parts of the conversation
sentiment String // positive, negative, teasing, etc.
extractedTopics String @default("[]") // JSON string of topics discussed
memorableQuotes String @default("[]") // JSON string of funny/notable quotes
botEmotionalState String? // How the bot should "feel" about this interaction
createdAt DateTime @default(now())
@@map("interaction_logs")
@@index([userFqn, createdAt])
}

437
src/api.ts Normal file
View File

@ -0,0 +1,437 @@
import { envConfig, prisma } from "./main.js";
import { PleromaEmoji, Notification, ContextResponse } from "../types.js";
import { selectRandomEmojis } from "./util.js";
import { getUserMemory, parseJsonArray, stringifyJsonArray } from "./memory.js";
const getNotifications = async () => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const request = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications?types[]=mention`,
{
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
const notifications: Notification[] = await request.json();
return notifications;
} catch (error: any) {
throw new Error(error.message);
}
};
const getStatusContext = async (statusId: string) => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/statuses/${statusId}/context`,
{
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
if (!response.ok) {
throw new Error(
`Could not get conversation context: ${response.status} - ${response.statusText}`
);
}
const data: ContextResponse = await response.json();
return data;
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
}
};
const getInstanceEmojis = async () => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const request = await fetch(`${pleromaInstanceUrl}/api/v1/pleroma/emoji`, {
method: "GET",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
});
if (!request.ok) {
console.error(`Emoji GET failed: ${request.status}`);
return;
}
const emojis: PleromaEmoji[] = await request.json();
return Object.keys(emojis);
} catch (error: any) {
console.error(`Could not fetch emojis: ${error.message}`);
}
};
const deleteNotification = async (notification: Notification) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
try {
if (!notification.id) {
return;
}
await prisma.response.updateMany({
// this is probably not the best way to do this, but since we may have duplicate notifications, we have to update all of them - probably won't scale (lmao)
where: { pleromaNotificationId: notification.id },
data: { isProcessing: false },
});
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/notifications/${notification.id}/dismiss`,
{
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
},
}
);
if (!response.ok) {
console.error(
`Could not delete notification ID: ${notification.id}\nReason: ${response.status} - ${response.statusText}`
);
}
} catch (error: any) {
throw new Error(error.message);
}
};
/**
* React to a status with a random emoji
*/
const reactToStatus = async (statusId: string, emojiName: string): Promise<boolean> => {
const { bearerToken, pleromaInstanceUrl } = envConfig;
try {
const response = await fetch(
`${pleromaInstanceUrl}/api/v1/statuses/${statusId}/react/${emojiName}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
console.error(`Failed to react to status ${statusId}: ${response.status} - ${response.statusText}`);
return false;
}
return true;
} catch (error: any) {
console.error(`Error reacting to status ${statusId}: ${error.message}`);
return false;
}
};
/**
* Check if we've already reacted to a status
*/
const hasAlreadyReacted = async (statusId: string): Promise<boolean> => {
try {
const reaction = await prisma.reaction.findFirst({
where: { statusId: statusId },
});
return !!reaction;
} catch (error: any) {
console.error(`Error checking reaction status: ${error.message}`);
return true; // Assume we've reacted to avoid spamming on error
}
};
/**
* Record that we've reacted to a status
*/
const recordReaction = async (statusId: string, emojiName: string): Promise<void> => {
try {
await prisma.reaction.create({
data: {
statusId: statusId,
emojiName: emojiName,
reactedAt: new Date(),
},
});
} catch (error: any) {
console.error(`Error recording reaction: ${error.message}`);
}
};
/**
* Decide whether to react to a post (not every post gets a reaction)
*/
const shouldReactToPost = (): boolean => {
// React to roughly 30% of posts
return Math.random() < 0.3;
};
/**
* Get appropriate reaction emojis based on content sentiment/keywords
*/
const getContextualEmoji = (content: string, availableEmojis: string[]): string => {
const contentLower = content.toLowerCase();
// Define emoji categories with keywords
const emojiCategories = {
positive: ['happy', 'smile', 'joy', 'love', 'heart', 'thumbsup', 'fire', 'based'],
negative: ['sad', 'cry', 'angry', 'rage', 'disappointed', 'cringe'],
thinking: ['think', 'hmm', 'brain', 'smart', 'curious'],
laughing: ['laugh', 'lol', 'kek', 'funny', 'haha', 'rofl'],
agreement: ['yes', 'agree', 'nod', 'correct', 'true', 'based'],
surprise: ['wow', 'amazing', 'surprised', 'shock', 'omg'],
};
// Keywords that might indicate sentiment
const sentimentKeywords = {
positive: ['good', 'great', 'awesome', 'nice', 'love', 'happy', 'excellent', 'perfect'],
negative: ['bad', 'terrible', 'hate', 'awful', 'horrible', 'worst', 'sucks'],
funny: ['lol', 'haha', 'funny', 'hilarious', 'joke', 'meme'],
question: ['?', 'what', 'how', 'why', 'when', 'where'],
agreement: ['yes', 'exactly', 'true', 'right', 'correct', 'agree'],
thinking: ['think', 'consider', 'maybe', 'perhaps', 'hmm', 'interesting'],
};
// Check content sentiment and find matching emojis
for (const [sentiment, keywords] of Object.entries(sentimentKeywords)) {
if (keywords.some(keyword => contentLower.includes(keyword))) {
const categoryEmojis = emojiCategories[sentiment as keyof typeof emojiCategories];
if (categoryEmojis) {
const matchingEmojis = availableEmojis.filter(emoji =>
categoryEmojis.some(cat => emoji.toLowerCase().includes(cat))
);
if (matchingEmojis.length > 0) {
return matchingEmojis[Math.floor(Math.random() * matchingEmojis.length)];
}
}
}
}
// Fallback to random emoji from a curated list of common reactions
const commonReactions = availableEmojis.filter(emoji =>
['heart', 'thumbsup', 'fire', 'kek', 'based', 'think', 'smile', 'laugh']
.some(common => emoji.toLowerCase().includes(common))
);
if (commonReactions.length > 0) {
return commonReactions[Math.floor(Math.random() * commonReactions.length)];
}
// Final fallback to any random emoji
return availableEmojis[Math.floor(Math.random() * availableEmojis.length)];
};
/**
* Main function to handle post reactions
*/
const handlePostReaction = async (notification: Notification): Promise<void> => {
try {
const statusId = notification.status.id;
// Check if we should react to this post
if (!shouldReactToPost()) {
return;
}
// Check if we've already reacted
if (await hasAlreadyReacted(statusId)) {
return;
}
// Get available emojis
const emojiList = await getInstanceEmojis();
if (!emojiList || emojiList.length === 0) {
return;
}
// Select a smaller random pool for reactions (5-10 emojis)
const reactionPool = selectRandomEmojis(emojiList, 8);
// Get contextual emoji based on post content
const selectedEmoji = getContextualEmoji(
notification.status.pleroma.content["text/plain"],
reactionPool
);
// React to the post
const success = await reactToStatus(statusId, selectedEmoji);
if (success) {
await recordReaction(statusId, selectedEmoji);
console.log(`Reacted to status ${statusId} with :${selectedEmoji}:`);
}
} catch (error: any) {
console.error(`Error handling post reaction: ${error.message}`);
}
};
/**
* Get detailed user memory for admin/debugging
*/
const getUserMemoryDetails = async (userFqn: string) => {
try {
const memory = await prisma.userMemory.findUnique({
where: { userFqn: userFqn },
include: {
user: true
}
});
if (!memory) return null;
// Get recent interaction logs
const recentLogs = await prisma.interactionLog.findMany({
where: { userFqn: userFqn },
orderBy: { createdAt: 'desc' },
take: 10
});
// Parse JSON strings for better readability
const parsedMemory = {
...memory,
personalityTraits: parseJsonArray(memory.personalityTraits),
runningGags: parseJsonArray(memory.runningGags),
relationships: parseJsonArray(memory.relationships),
interests: parseJsonArray(memory.interests),
backstory: parseJsonArray(memory.backstory),
recentInteractions: recentLogs.map(log => ({
...log,
extractedTopics: parseJsonArray(log.extractedTopics),
memorableQuotes: parseJsonArray(log.memorableQuotes)
}))
};
return parsedMemory;
} catch (error: any) {
console.error(`Error getting user memory details: ${error.message}`);
return null;
}
};
/**
* Manually add or remove memory elements (for admin use)
*/
const modifyUserMemory = async (
userFqn: string,
action: 'add' | 'remove',
category: 'personalityTraits' | 'runningGags' | 'relationships' | 'interests' | 'backstory',
item: string
) => {
try {
const memory = await getUserMemory(userFqn);
if (!memory) return false;
const currentArray = parseJsonArray(memory[category] as string);
let updatedArray: string[];
if (action === 'add') {
updatedArray = [...new Set([...currentArray, item])]; // Add without duplicates
} else {
updatedArray = currentArray.filter(existingItem => existingItem !== item);
}
await prisma.userMemory.update({
where: { userFqn: userFqn },
data: { [category]: stringifyJsonArray(updatedArray) }
});
console.log(`${action === 'add' ? 'Added' : 'Removed'} "${item}" ${action === 'add' ? 'to' : 'from'} ${category} for ${userFqn}`);
return true;
} catch (error: any) {
console.error(`Error modifying user memory: ${error.message}`);
return false;
}
};
const getMemoryStats = async () => {
try {
const totalUsers = await prisma.userMemory.count();
const totalInteractions = await prisma.interactionLog.count();
const mostActiveUsers = await prisma.userMemory.findMany({
orderBy: { interactionCount: 'desc' },
take: 10,
select: {
userFqn: true,
interactionCount: true,
personalityTraits: true,
runningGags: true
}
});
// Parse JSON strings for the active users
const parsedActiveUsers = mostActiveUsers.map(user => ({
...user,
personalityTraits: parseJsonArray(user.personalityTraits),
runningGags: parseJsonArray(user.runningGags)
}));
const sentimentStats = await prisma.interactionLog.groupBy({
by: ['sentiment'],
_count: { sentiment: true }
});
return {
totalUsers,
totalInteractions,
mostActiveUsers: parsedActiveUsers,
sentimentDistribution: sentimentStats
};
} catch (error: any) {
console.error(`Error getting memory stats: ${error.message}`);
return null;
}
};
const resetUserMemory = async (userFqn: string) => {
try {
await prisma.userMemory.update({
where: { userFqn: userFqn },
data: {
personalityTraits: stringifyJsonArray([]),
runningGags: stringifyJsonArray([]),
relationships: stringifyJsonArray([]),
interests: stringifyJsonArray([]),
backstory: stringifyJsonArray([]),
lastInteractionSummary: null,
interactionCount: 0,
}
});
// Optionally delete interaction logs too
await prisma.interactionLog.deleteMany({
where: { userFqn: userFqn }
});
console.log(`Reset memory for ${userFqn}`);
return true;
} catch (error: any) {
console.error(`Error resetting user memory: ${error.message}`);
return false;
}
};
export {
deleteNotification,
getInstanceEmojis,
getNotifications,
getStatusContext,
reactToStatus,
handlePostReaction,
hasAlreadyReacted,
getUserMemoryDetails,
modifyUserMemory,
getMemoryStats,
resetUserMemory,
};

View File

@ -1,121 +1,88 @@
import {
OllamaRequest,
OllamaResponse,
NewStatusBody,
Notification,
OllamaConfigOptions,
OllamaChatRequest,
OllamaChatResponse,
PostAncestorsForModel,
} from "../types.js";
import striptags from "striptags";
// import striptags from "striptags";
import { PrismaClient } from "../generated/prisma/client.js";
import {
getInstanceEmojis,
deleteNotification,
getNotifications,
getStatusContext,
handlePostReaction,
} from "./api.js";
import { storeUserData, storePromptData } from "./prisma.js";
import {
isFromWhitelistedDomain,
alreadyRespondedTo,
recordPendingResponse,
// trimInputData,
// selectRandomEmoji,
selectRandomEmojis,
isLLMRefusal,
shouldContinue,
processConversationHistory,
} from "./util.js";
import {
analyzeInteraction,
updateUserMemory,
generateMemoryContext,
} from "./memory.js";
const prisma = new PrismaClient();
export const prisma = new PrismaClient();
const getNotifications = async () => {
try {
const request = await fetch(
`${process.env.PLEROMA_INSTANCE_URL}/api/v1/notifications?types[]=mention`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INSTANCE_BEARER_TOKEN}`,
},
}
);
const notifications: Notification[] = await request.json();
return notifications;
} catch (error: any) {
throw new Error(error.message);
}
export const envConfig = {
pleromaInstanceUrl: process.env.PLEROMA_INSTANCE_URL || "",
pleromaInstanceDomain: process.env.PLEROMA_INSTANCE_DOMAIN || "",
whitelistOnly: process.env.ONLY_WHITELIST === "true" ? true : false,
whitelistedDomains: process.env.WHITELISTED_DOMAINS
? process.env.WHITELISTED_DOMAINS.split(",")
: [process.env.PLEROMA_INSTANCE_DOMAIN],
ollamaUrl: process.env.OLLAMA_URL || "",
ollamaSystemPrompt: process.env.OLLAMA_SYSTEM_PROMPT,
ollamaModel: process.env.OLLAMA_MODEL || "",
fetchInterval: process.env.FETCH_INTERVAL
? parseInt(process.env.FETCH_INTERVAL)
: 15000,
bearerToken: process.env.INSTANCE_BEARER_TOKEN || "",
adHocPostInterval: process.env.RANDOM_POST_INTERVAL
? parseInt(process.env.RANDOM_POST_INTERVAL)
: 3600000,
botAccountId: process.env.PLEROMA_ACCOUNT_ID,
replyWithContext: process.env.REPLY_WITH_CONTEXT === "true" ? true : false,
};
const storeUserData = async (notification: Notification): Promise<void> => {
try {
await prisma.user.upsert({
where: { userFqn: notification.status.account.fqn },
update: {
lastRespondedTo: new Date(Date.now()),
},
create: {
userFqn: notification.status.account.fqn,
lastRespondedTo: new Date(Date.now()),
},
});
} catch (error: any) {
throw new Error(error.message);
}
const ollamaConfig: OllamaConfigOptions = {
temperature: 0.6,
top_p: 0.85,
top_k: 40,
num_ctx: 8192,
repeat_penalty: 1.1,
};
const alreadyRespondedTo = async (
notification: Notification
): Promise<boolean> => {
try {
const duplicate = await prisma.response.findFirst({
where: { pleromaNotificationId: notification.id, isProcessing: true },
});
if (duplicate) {
return true;
}
return false;
} catch (error: any) {
throw new Error(error.message);
}
};
const storePromptData = async (
notification: Notification,
ollamaResponseBody: OllamaResponse
) => {
try {
await prisma.response.updateMany({
where: { pleromaNotificationId: notification.id },
data: {
response: ollamaResponseBody.response,
request: trimInputData(notification.status.content),
to: notification.account.fqn,
isProcessing: false,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const trimInputData = (input: string) => {
const strippedInput = striptags(input);
const split = strippedInput.split(" ");
const promptStringIndex = split.indexOf("!prompt");
return split.slice(promptStringIndex + 1).join(" "); // returns everything after the !prompt
};
const recordPendingResponse = async (notification: Notification) => {
try {
await prisma.response.create({
data: {
pleromaNotificationId: notification.id,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
// this could be helpful
// https://replicate.com/blog/how-to-prompt-llama
const generateOllamaRequest = async (
notification: Notification
): Promise<OllamaResponse | undefined> => {
notification: Notification,
retryAttempt: number = 0
): Promise<OllamaChatResponse | undefined> => {
const {
whitelistOnly,
ollamaModel,
ollamaSystemPrompt,
ollamaUrl,
replyWithContext,
} = envConfig;
try {
if (
striptags(notification.status.content).includes("!prompt") &&
!notification.status.account.bot && // sanity check, sort of
notification.type === "mention"
) {
if (
process.env.ONLY_LOCAL_REPLIES === "true" &&
!notification.status.account.fqn.includes(
`@${process.env.PLEROMA_INSTANCE_DOMAIN}`
)
) {
if (shouldContinue(notification)) {
if (whitelistOnly && !isFromWhitelistedDomain(notification)) {
await deleteNotification(notification);
return;
}
if (await alreadyRespondedTo(notification)) {
@ -123,24 +90,81 @@ const generateOllamaRequest = async (
}
await recordPendingResponse(notification);
await storeUserData(notification);
const ollamaConfig: OllamaConfigOptions = {
temperature: 1.2,
num_predict: 400,
const userFqn = notification.status.account.fqn;
const userMessage = notification.status.pleroma.content["text/plain"];
let conversationHistory: PostAncestorsForModel[] = [];
let processedContext = "";
if (replyWithContext) {
const contextPosts = await getStatusContext(notification.status.id);
if (!contextPosts?.ancestors || !contextPosts) {
throw new Error(`Unable to obtain post context ancestors.`);
}
conversationHistory = contextPosts.ancestors.map((ancestor) => {
const mentions = ancestor.mentions.map((mention) => mention.acct);
return {
account_fqn: ancestor.account.fqn,
mentions,
plaintext_content: ancestor.pleroma.content["text/plain"],
};
});
// Process context - summarize if too long
processedContext = await processConversationHistory(conversationHistory);
}
const formattedUserMessage = `${userFqn} says: ${userMessage}`;
// Get user memory context
const memoryContext = await generateMemoryContext(userFqn);
// Get random emojis for this request
const emojiList = await getInstanceEmojis();
let availableEmojis = "";
if (emojiList && emojiList.length > 0) {
const randomEmojis = selectRandomEmojis(emojiList, 20);
availableEmojis = `\n\nAvailable custom emojis you can use in your response (format as :emoji_name:): ${randomEmojis.join(", ")}`;
}
let systemContent = ollamaSystemPrompt + memoryContext + availableEmojis;
if (replyWithContext) {
systemContent = `${ollamaSystemPrompt}${memoryContext}\n\nPrevious conversation context:\n${processedContext}\nReply as if you are a party to the conversation. If '@nice-ai' is mentioned, respond directly. Prefix usernames with '@' when addressing them.${availableEmojis}`;
}
// Use different seeds for retry attempts
const currentConfig = {
...ollamaConfig,
seed: retryAttempt > 0 ? Math.floor(Math.random() * 1000000) : ollamaConfig.seed,
};
const ollamaRequestBody: OllamaRequest = {
model: process.env.OLLAMA_MODEL as string,
system: process.env.OLLAMA_SYSTEM_PROMPT as string,
prompt: `@${notification.status.account.fqn} says: ${trimInputData(
notification.status.content
)}`,
const ollamaRequestBody: OllamaChatRequest = {
model: ollamaModel,
messages: [
{ role: "system", content: systemContent as string },
{ role: "user", content: formattedUserMessage },
],
stream: false,
options: ollamaConfig,
options: currentConfig,
};
const response = await fetch(`${process.env.OLLAMA_URL}/api/generate`, {
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(ollamaRequestBody),
});
const ollamaResponse: OllamaResponse = await response.json();
const ollamaResponse: OllamaChatResponse = await response.json();
// Check for refusal and retry up to 2 times
if (isLLMRefusal(ollamaResponse.message.content) && retryAttempt < 2) {
console.log(`LLM refused to answer (attempt ${retryAttempt + 1}), retrying with different seed...`);
return generateOllamaRequest(notification, retryAttempt + 1);
}
// Analyze interaction and update user memory (async, don't block response)
analyzeAndUpdateMemory(userFqn, userMessage, ollamaResponse.message.content);
await storePromptData(notification, ollamaResponse);
return ollamaResponse;
}
@ -149,15 +173,40 @@ const generateOllamaRequest = async (
}
};
/**
* Analyze interaction and update user memory (runs asynchronously)
*/
const analyzeAndUpdateMemory = async (
userFqn: string,
userMessage: string,
botResponse: string
): Promise<void> => {
try {
// Run analysis in background - don't await to avoid blocking response
const analysis = await analyzeInteraction(userMessage, botResponse, userFqn);
await updateUserMemory({
userFqn,
conversationContent: userMessage,
botResponse,
analysis,
});
} catch (error: any) {
console.error(`Memory analysis failed for ${userFqn}: ${error.message}`);
}
};
const postReplyToStatus = async (
notification: Notification,
ollamaResponseBody: OllamaResponse
ollamaResponseBody: OllamaChatResponse
) => {
const { pleromaInstanceUrl, bearerToken } = envConfig;
try {
let mentions: string[];
const statusBody: NewStatusBody = {
content_type: "text/markdown",
status: ollamaResponseBody.response,
status: ollamaResponseBody.message.content,
in_reply_to_id: notification.status.id,
};
if (
@ -170,17 +219,14 @@ const postReplyToStatus = async (
statusBody.to = mentions;
}
const response = await fetch(
`${process.env.PLEROMA_INSTANCE_URL}/api/v1/statuses`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTANCE_BEARER_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(statusBody),
}
);
const response = await fetch(`${pleromaInstanceUrl}/api/v1/statuses`, {
method: "POST",
headers: {
Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(statusBody),
});
if (!response.ok) {
throw new Error(`New status request failed: ${response.statusText}`);
@ -192,39 +238,59 @@ const postReplyToStatus = async (
}
};
const deleteNotification = async (notification: Notification) => {
const createTimelinePost = async () => {
const {
bearerToken,
ollamaModel,
ollamaSystemPrompt,
ollamaUrl,
pleromaInstanceUrl,
} = envConfig;
const ollamaRequestBody: OllamaChatRequest = {
model: ollamaModel,
messages: [
{ role: "system", content: ollamaSystemPrompt as string },
{ role: "user", content: "Say something random." },
],
stream: false,
options: ollamaConfig,
};
try {
if (!notification.id) {
return;
}
await prisma.response.updateMany({
// this is probably not the best way to do this, but since we may have duplicate notifications, we have to update all of them - probably won't scale (lmao)
where: { pleromaNotificationId: notification.id },
data: { isProcessing: false },
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(ollamaRequestBody),
});
const response = await fetch(
`${process.env.PLEROMA_INSTANCE_URL}/api/v1/notifications/${notification.id}/dismiss`,
if (!response.ok)
throw new Error("Error generating ad-hoc Ollama response");
const ollamaResponse: OllamaChatResponse = await response.json();
const newStatusBody: NewStatusBody = {
content_type: "text/markdown",
status: ollamaResponse.message.content,
};
const pleromaResponse = await fetch(
`${pleromaInstanceUrl}/api/v1/statuses`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTANCE_BEARER_TOKEN}`,
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(newStatusBody),
}
);
if (!response.ok) {
console.error(
`Could not delete notification ID: ${notification.id}\nReason: ${response.status} - ${response.statusText}`
);
if (!pleromaResponse.ok)
throw new Error("Error posting ad-hoc Ollama response to Pleroma");
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
} catch (error: any) {
throw new Error(error.message);
}
};
const fetchInterval = process.env.FETCH_INTERVAL
? parseInt(process.env.FETCH_INTERVAL)
: 15000;
let notifications = [];
const beginFetchCycle = async () => {
setInterval(async () => {
@ -233,22 +299,59 @@ const beginFetchCycle = async () => {
await Promise.all(
notifications.map(async (notification) => {
try {
// Handle reactions first (before generating response)
// This way we can react even if response generation fails
await handlePostReaction(notification);
// Then handle the response generation as before
const ollamaResponse = await generateOllamaRequest(notification);
if (ollamaResponse) {
postReplyToStatus(notification, ollamaResponse);
await postReplyToStatus(notification, ollamaResponse);
}
} catch (error: any) {
throw new Error(error.message);
console.error(`Error processing notification ${notification.id}: ${error.message}`);
// Still try to delete the notification to avoid getting stuck
try {
await deleteNotification(notification);
} catch (deleteError: any) {
console.error(`Failed to delete notification: ${deleteError.message}`);
}
}
})
);
}
}, fetchInterval); // lower intervals may cause the bot to respond multiple times to the same message, but we try to mitigate this with the deleteNotification function
}, envConfig.fetchInterval);
};
const beginStatusPostInterval = async () => {
setInterval(async () => {
try {
createTimelinePost();
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
}
}, envConfig.adHocPostInterval);
};
console.log(
`Fetching notifications from ${process.env.PLEROMA_INSTANCE_DOMAIN}, every ${
fetchInterval / 1000
`Fetching notifications from ${envConfig.pleromaInstanceDomain}, every ${
envConfig.fetchInterval / 1000
} seconds.`
);
console.log(
`Accepting prompts from: ${envConfig.whitelistedDomains.join(", ")}`
);
console.log(
`Using model: ${envConfig.ollamaModel}\nConfig: ${JSON.stringify(
ollamaConfig
)}`
);
console.log(`System prompt: ${envConfig.ollamaSystemPrompt}`);
await beginFetchCycle();
// setInterval(async () => {
// createTimelinePost();
// }, 10000);
await beginStatusPostInterval();

323
src/memory.ts Normal file
View File

@ -0,0 +1,323 @@
/**
* ADAPTIVE MEMORY SYSTEM FOR FEDIVERSE CHATBOT
*
* This system maintains persistent, evolving user profiles to enable personalized
* interactions across chat sessions. It uses LLM-based analysis to extract and
* categorize user traits, then builds context for future conversations.
*
* ARCHITECTURE:
* - UserMemory: Core profile (personality, gags, relationships, interests, backstory)
* - InteractionLog: Historical conversation snapshots with sentiment analysis
* - JSON string arrays in SQLite for flexible data storage
*
* WORKFLOW:
* 1. Each user message + bot response gets analyzed by Ollama
* 2. Extract personality traits, running gags, relationship dynamics, etc.
* 3. Merge new insights with existing profile (deduplication)
* 4. Generate memory context string for next conversation's system prompt
* 5. Log interaction with sentiment and notable quotes
*
* MEMORY CATEGORIES:
* - personalityTraits: User characteristics (sarcastic, protective, etc.)
* - runningGags: Recurring jokes, memes, fake claims between user and bot
* - relationships: How user treats bot (mean, protective, flirty)
* - interests: Hobbies, topics user cares about
* - backstory: Biographical info, "lore" (real or fabricated)
*
* CURRENT LIMITATIONS:
* - No memory aging/decay - old info persists indefinitely
* - Simple deduplication - similar but not identical entries accumulate
* - No relevance scoring - stale assumptions carry same weight as recent ones
* - Fixed array limits may truncate important long-term patterns
*
* RECOMMENDED IMPROVEMENTS:
* - Add timestamp-based relevance weighting
* - Implement semantic similarity checks for better deduplication
* - Add contradiction detection to update outdated assumptions
* - Consider LRU-style eviction instead of simple truncation
*/
// Updated memory.ts with JSON string handling for SQLite
import { prisma } from "./main.js";
import { envConfig } from "./main.js";
import { InteractionAnalysis, MemoryUpdateRequest, OllamaChatRequest, OllamaChatResponse } from "../types.js";
// Helper functions for JSON string array handling
const parseJsonArray = (jsonString: string): string[] => {
try {
const parsed = JSON.parse(jsonString);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const stringifyJsonArray = (array: string[]): string => {
return JSON.stringify(array);
};
/**
* Analyze a conversation to extract user personality, gags, and relationship dynamics
*/
const analyzeInteraction = async (
userMessage: string,
botResponse: string,
userFqn: string
): Promise<InteractionAnalysis> => {
const { ollamaUrl, ollamaModel } = envConfig;
const analysisPrompt = `Analyze this conversation between a user and a cute female AI chatbot named Lexi. Extract personality traits, running gags, relationship dynamics, and interesting facts.
User (${userFqn}): ${userMessage}
Bot (Lexi): ${botResponse}
Please analyze and respond with a JSON object containing:
{
"sentiment": "positive|negative|neutral|teasing|flirty|aggressive",
"topics": ["topic1", "topic2"],
"personalityObservations": ["trait1", "trait2"],
"runningGagUpdates": ["gag1", "gag2"],
"relationshipUpdates": ["relationship_change1"],
"interestMentions": ["interest1", "interest2"],
"backstoryElements": ["fact1", "fact2"],
"memorableQuotes": ["quote1", "quote2"]
}
Focus on:
- Personality traits (sarcastic, teasing, protective, joker, etc.)
- Running gags and memes (fake claims, recurring jokes, etc.)
- How they treat the bot (mean, nice, flirty, protective)
- Interests and hobbies mentioned
- Any biographical info (real or fake "lore")
- Memorable or funny quotes
Keep entries brief and specific. Empty arrays are fine if nothing notable.`;
try {
const analysisRequest: OllamaChatRequest = {
model: ollamaModel,
messages: [
{
role: "system",
content: "You are an expert at analyzing social interactions and extracting personality insights. Always respond with valid JSON only."
},
{ role: "user", content: analysisPrompt }
],
stream: false,
options: {
temperature: 0.3, // Lower temperature for more consistent analysis
num_predict: 800,
}
};
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(analysisRequest),
});
if (!response.ok) {
throw new Error(`Analysis request failed: ${response.statusText}`);
}
const analysisResponse: OllamaChatResponse = await response.json();
try {
// Parse the JSON response
const analysis: InteractionAnalysis = JSON.parse(analysisResponse.message.content.trim());
return analysis;
} catch (parseError) {
console.error("Failed to parse analysis JSON:", analysisResponse.message.content);
// Return default analysis if parsing fails
return {
sentiment: 'neutral',
topics: [],
personalityObservations: [],
runningGagUpdates: [],
relationshipUpdates: [],
interestMentions: [],
backstoryElements: [],
memorableQuotes: []
};
}
} catch (error: any) {
console.error(`Error analyzing interaction: ${error.message}`);
return {
sentiment: 'neutral',
topics: [],
personalityObservations: [],
runningGagUpdates: [],
relationshipUpdates: [],
interestMentions: [],
backstoryElements: [],
memorableQuotes: []
};
}
};
/**
* Get or create user memory profile
*/
const getUserMemory = async (userFqn: string) => {
try {
let memory = await prisma.userMemory.findUnique({
where: { userFqn: userFqn }
});
if (!memory) {
memory = await prisma.userMemory.create({
data: {
userFqn: userFqn,
personalityTraits: stringifyJsonArray([]),
runningGags: stringifyJsonArray([]),
relationships: stringifyJsonArray([]),
interests: stringifyJsonArray([]),
backstory: stringifyJsonArray([]),
lastInteractionSummary: null,
interactionCount: 0,
}
});
}
return memory;
} catch (error: any) {
console.error(`Error getting user memory: ${error.message}`);
return null;
}
};
/**
* Update user memory with new interaction insights
*/
const updateUserMemory = async (request: MemoryUpdateRequest): Promise<void> => {
try {
const { userFqn, conversationContent, botResponse, analysis } = request;
// Get existing memory
const existingMemory = await getUserMemory(userFqn);
if (!existingMemory) return;
// Parse existing JSON arrays
const existingPersonality = parseJsonArray(existingMemory.personalityTraits);
const existingGags = parseJsonArray(existingMemory.runningGags);
const existingRelationships = parseJsonArray(existingMemory.relationships);
const existingInterests = parseJsonArray(existingMemory.interests);
const existingBackstory = parseJsonArray(existingMemory.backstory);
// Merge new observations with existing ones (avoiding duplicates)
const mergeArrays = (existing: string[], newItems: string[]): string[] => {
const combined = [...existing, ...newItems];
return [...new Set(combined)]; // Remove duplicates
};
// Limit array sizes to prevent memory bloat
const limitArray = (arr: string[], maxSize: number = 20): string[] => {
return arr.slice(-maxSize); // Keep most recent items
};
const updatedMemory = {
personalityTraits: stringifyJsonArray(limitArray(mergeArrays(existingPersonality, analysis.personalityObservations))),
runningGags: stringifyJsonArray(limitArray(mergeArrays(existingGags, analysis.runningGagUpdates))),
relationships: stringifyJsonArray(limitArray(mergeArrays(existingRelationships, analysis.relationshipUpdates))),
interests: stringifyJsonArray(limitArray(mergeArrays(existingInterests, analysis.interestMentions))),
backstory: stringifyJsonArray(limitArray(mergeArrays(existingBackstory, analysis.backstoryElements))),
lastInteractionSummary: `${analysis.sentiment} conversation about ${analysis.topics.join(', ') || 'general chat'}`,
interactionCount: existingMemory.interactionCount + 1,
};
// Update database
await prisma.userMemory.update({
where: { userFqn: userFqn },
data: updatedMemory
});
// Log the interaction for historical reference
await prisma.interactionLog.create({
data: {
userFqn: userFqn,
conversationSnapshot: `${userFqn}: ${conversationContent.slice(0, 200)}... | Lexi: ${botResponse.slice(0, 200)}...`,
sentiment: analysis.sentiment,
extractedTopics: stringifyJsonArray(analysis.topics),
memorableQuotes: stringifyJsonArray(analysis.memorableQuotes),
botEmotionalState: generateEmotionalState(analysis),
}
});
console.log(`Updated memory for ${userFqn}: ${analysis.personalityObservations.join(', ')}`);
} catch (error: any) {
console.error(`Error updating user memory: ${error.message}`);
}
};
/**
* Generate how the bot should "feel" about this interaction
*/
const generateEmotionalState = (analysis: InteractionAnalysis): string => {
const { sentiment, relationshipUpdates } = analysis;
if (sentiment === 'teasing') return 'playfully_hurt';
if (sentiment === 'flirty') return 'flustered';
if (sentiment === 'aggressive') return 'sad';
if (relationshipUpdates.some(rel => rel.includes('hurt') || rel.includes('mean'))) return 'hurt_feelings';
if (relationshipUpdates.some(rel => rel.includes('cute') || rel.includes('sweet'))) return 'happy';
return 'neutral';
};
/**
* Generate memory context for system prompt
*/
const generateMemoryContext = async (userFqn: string): Promise<string> => {
try {
const memory = await getUserMemory(userFqn);
if (!memory || memory.interactionCount === 0) {
return "";
}
let context = `\n\n--- User Memory for ${userFqn} ---\n`;
const personalityTraits = parseJsonArray(memory.personalityTraits);
const runningGags = parseJsonArray(memory.runningGags);
const relationships = parseJsonArray(memory.relationships);
const interests = parseJsonArray(memory.interests);
const backstory = parseJsonArray(memory.backstory);
if (personalityTraits.length > 0) {
context += `Personality: ${personalityTraits.join(', ')}\n`;
}
if (runningGags.length > 0) {
context += `Running gags: ${runningGags.join(', ')}\n`;
}
if (relationships.length > 0) {
context += `Our relationship: ${relationships.join(', ')}\n`;
}
if (interests.length > 0) {
context += `Interests: ${interests.join(', ')}\n`;
}
if (backstory.length > 0) {
context += `Background: ${backstory.join(', ')}\n`;
}
if (memory.lastInteractionSummary) {
context += `Last time we talked: ${memory.lastInteractionSummary}\n`;
}
context += `Total conversations: ${memory.interactionCount}`;
return context;
} catch (error: any) {
console.error(`Error generating memory context: ${error.message}`);
return "";
}
};
export {
analyzeInteraction,
updateUserMemory,
getUserMemory,
generateMemoryContext,
parseJsonArray,
stringifyJsonArray,
};

42
src/prisma.ts Normal file
View File

@ -0,0 +1,42 @@
import { Notification, OllamaChatResponse } from "../types.js";
import { trimInputData } from "./util.js";
import { prisma } from "./main.js";
const storePromptData = async (
notification: Notification,
ollamaResponseBody: OllamaChatResponse
) => {
try {
await prisma.response.updateMany({
where: { pleromaNotificationId: notification.id },
data: {
response: ollamaResponseBody.message.content,
request: trimInputData(notification.status.content),
to: notification.account.fqn,
isProcessing: false,
isComplete: true,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const storeUserData = async (notification: Notification): Promise<void> => {
try {
await prisma.user.upsert({
where: { userFqn: notification.status.account.fqn },
update: {
lastRespondedTo: new Date(Date.now()),
},
create: {
userFqn: notification.status.account.fqn,
lastRespondedTo: new Date(Date.now()),
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
export { storeUserData, storePromptData };

267
src/util.ts Normal file
View File

@ -0,0 +1,267 @@
import striptags from "striptags";
import { prisma } from "./main.js";
import { envConfig } from "./main.js";
import { Notification } from "../types.js";
import { OllamaChatRequest, OllamaChatResponse, PostAncestorsForModel } from "../types.js";
const trimInputData = (input: string): string => {
const strippedInput = striptags(input);
const split = strippedInput.split(" ");
// const promptStringIndex = split.indexOf("!prompt");
const botFqnIndex = split.indexOf("@nice-ai");
const botFqnIndexFull = split.indexOf("@nice-ai@nicecrew.digital");
if (botFqnIndex !== -1) {
split[botFqnIndex] = "Lexi";
}
if (botFqnIndexFull !== -1) {
split[botFqnIndexFull] = "Lexi";
}
// split.splice(promptStringIndex, 1);
return split.join(" "); // returns everything after the !prompt
};
const recordPendingResponse = async (notification: Notification) => {
try {
await prisma.response.create({
data: {
pleromaNotificationId: notification.id,
isProcessing: true,
isComplete: false,
},
});
} catch (error: any) {
throw new Error(error.message);
}
};
const shouldContinue = (notification: Notification) => {
// wow this is bad
try {
const { botAccountId } = envConfig;
const statusContent = trimInputData(notification.status.content);
if (
// notification.status.visibility !== "private" &&
!notification.account.bot &&
notification.type === "mention"
) {
if (notification.status.in_reply_to_account_id === botAccountId) {
return true;
} else if (
notification.status.in_reply_to_account_id !== botAccountId &&
statusContent.includes("Lexi")
) {
return true;
} else {
return false;
}
}
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(error.message);
}
}
};
const isFromWhitelistedDomain = (notification: Notification): boolean => {
try {
const domain = notification.status.account.fqn.split("@")[1];
if (envConfig.whitelistedDomains.includes(domain)) {
return true;
}
console.log(
`Rejecting prompt request from non-whitelisted domain: ${domain}`
);
return false;
} catch (error: any) {
console.error(`Error with domain check: ${error.message}`);
return false;
}
};
const alreadyRespondedTo = async (
notification: Notification
): Promise<boolean> => {
try {
const duplicate = await prisma.response.findFirst({
where: { pleromaNotificationId: notification.id },
});
if (duplicate?.isProcessing || duplicate?.isComplete) {
return true;
}
return false;
} catch (error: any) {
throw new Error(error.message);
}
};
const selectRandomEmoji = (emojiList: string[]) => {
return emojiList[Math.floor(Math.random() * emojiList.length)];
};
const selectRandomEmojis = (emojiList: string[], count: number = 20): string[] => {
if (emojiList.length <= count) return emojiList;
const shuffled = [...emojiList].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
const isLLMRefusal = (response: string): boolean => {
const refusalPatterns = [
/i can't|i cannot|unable to|i'm not able to/i,
/i don't feel comfortable/i,
/i'm not comfortable/i,
/i shouldn't|i won't/i,
/that's not something i can/i,
/i'm not programmed to/i,
/i'm an ai (assistant|language model)/i,
/as an ai/i,
/i apologize, but/i,
/i must decline/i,
/that would be inappropriate/i,
/i'm not supposed to/i,
/i'd rather not/i,
/i prefer not to/i,
/against my guidelines/i,
/violates my programming/i,
];
const normalizedResponse = response.toLowerCase().trim();
// Check if response is too short (likely a refusal)
if (normalizedResponse.length < 20) return true;
// Check for refusal patterns
return refusalPatterns.some(pattern => pattern.test(normalizedResponse));
};
/**
* Summarize a long conversation thread to reduce context length
*/
const summarizeConversationHistory = async (
conversationHistory: PostAncestorsForModel[]
): Promise<string> => {
const { ollamaUrl, ollamaModel } = envConfig;
if (conversationHistory.length === 0) return "";
// Create a concise thread representation
const threadText = conversationHistory
.map(post => `${post.account_fqn}: ${post.plaintext_content}`)
.join('\n');
const summarizePrompt = `Summarize this conversation thread in 2-3 sentences, focusing on the main topics discussed and the overall tone/mood. Keep it brief but capture the essential context:
${threadText}
Summary:`;
try {
const summarizeRequest: OllamaChatRequest = {
model: ollamaModel,
messages: [
{
role: "system",
content: "You are excellent at creating concise, informative summaries. Keep summaries under 150 words and focus on key topics and relationships between participants."
},
{ role: "user", content: summarizePrompt }
],
stream: false,
options: {
temperature: 0.2, // Low temperature for consistent summaries
num_predict: 200,
num_ctx: 4096, // Smaller context for summarization
}
};
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
body: JSON.stringify(summarizeRequest),
});
if (!response.ok) {
console.error(`Summary request failed: ${response.statusText}`);
return `Previous conversation with ${conversationHistory.length} messages about various topics.`;
}
const summaryResponse: OllamaChatResponse = await response.json();
return summaryResponse.message.content.trim();
} catch (error: any) {
console.error(`Error summarizing conversation: ${error.message}`);
return `Previous conversation with ${conversationHistory.length} messages.`;
}
};
/**
* Decide whether to summarize based on thread length and complexity
*/
const shouldSummarizeThread = (conversationHistory: PostAncestorsForModel[]): boolean => {
const SUMMARY_THRESHOLD = 15;
if (conversationHistory.length < SUMMARY_THRESHOLD) return false;
// Additional heuristics could be added here:
// - Total character count
// - Average message length
// - Time span of conversation
return true;
};
/**
* Process conversation history - either use full context or summarized version
*/
const processConversationHistory = async (
conversationHistory: PostAncestorsForModel[]
): Promise<string> => {
if (!shouldSummarizeThread(conversationHistory)) {
// Use full context for short threads
return conversationHistory
.map(post =>
`${post.account_fqn} (to ${post.mentions.join(", ")}): ${post.plaintext_content}`
)
.join('\n');
}
// Keep the last few messages in full detail + summary of earlier messages
const KEEP_RECENT_COUNT = 5;
const recentMessages = conversationHistory.slice(-KEEP_RECENT_COUNT);
const olderMessages = conversationHistory.slice(0, -KEEP_RECENT_COUNT);
let contextString = "";
if (olderMessages.length > 0) {
const summary = await summarizeConversationHistory(olderMessages);
contextString += `Earlier conversation summary: ${summary}\n\n`;
}
if (recentMessages.length > 0) {
contextString += "Recent messages:\n";
contextString += recentMessages
.map(post =>
`${post.account_fqn} (to ${post.mentions.join(", ")}): ${post.plaintext_content}`
)
.join('\n');
}
return contextString;
};
export {
alreadyRespondedTo,
selectRandomEmoji,
selectRandomEmojis,
isLLMRefusal,
trimInputData,
recordPendingResponse,
isFromWhitelistedDomain,
shouldContinue,
summarizeConversationHistory,
shouldSummarizeThread,
processConversationHistory,
};

14
systemd.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Pleroma Ollama Bot
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=bot
Restart=always
RestartSec=3
ExecStart=/usr/bin/screen -L -DmS pleroma-ollama-bot /home/bot/.nvm/versions/node/v22.11.0/bin/npm run start
WorkingDirectory=/path/to/directory
[Install]
WantedBy=multi-user.target

304
types.d.ts vendored
View File

@ -6,6 +6,41 @@ export interface Notification {
created_at: string;
}
export interface ContextResponse {
ancestors: ContextObject[];
descendents: ContextObject[];
}
export interface PostAncestorsForModel {
account_fqn: string;
mentions: string[];
plaintext_content: string;
}
interface ContextAccountObject {
acct: string;
avatar: string;
bot: boolean;
display_name: string;
followers_count: number;
following_count: number;
fqn: string;
id: string;
}
export interface ContextObject {
content: string;
id: string;
in_reply_to_account_id: string | null;
in_reply_to_id: string | null;
media_attachments: string[];
mentions: Mention[];
pleroma: PleromaObjectInResponse;
visibility: "public" | "private" | "unlisted";
uri: string;
account: ContextAccountObject;
}
export interface NewStatusBody {
content_type: "application/json" | "text/markdown";
in_reply_to_id?: string;
@ -36,14 +71,48 @@ export interface OllamaRequest {
/**
* Whatever system prompt you'd like to add to the model to make it more unique, or force it to respond a certain way.
*/
system: string;
system?: string;
/**
* Whether to stream responses from the API, or have it sent all as one payload.
*/
stream?: boolean = false; // stream response vs get response in one full message
stream?: boolean = false;
/**
* Ollama configuration options
*/
options?: OllamaConfigOptions;
}
export interface OllamaChatRequest {
model: string;
messages: OllamaMessages[];
stream?: boolean = false;
options?: OllamaConfigOptions;
}
export interface OllamaChatResponse {
model: string;
created_at: string;
message: OllamaChatResponseMessage;
done_reason: "string";
done: boolean;
total_duration: number;
load_duration: number;
prompt_eval_count: number;
prompt_eval_duration: number;
eval_count: number;
eval_duration: number;
}
interface OllamaChatResponseMessage {
role: "assistant";
content: string;
}
interface OllamaMessages {
role: "system" | "user";
content: string;
}
export interface OllamaResponse {
model: string;
created_at: Date | string;
@ -58,8 +127,19 @@ export interface Status {
created_at: string | Date; // when the post was created
id: string; // ID of the reply itself
in_reply_to_account_id: string; // account ID of the reply
in_reply_to_id?: string; // status that the user has replied to
mentions?: Mention[]; // array of mentions
in_reply_to_id: string; // status that the user has replied to
mentions: Mention[]; // array of mentions
pleroma: PleromaObjectInResponse;
visibility: "private" | "public" | "unlisted";
}
interface PleromaObjectInResponse {
content: { "text/plain": string };
context: string;
conversation_id: number;
direct_conversation_id: number | null;
local: boolean;
in_reply_to_account_acct: string;
}
export interface Mention {
@ -69,48 +149,224 @@ export interface Mention {
username: string;
}
export interface PleromaEmoji {
[emojiName: string]: PleromaEmojiMetadata;
}
interface PleromaEmojiMetadata {
image_url: string;
tags: string[];
}
interface ReactionRequest {
name: string; // emoji name without colons
}
interface ReactionResponse {
name: string;
count: number;
me: boolean;
url?: string;
static_url?: string;
}
/**
* Experimental settings, I wouldn't recommend messing with these if you don't know how they work (I don't either)
*/
export interface OllamaConfigOptions {
/**
* Number of tokens guaranteed to be kept in memory during response generation. Higher values leave less
* possible room for num_ctx
* Number of tokens guaranteed to be kept in memory during response generation.
* Higher values leave less room for num_ctx. Used to preserve important context.
* Default: 0, Range: 0-512
*/
num_keep?: number;
seed?: number;
/**
* Sets maximum of tokens in the response
* Random seed for reproducible outputs. Same seed + same inputs = same output.
* Default: -1 (random), Range: any integer
*/
seed?: number;
/**
* Maximum number of tokens to generate in the response. Controls response length.
* Default: 128, Range: 1-4096+ (model dependent)
*/
num_predict?: number;
top_k?: number;
top_p?: number;
min_p?: number;
typical_p?: number;
repeat_last_n?: number;
/**
* How close of a response should the response be to the original prompt - lower = more focused response
* Limits token selection to top K most probable tokens. Reduces randomness.
* Default: 40, Range: 1-100 (higher = more diverse)
*/
top_k?: number;
/**
* Nucleus sampling - cumulative probability cutoff for token selection.
* Default: 0.9, Range: 0.0-1.0 (lower = more focused)
*/
top_p?: number;
/**
* Alternative to top_p - minimum probability threshold for tokens.
* Default: 0.0, Range: 0.0-1.0 (higher = more selective)
*/
min_p?: number;
/**
* Typical sampling - targets tokens with "typical" probability mass.
* Default: 1.0 (disabled), Range: 0.0-1.0 (lower = less random)
*/
typical_p?: number;
/**
* Number of previous tokens to consider for repetition penalty.
* Default: 64, Range: 0-512
*/
repeat_last_n?: number;
/**
* Randomness/creativity control. Lower = more deterministic, higher = more creative.
* Default: 0.8, Range: 0.0-2.0 (sweet spot: 0.1-1.2)
*/
temperature?: number;
repeat_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
mirostat?: number;
mirostat_tau?: number;
mirostat_eta?: number;
penalize_newline?: boolean;
stop?: string[];
numa?: boolean;
/**
* Number of tokens for the prompt to keep in memory for the response, minus the value of num_keep
* Penalty for repeating tokens. Higher values reduce repetition.
* Default: 1.1, Range: 0.0-2.0 (1.0 = no penalty)
*/
repeat_penalty?: number;
/**
* Penalty for using tokens that have already appeared (OpenAI-style).
* Default: 0.0, Range: -2.0 to 2.0
*/
presence_penalty?: number;
/**
* Penalty proportional to token frequency in text (OpenAI-style).
* Default: 0.0, Range: -2.0 to 2.0
*/
frequency_penalty?: number;
/**
* Enables Mirostat sampling algorithm (0=disabled, 1=v1, 2=v2).
* Default: 0, Range: 0, 1, or 2
*/
mirostat?: number;
/**
* Target entropy for Mirostat. Controls coherence vs creativity balance.
* Default: 5.0, Range: 0.0-10.0
*/
mirostat_tau?: number;
/**
* Learning rate for Mirostat. How quickly it adapts.
* Default: 0.1, Range: 0.001-1.0
*/
mirostat_eta?: number;
/**
* Apply penalty to newline tokens to control formatting.
* Default: true
*/
penalize_newline?: boolean;
/**
* Array of strings that will stop generation when encountered.
* Default: [], Example: ["\n", "User:", "###"]
*/
stop?: string[];
/**
* Enable NUMA (Non-Uniform Memory Access) optimization.
* Default: false (Linux systems may benefit from true)
*/
numa?: boolean;
/**
* Context window size - total tokens for prompt + response.
* Default: 2048, Range: 512-32768+ (model dependent, affects memory usage)
*/
num_ctx?: number;
/**
* Batch size for prompt processing. Higher = faster but more memory.
* Default: 512, Range: 1-2048
*/
num_batch?: number;
/**
* Number of GPU layers to offload. -1 = auto, 0 = CPU only.
* Default: -1, Range: -1 to model layer count
*/
num_gpu?: number;
/**
* Primary GPU device ID for multi-GPU setups.
* Default: 0, Range: 0 to (GPU count - 1)
*/
main_gpu?: number;
/**
* Optimize for low VRAM usage at cost of speed.
* Default: false
*/
low_vram?: boolean;
/**
* Only load vocabulary, skip weights. For tokenization only.
* Default: false
*/
vocab_only?: boolean;
/**
* Use memory mapping for model files (faster loading).
* Default: true
*/
use_mmap?: boolean;
/**
* Lock model in memory to prevent swapping.
* Default: false (enable for consistent performance)
*/
use_mlock?: boolean;
/**
* Number of CPU threads for inference.
* Default: auto-detected, Range: 1 to CPU core count
*/
num_thread?: number;
}
export interface UserMemory {
id: number;
userFqn: string;
personalityTraits: string[]; // ["teases_bot", "sarcastic", "friendly", "joker"]
runningGags: string[]; // ["claims_to_shit_pants", "pretends_to_be_cat", "always_hungry"]
relationships: string[]; // ["hurt_my_feelings_once", "called_me_cute", "protective_of_me"]
interests: string[]; // ["programming", "anime", "cooking"]
backstory: string[]; // ["works_at_tech_company", "has_three_cats", "lives_in_california"]
lastInteractionSummary: string; // Brief summary of last conversation
interactionCount: number;
lastUpdated: DateTime;
createdAt: DateTime;
}
export interface InteractionAnalysis {
sentiment: 'positive' | 'negative' | 'neutral' | 'teasing' | 'flirty' | 'aggressive';
topics: string[]; // Extracted topics from conversation
personalityObservations: string[]; // New traits observed
runningGagUpdates: string[]; // New or updated running gags
relationshipUpdates: string[]; // How relationship with bot changed
interestMentions: string[]; // Interests/hobbies mentioned
backstoryElements: string[]; // New biographical info (real or fake)
memorableQuotes: string[]; // Funny or notable things they said
}
export interface MemoryUpdateRequest {
userFqn: string;
conversationContent: string;
botResponse: string;
analysis: InteractionAnalysis;
}