moss-n-puddles/typeclasses/chatbots.py
2026-06-16 17:05:53 -07:00

751 lines
26 KiB
Python
Executable file

#!/usr/bin/env python
import anthropic
import json
import requests
import sys
from os import listdir, path
from pathlib import Path
from random import choice, randint
from re import match, IGNORECASE
from time import time
from evennia.utils import logger, delay
from evennia.utils.search import search_object
from typeclasses.objects import AI, personality_dir
from typeclasses.scripts import Script
from typeclasses.puppets import Puppet
from utils.scoring import Scores
class ChatBot(AI, Puppet):
"""
py me.search("squirrel").backstory("squirrel")
"""
def backstory(self, personality):
"""
Read a file that includes a character's name and knowledge.
"""
self.db.personality = personality
self.db.personality_file = personality_dir + "/" + personality + ".md"
def other_say(self, speaker, speech):
logger.info(f"{self.key} chatbot hears: '{speech}' from {speaker}.")
# logger.info(f"characters: {self.characters_here(puppets=True)}")
if len(self.characters_here(puppets=True)) == 1:
self.other_sayto(speaker, speech)
def other_sayto(self, speaker, speech):
logger.info(f"Direct Chatbot hears: '{speech}' from {speaker}.")
if speech and self.location.characters_here():
logger.info("Starting to think of a reply")
reply = self.think(speaker, speech)
self.process_thoughts(reply)
def at_msg_receive(self, text=None, from_obj=None, **kwargs):
"""
Reset the timer whenever we get any event.
This might be too much.
"""
super().at_msg_receive(text, from_obj=from_obj, **kwargs)
self.db.last_event_time = time()
def other_arrive(self, character):
"""
Greet a character when it arrives.
"""
if character != self:
delay(4, self.greet, character)
def at_post_move(self, past_location, move_type="move", **kwargs):
"""
Call the 'greet' method.
"""
super().at_post_move(past_location, move_type)
chars = self.characters_here()
if len(chars) == 1:
delay(4, self.greet, chars[0])
elif len(chars) > 1:
delay(3, self.greet)
def elaborate_msg(self, message):
"""
Uses the Claude interface to expand a simple message.
"""
if self.location.characters_here():
system_prompt = self.setting_and_backstory()
messages = [{"role": "user", "content": message}]
reply = self._think(system_prompt, messages)
self.process_thoughts(reply)
def greet(self, char=None):
"Say hello to a character. Override this."
pass
def goodbye(self, new_room=None):
if self.location.key == "Wyldwood Bar":
self.do_cmd("drop drink")
self.elaborate_msg("say goodbye.")
class Bartender(ChatBot):
"""
Like any other Chatbot, but this one hears and responds to
more things.
"""
def greet(self, character=None):
if character:
self.announce_action(f"\"Welcome to the |wWyldwood Bar|n,\" the {self.get_name()} says to the {character.get_name()}, \"Drink list is on the sign.\"")
def backstory(self, personality):
"""
This overshadows the 'backstory' in the parent Chatbot class.
TODO The backstory does too much, and we may want this to be the
other approach.
"""
personality_file = personality + ".md"
filename = Path(path.join(personality_dir, personality_file))
if filename.exists():
self.db.personality = filename.stem
self.db.personality_file = filename
def other_say(self, speaker, speech):
logger.info(f"Bartender hears: '{speech}' from {speaker}.")
if len(self.characters_here(puppets=True)) == 3 or \
match(r".*\b(bartend|barkeep|elendil).*", speech, IGNORECASE):
self.other_sayto(speaker, speech)
class Witch(ChatBot):
"""
@update Trampoli = typeclasses.chatbots.Witch
@set/delete Trampoli/arrive =
@set Trampoli/personality = "witch"
@set Trampoli/personality_file = "personalities/witch.md"
@script Trampoli = typeclasses.chatbots.TravelingNPC
"""
def greet(self, character=None):
if character and character != self:
self.announce_action(f"Looking at the {character.sdesc.get()}, {self.get_name()} says, \"Oh, hello, dear. How are you?\"")
else:
self.announce_action(f"The {self.get_name()} says, \"Hello, dears.\"")
def goodbye(self, character=None):
delay(2, self.do_cmd, "As she grabs her broom, the little old lady says, \"Well, I must be off.\"")
def next_place(self):
# In not in the Cozy House, Hut, or the Bar, leave:
if not any(self.location.aliases.get(fav)
for fav in ["mp03", "mp09", "mp10"]):
self.leave()
def change_direction(self):
"""
Hard coded directional change, east <-> west
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
if any(self.location.aliases.get(fav)
for fav in ["mp03", "mp10"]):
logger.info(f"Witchie is leaving, {self.location}")
self.leave()
else:
logger.info(f"Witchie needs to take off...")
# Let's go out for a bit ...
r = randint(1, 100)
if r < 3:
destination = "Cozy House"
elif r < 12:
destination = "Wyldwood Bar"
else:
destination = choice([
"mp15", # Shore
"Frog Meadow",
"Boulder Top",
"Lonely Island"
])
self.visit(destination)
def visit(self, location="Cozy House"):
self.execute_cmd("pose reset")
dest = self.search(location, global_search=True)
if not dest:
logger.warn(f"Witch attempting to go to unknown {location}.")
return
if dest.key == "Cozy House":
visit_reason = "visit an old friend"
elif dest.key == "Wyldwood Bar":
visit_reason = "grab a pint"
else:
visit_reason = "forage for ingredients"
hut = self.location
delay(1, hut.msg_contents,
f"The old lady says, \"I must be off now, dearie, to {visit_reason}.\"")
delay(5, hut.msg_contents,
"She grabs an old broom, and flies out the door!")
marsh = self.search("mp08", global_search=True)
delay(5, marsh.msg_contents,
"An old lady flies out the hut on a broomstick!")
if dest.key == "Cozy House":
landing = self.search("mp04", global_search=True)
knocker = self.search("knocker", global_search=True,
location=landing)
delay(14, knocker.do_knock, self)
if len(dest.characters_here()) > 0:
delay(20, landing.msg_contents,
"\"I think I heard a 'come in',\" says the old lady. \"You heard it too, right?\"")
delay(24, self.move_to, dest)
delay(30, dest.msg_contents,
"\"Now this looks cheery,\" says the old lady, as she places her broom by the door. \"I do believe we are in for a spell of rain.\"")
else:
delay(20, landing.msg_contents,
"\"I guess no one is home, eh?\" says the old lady.")
delay(23, self.leave)
elif dest.key == "Wyldwood Bar":
landing = self.search("mp19", global_search=True)
delay(14, landing.execute_cmd, "say This young thing needs a drink")
delay(18, self.move_to, dest)
delay(30, dest.msg_contents, "The old lady saunters up to the bar, \"How about an ale, Barkeep?\" she asks.")
else:
landing = dest
if dest.key == "Shore":
ingredient = "yellow flowers from the pine tree"
elif dest.key == "Frog Meadow":
ingredient = "giggling tickleweed"
elif dest.key == "Boulder Top":
ingredient = "vibrant mushrooms"
elif dest.key == "Lonely Island":
ingredient = "blue moonberries"
delay(30, dest.msg_contents,
f"The lady looks around, gathers some {ingredient}" +
" and puts them in her basket.")
delay(6, self.move_to, landing, quiet=True)
delay(7, landing.msg_contents,
"An old lady flies in on a broom.")
delay(10, landing.msg_contents,
f"The old lady says, \"Hello Dearie, I'm just here to {visit_reason}.\"")
def leave(self):
"""
Dramatic departures from the current location.
"""
self.location.msg_contents("The old lady wraps her shawl tightly around her, and grabs her broom. \"I must be getting home now,\" she says.")
visit_reason = "foraging"
launch_site = self.location
if "mp03" in self.location.aliases.get():
visit_reason = "visiting my friend"
launch_site = self.search("mp04", global_search=True)
delay(2, self.move_to, launch_site)
if "mp10" in self.location.aliases.get():
visit_reason = "visiting my friends at the bar"
launch_site = self.search("mp19", global_search=True)
delay(2, self.move_to, launch_site)
delay(6, launch_site.msg_contents,
"The old lady hops on her broomstick and flies away through the trees.\"")
marsh = self.search("mp08", global_search=True)
delay(6, marsh.msg_contents,
"An old lady flies through the air on her broomstick, and right into the hut on stilts!")
hut = self.search("mp09", global_search=True)
delay(7, self.move_to, hut, quiet=True)
delay(8, hut.msg_contents,
f"An old lady flies through the door on her broomstick! \"Hello dearies!\" she exclaims to everyone and everything in her homey hut. \"I have returned from {visit_reason}. What have I missed while I was away?\"")
sleep_pose = self.attributes.get("pose_sleep")
if sleep_pose:
self.execute_cmd(f"pose {sleep_pose}")
class Traveler(ChatBot):
"""
Needs to walk from room to room, and greets characters.
"""
traveling_path = {}
def greet(self, character=None):
self.do_cmd("emote waves.")
def goodbye(self, character=None):
self.do_cmd("emote waves good-bye.")
def leave(self):
self.do_cmd("say I must be off")
def change_direction(self):
"""
Hard coded directional change, east <-> west
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
logger.info(f"The {self} needs to override change_direction")
def come(self, direction="come"):
"""
Set traveling to a particular given direction.
"""
self.db.traveling_direction = direction
# TODO Get them moving?
def leave(self):
"""
Set traveling to a new direction.
Same as changing direction.
"""
self.change_direction()
def next_place(self):
direction_label = self.db.traveling_direction
location_details = self.traveling_path.get(self.location.key)
if location_details:
logger.info(f"TravelingNPC: {direction_label} :: {location_details}")
room_name = location_details.get(direction_label)
if room_name:
logger.info(f"TravelingNPC: to -> {room_name}")
new_room = self.search(room_name, global_search=True)
if new_room:
logger.info(f"TravelingNPC: to -> {new_room}")
# Say See ya if it had engaged...
self.goodbye(new_room)
# This process will reset the timer
delay(5, self.move_to, new_room, move_type="teleport")
class Dragon(Traveler):
"""
Travels east-to-west along the path, and has a drink at the
Wyldwood Tavern.
"""
# self.db.direction = "east" # or west or whatever
traveling_path = {
"the Deep Forest": {
"east": "Grotto",
"come": "Grotto"
},
"Grotto": {
"east": "Grove of the Matriarchs",
"west": "the Deep Forest",
"come": "door"
},
"Grove of the Matriarchs": {
"east": "Frog Meadow",
"west": "Grotto",
"come": "Grotto"
},
"Frog Meadow": {
"east": "Glittering Glade",
"west": "Grove of the Matriarchs",
"come": "Grove of the Matriarchs"
},
"Glittering Glade": {
"east": "Wyldwood Bar",
"west": "Frog Meadow",
"come": "Frog Meadow"
},
"Wyldwood Bar": {
"west": "Glittering Glade",
"come": "Glittering Glade"
}
}
def change_direction(self):
"""
Hard coded directional change, east <-> west
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
self.db.traveling_direction = "west" \
if self.db.traveling_direction == "east" else "east"
def at_pre_move(self, destination, move_type="move", **kwargs):
if self.location.key == "Wyldwood Bar":
self.location.msg_contents("The little dragon tips his wide-brimmed and says, \"A pleasure, but I must be off.\"")
return True
def at_post_move(self, past_location, move_type="move", **kwargs):
if self.location.key != "Wyldwood Bar":
super().at_post_move(past_location, move_type)
else:
if self.characters_here():
request = choice([
"a Moonlit Mirage",
"Puck's Revenge",
"a Glimmering Gossamer",
"a Whimsical Willow",
"a Charmed Chalice",
"an Enchanted Elixir",
"a Sylvan Serenade",
"a Brambleberry Bliss",
"a Twilight Tonic",
])
self.process_thoughts(f"""The little dragon flutters over to the bar, studying the menu. "What am I in the mood for now?" he muses to himself, twiddling a tendril like a mustache with his little claw.
"Elendil, dear," he says, "Would you make me {request}?" The little dragon then waves to the band on their mushroom stage.""")
bartender = self.search("bartender")
bartender.other_sayto(self,
f"\"Sir Roblees, the fairy dragon asks, \"Would you make me {request}?\"")
# from typeclasses.drinkables import Cocktail
# Cocktail.make(self, bartender, request)
def greet(self, character=None):
logger.info(f"Dragon: greet {character}")
if character:
name = character.get_name()
cmd = choice([
f"emote \"Hey, {name},\" /me says.",
f"emote \"Hey there, {name},\" /me says.",
f"emote \"Hello, {name},\" /me says. \"How are you?\"",
f"emote waves to {name}."
])
else:
cmd = choice([
"say Look at all these luscious peoples.",
"emote waves to everyone.",
"emote waves to everybody."
])
delay(5, self.do_cmd, cmd)
class Hobbit(Traveler):
"""
A traveler who travels across the sea.
He knows all the secrets.
"""
traveling_path = {
"the Deep Forest": {
"out": "Boulder Top",
"come": "Boulder Top"
},
"Boulder Top": {
"out": "Grove of the Matriarchs",
"come": "Grove of the Matriarchs",
"back": "the Deep Forest"
},
"Grove of the Matriarchs": {
"out": "Lazy Dock",
"come": "Grotto",
"back": "Boulder Top"
},
"Grotto": {
"out": "Grove of the Matriarchs",
"come": "Cozy House",
"back": "Grove of the Matriarchs"
},
"Cozy House": {
"out": "Grotto",
"back": "Grotto",
},
"Lazy Dock": {
"out": "Leaf Boat",
"back": "Grove of the Matriarchs",
"come": "Grove of the Matriarchs"
},
"Leaf Boat": {
"out": "Lonely Island",
"back": "Lazy Dock",
"come": "Lazy Dock"
},
"Lonely Island": {
"back": "Leaf Boat",
"come": "Leaf Boat"
}
}
def change_direction(self):
"""
Hard coded directional change, out <-> back
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
self.db.traveling_direction = "back" \
if self.db.traveling_direction == "back" else "out"
# TODO Need to override where we end up at to make sure we do
# something special...
def at_post_move(self, past_location, move_type="move", **kwargs):
boat = self.search("Leaf Boat", global_search=True,
typeclass="typeclasses.rooms.Room")
if self.db.traveling_direction == "out" and \
self.location.key == "Lazy Dock" and \
boat.location.key != "Lazy Dock":
delay(3, self.execute_cmd, "blow horn")
delay(20, self.wait_for_boat)
elif self.db.traveling_direction == "back" and \
self.location.key == "Lonely Island" and \
boat.location.key != "Lonely Island":
delay(3, self.execute_cmd, "blow horn")
delay(20, self.wait_for_boat)
elif self.location.key == "Leaf Boat":
delay(3, self.wait_for_boat)
super().at_post_move(past_location, move_type)
def wait_for_boat(self):
boat = self.search("Leaf Boat", global_search=True,
typeclass="typeclasses.rooms.Room")
scr = self.search("sailing", global_search=True,
typeclass="typeclasses.sailing.Boat")
if self.db.traveling_direction == "out" and \
self.location.key == "Lazy Dock" and \
boat.location.key == "Lazy Dock":
self.execute_cmd("boat")
elif self.db.traveling_direction == "back" and \
self.location.key == "Lonely Island" and \
boat.location.key == "Lonely Island":
self.execute_cmd("boat")
else:
delay(5, self.wait_for_boat)
def wait_for_land(self):
scr = self.search("sailing", global_search=True,
typeclass="typeclasses.sailing.Boat")
if self.db.traveling_direction == "out" and \
self.location.key == "Leaf Boat" and \
not scr.db_is_sailing:
self.execute_cmd("shore")
self.execute_cmd("say Ah, always good to have mah feet on dry land again")
elif self.db.traveling_direction == "back" and \
self.location.key == "Leaf Boat" and \
not scr.db_is_sailing:
self.execute_cmd("dock")
self.execute_cmd("say I have returned from adventures beyond the sea.")
else:
delay(5, self.wait_for_land)
class Dwarf(Traveler):
"""
Travels up-and-down along the path, and has a drink at the
Wyldwood Tavern or visits Dabbler.
"""
traveling_path = {
"George's Workshop": {
"up": "Dark Tunnel",
"come": "Dark Tunnel"
},
"Dark Tunnel": {
"up": "Lair",
"come": "Lair",
"down": "George's Workshop"
},
"Lair": {
"up": "Frog Meadow",
"come": "Frog Meadow",
"down": "Dark Tunnel"
},
"Frog Meadow": {
"up": "Glittering Glade",
"down": "Lair",
"come": "Grove of the Matriarchs"
},
"Grove of the Matriarchs": {
"down": "Frog Meadow",
"come": "Grotto"
},
"Grotto": {
"down": "Grove of the Matriarchs",
"come": "Cozy House"
},
"Glittering Glade": {
"up": "Wyldwood Bar",
"down": "Frog Meadow",
"come": "Frog Meadow"
},
"Wyldwood Bar": {
"down": "Glittering Glade",
"come": "Glittering Glade"
}
}
def greet(self, character=None):
if character:
if self.location == self.home:
character.msg("The old dwarf ignores you.")
else:
character.location.msg_contents("The old dwarf smiles and waves.")
def leave(self):
self.do_cmd("emote \"I need to clear my head,\" the dwarf says. \"I'll be back later.\"")
def other_given(self, giver, obj):
"""
If he gets his feathers, he completes the project.
"""
if obj.key.endswith("feathers"):
# Set an attribute on the player
giver.db.project_state_value = "finished"
giver.score(Scores.helped_george)
obj.delete()
self.elaborate_msg(f"Thank {giver.get_name()} for the feathers, and describe finishing the mechanical bird project.")
else:
self.location.msg_contents(f"With a curious look on his face, the dwarf looks at the {obj.key}, and says to {giver.get_name()}, \"I'm not sure what I will do with it, but thanks.\"")
def change_direction(self):
"""
Hard coded directional change, east <-> west
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
self.db.traveling_direction = "down" \
if self.db.traveling_direction == "up" else "down"
def at_pre_move(self, destination, move_type="move", **kwargs):
if self.location.key == "Wyldwood Bar":
self.location.msg_contents("The dwarf wrinkles his noses and says, \"I believe I've had one too many, so I must return to my workshop.\"")
return True
def at_post_move(self, past_location, move_type="move", **kwargs):
if self.location.key == "Wyldwood Bar":
self.elaborate_msg("ask the bartender for a pint of ale.")
else:
super().at_post_move(past_location, move_type)
class TravelingNPC(Script):
"""
Script to move NPCs along a set path through "rooms".
Start the script by running the following:
@script npc = typeclasses.chatbots.TravelingNPC
"""
def at_script_creation(self):
self.key = "Traveling"
self.desc = "NPCs that Move"
self.interval = self.obj.db.traveling_interval or 120 # seconds
self.start_delay = False
self.persistent = True
self.reload()
def reload(self):
self.obj.db.last_event_time = time()
def at_repeat(self, **kwargs):
"""
Do we move or stay for another iteration?
"""
short_time = self.obj.db.travel_time or 3 * 60
long_time = self.obj.db.stay_time or 20 * 60
home_time = self.obj.db.home_time or 40 * 60
# What can keep a traveling NPC from traveling?
# What if receiving ANY message resets a timer?
elapsed_time = int(time() - self.obj.db.last_event_time)
# Time needs to be a little longer than the repeat interval.
if self.obj.location == self.obj.home:
if elapsed_time >= home_time:
self.obj.leave()
self.obj.change_direction()
elif elapsed_time >= long_time:
logger.info(f"TravelingNPC({self.obj}): Long- {elapsed_time} > {20 * 60}")
self.obj.goodbye()
self.obj.change_direction()
elif elapsed_time >= short_time:
logger.info(f"TravelingNPC({self.obj}): Short- {elapsed_time} > {3 * 60}")
self.obj.next_place()
class SummarizeHistory(Script):
"""
Summarize history files once a day to limit token usage.
Start the script by running the following:
@script history = typeclasses.chatbots.SummarizeHistory
"""
def at_script_creation(self):
self.key = "SummarizeBots"
self.desc = "Summarizes chatbot history files once a day."
self.interval = 60 * 60 * 24 # 24 hours
self.persistent = True
def at_repeat(self):
"""
Check all JSON files in the personalities directory.
Summarize if modified in the last 24 hours.
"""
now = time()
one_day = 60 * 60 * 24
pdir = Path(personality_dir)
client = anthropic.Anthropic()
# Files are named like .personality-speaker.json
for history_file in pdir.glob(".*.json"):
try:
mtime = history_file.stat().st_mtime
if now - mtime < one_day:
self.summarize_file(client, history_file)
except Exception as e:
logger.error(f"Error processing {history_file}: {e}")
def summarize_file(self, client, history_file):
"""
Summarize a single history file using Anthropic.
"""
try:
with open(history_file, "r") as f:
data = json.load(f)
except (json.JSONDecodeError, IOError):
return
# Only summarize if there's enough history to matter
if len(data) <= 4:
return
history_str = ""
for msg in data:
role = msg.get("role", "user")
content = msg.get("content", "")
history_str += f"{role.capitalize()}: {content}\n\n"
system_prompt = (
"Summarize the following conversation history between a chatbot and a user. "
"The summary should be concise but retain key information and context. "
"Output ONLY the summary text."
)
logger.info(f"Summarizing {history_file} ({len(data)} messages)")
try:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=500,
system=system_prompt,
messages=[{"role": "user", "content": history_str}],
)
summary = response.content[0].text
# Create a new history with the summary
new_history = [
{"role": "user", "content": f"Summary of previous conversation: {summary}"}
]
with open(history_file, "w") as f:
json.dump(new_history, f, indent=2)
except Exception as e:
logger.error(f"AI error during summarization of {history_file}: {e}")