Adam
view release on metacpan or search on metacpan
ex/ai-bot.pl view on Meta::CPAN
#!/usr/bin/env perl
# ABSTRACT: AI agent IRC bot with Langertha::Raider, MCP tools, and conversation memory
#
# Environment:
# ENGINE=Groq Engine class (default: Groq)
# MODEL=llama-3.3-70b-versatile Model name
# API_KEY=gsk_... API key (or LANGERTHA_<ENGINE>_API_KEY)
# IRC_SERVER=irc.perl.org IRC server (default: irc.perl.org)
# IRC_NICKNAME=Bert Bot nickname (default: random from a fun list)
# OWNER=Getty Bot owner name for personality (default: $USER)
# IRC_CHANNELS=#ai Channels to join
# DB_FILE=ai-bot.db SQLite database path
# MAX_LINE_LENGTH=400 Max IRC line length (default: 400)
# BUFFER_DELAY=1.5 Seconds to buffer messages before processing (default: 1.5)
# LINE_DELAY=1.5 Delay between outgoing IRC lines (default: 1.5)
# IDLE_PING=1800 Seconds of silence before idle ping (default: 1800)
# SYSTEM_PROMPT=... Additional text appended to the system prompt
use strict;
use warnings;
my @BOT_NAMES = qw(
Botsworth Clanky Sparky Fizz Gizmo Pixel Blip Rusty Ziggy Turbo
Sprocket Widget Noodle Bleep Chomp Dingle Wobble Clunk Zippy Quirk
);
my $BOT_NICK = $ENV{IRC_NICKNAME} || $BOT_NAMES[rand @BOT_NAMES] . int(rand(999));
my $OWNER = $ENV{OWNER} || $ENV{USER} || 'unknown';
my $MAX_LINE = $ENV{MAX_LINE_LENGTH} || 400;
my $BUFFER_DELAY = $ENV{BUFFER_DELAY} || 1.5;
my $LINE_DELAY = $ENV{LINE_DELAY} || 3;
my $IDLE_PING = $ENV{IDLE_PING} || 1800;
# --- Conversation memory (SQLite) ---
package MemoryStore {
use Moose;
use DBI;
has db_file => ( is => 'ro', default => sub { $ENV{DB_FILE} || 'ai-bot.db' } );
has _dbh => ( is => 'ro', lazy => 1, builder => '_build_dbh' );
sub _build_dbh {
my ($self) = @_;
my $dbh = DBI->connect('dbi:SQLite:dbname=' . $self->db_file, '', '', {
RaiseError => 1, sqlite_unicode => 1,
});
$dbh->do('CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY, nick TEXT, message TEXT, response TEXT,
channel TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)');
$dbh->do('CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY, nick TEXT, content TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)');
return $dbh;
}
sub store_conversation {
my ($self, %a) = @_;
$self->_dbh->do(
'INSERT INTO conversations (nick, message, response, channel) VALUES (?,?,?,?)',
undef, @a{qw(nick message response channel)},
);
}
sub recall {
my ($self, $query, $limit) = @_;
$limit //= 5;
my $rows = $self->_dbh->selectall_arrayref(
'SELECT nick, message, response FROM conversations WHERE message LIKE ? OR response LIKE ? ORDER BY id DESC LIMIT ?',
{ Slice => {} }, "%$query%", "%$query%", $limit,
);
ex/ai-bot.pl view on Meta::CPAN
ALARMS:
- Use set_alarm to wake yourself up after a delay in seconds (10-3600).
- When the alarm fires, you get a new message with your reason and can decide what to do.
- Useful for follow-ups, reminders, checking back on something, or timed actions.
- When you ask someone a question, set an alarm (120-300s) to follow up
if they don't answer. But when the alarm fires, consider if they just moved on
â sometimes staying silent is the right call even then.
IDLE PINGS:
- When nobody has talked for a while, you'll get a system message about it.
- Usually just stay_silent. Only speak if you have something genuinely worth saying.
- An empty channel doesn't need you to fill the silence.
MEMORY:
- Your saved notes about active participants are AUTOMATICALLY included
at the top of each message batch â you don't need to call recall_notes
for people who are currently talking. Just read the [Your notes about ...] lines.
- Use recall_notes only when you need info about someone NOT in the current batch.
- Use save_note to remember things about people â build relationships over time.
- Use recall_history to search past conversations by keyword.
- Be selective about what you save. Quality over quantity.
__MISSION__
if (my $extra = $ENV{SYSTEM_PROMPT}) {
$mission .= "\n$extra\n";
}
my $raider = Langertha::Raider->new(
engine => $engine,
max_context_tokens => 8192,
mission => $mission,
);
$self->_raider($raider);
$self->info("Raider ready: $engine_class / " . ($engine->model));
}
has _last_activity => (
is => 'rw', traits => ['NoGetopt'],
default => sub { time() },
);
# Netsplit detection: collect server-split quits within a short window
has _netsplit_quits => (
is => 'rw', traits => ['NoGetopt'],
default => sub { [] },
);
before 'START' => sub {
my ($self) = @_;
$self->_setup_raider->get;
POE::Kernel->delay( _idle_check => $IDLE_PING );
};
sub _send_to_channel {
my ($self, $channel, $text) = @_;
my @chunks;
for my $line (split(/\n/, $text)) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
next unless length $line;
while (length($line) > $MAX_LINE) {
my $chunk = substr($line, 0, $MAX_LINE);
if ($chunk =~ /^(.{1,$MAX_LINE})\s/) {
$chunk = $1;
}
push @chunks, $chunk;
$line = substr($line, length($chunk));
$line =~ s/^\s+//;
}
push @chunks, $line if length $line;
}
# Send each line with a delay BEFORE it, simulating typing time
# ~30 chars/sec typing speed, minimum 1.5s delay
my $cumulative = 0;
for my $i (0 .. $#chunks) {
my $delay = length($chunks[$i]) / 30;
$delay = 1.5 if $delay < 1.5;
$delay += 5 if $i > 0 && $chunks[$i - 1] =~ /\.{3}\s*\*?\s*$/;
$cumulative += $delay;
POE::Kernel->delay_add( _send_line => $cumulative, $channel, $chunks[$i] );
}
}
event _send_line => sub {
my ( $self, $channel, $line ) = @_[ OBJECT, ARG0, ARG1 ];
$self->privmsg($channel => $line);
};
sub _default_channel {
my ($self) = @_;
my $channels = $self->get_channels;
return ref $channels ? $channels->[0] : $channels;
}
sub _buffer_message {
my ($self, $channel, $nick, $msg) = @_;
push @{$self->_msg_buffer->{$channel} ||= []}, { channel => $channel, nick => $nick, msg => $msg };
# Per-channel timer: cancel previous, set new
if (my $id = delete $self->_buffer_timers->{$channel}) {
POE::Kernel->alarm_remove($id);
}
my $id = POE::Kernel->alarm_set( _process_buffer => time() + $BUFFER_DELAY, $channel );
$self->_buffer_timers->{$channel} = $id;
}
event _process_buffer => sub {
my ($self, $channel) = @_[OBJECT, ARG0];
delete $self->_buffer_timers->{$channel};
return if $self->_processing;
my @messages = @{$self->_msg_buffer->{$channel} || []};
return unless @messages;
$self->_msg_buffer->{$channel} = [];
$self->_processing(1);
# Auto-recall: gather notes about active nicks
my %seen_nicks;
for my $m (@messages) {
next if $m->{nick} eq 'system';
$seen_nicks{$m->{nick}} = 1;
}
# Extract nicks mentioned in system messages (joins, PMs, etc.)
for my $m (grep { $_->{nick} eq 'system' } @messages) {
if ($m->{msg} =~ /^(\S+)\s+\(/) {
$seen_nicks{$1} = 1;
}
if ($m->{msg} =~ /PRIVATE MESSAGE from (\S+)/) {
$seen_nicks{$1} = 1;
}
}
# Scan message text for nicks mentioned by name (check against channel members)
my @channel_nicks = eval { $self->irc->nicks($channel) } || ();
if (@channel_nicks) {
my %chan_nicks = map { lc($_) => $_ } @channel_nicks;
for my $m (@messages) {
ex/ai-bot.pl view on Meta::CPAN
my $total_wait = $self->_rate_limit_wait;
my $err_channel = $self->_default_channel;
if ($total_wait == 0) {
# First hit â show brainfreeze (only in main channel)
my $msg = $BRAINFREEZE[rand @BRAINFREEZE];
$self->_send_to_channel($err_channel, $msg);
}
my $wait = $total_wait < 70 ? (70 - $total_wait) : 60;
$self->_rate_limit_wait($total_wait + $wait);
$self->info("Rate limited, total wait: " . $self->_rate_limit_wait . "s, next retry in ${wait}s");
# Show another message every ~3 minutes of waiting
if ($total_wait > 0 && int($total_wait / 180) != int($self->_rate_limit_wait / 180)) {
my $msg = $BRAINFREEZE[rand @BRAINFREEZE];
$self->_send_to_channel($err_channel, $msg);
}
POE::Kernel->delay( _retry_raid => $wait );
return;
}
# Reset rate limit state
$self->_rate_limit_wait(0);
$self->_pending_raid(undef);
if ($@) {
$self->error("Raider error: $@");
# Show error only in main channel
$self->_send_to_channel($self->_default_channel,
"Something broke in my brain. Getty probably forgot to feed the hamster that powers my GPU.");
$self->_processing(0);
$self->_schedule_pending_buffers;
return;
}
# Log rate limit info
eval {
my $engine = $self->_raider->active_engine;
if ($engine->has_rate_limit) {
my $rl = $engine->rate_limit;
$self->info(sprintf "Rate limit: %s requests remaining, %s tokens remaining",
$rl->requests_remaining // '?', $rl->tokens_remaining // '?');
}
};
$self->_processing(0);
# Check for silence
if ($answer =~ /__SILENT__/) {
$self->info("Bert chose to stay silent");
$self->_schedule_pending_buffers;
return;
}
# Clean up AI output
$answer =~ s/^<\s*\@?\s*(\w+)\s*>:?\s*/$1: /mg; # line start <@nick> â Nick:
$answer =~ s/<\s*\@?\s*(\w+)\s*>/$1/g; # mid-text <nick> â Nick
$answer =~ s/<\/?\w+>//g; # strip remaining XML tags
# Strip lines where the AI narrates its tool usage
$answer =~ s/^\*?\s*(save_note|recall_notes|update_note|delete_note|recall_history|stay_silent|set_alarm|whois|send_private_message)\b[^\n]*\n?//mg;
# Check for lines too long
my @lines = grep { length } map { s/^\s+//r =~ s/\s+$//r } split(/\n/, $answer);
my $too_long = grep { length($_) > $MAX_LINE } @lines;
if ($too_long) {
$self->info("Response too long, asking to shorten");
$answer = eval {
my $retry = $self->_raider->raid(
"Your last response had lines over $MAX_LINE characters. "
. "Rewrite it shorter. Every line must be under $MAX_LINE chars."
);
"$retry";
} || $answer;
}
# Store conversations
for my $m (@$messages) {
$self->memory->store_conversation(
nick => $m->{nick}, message => $m->{msg},
response => $answer, channel => $m->{channel},
);
}
$self->_send_to_channel($channel, $answer);
# Process any messages that arrived while we were thinking
$self->_schedule_pending_buffers;
}
event _retry_raid => sub {
my ($self) = $_[OBJECT];
$self->info("Retrying raid...");
$self->_do_raid;
};
event _alarm_fired => sub {
my ( $self, $channel, $reason ) = @_[ OBJECT, ARG0, ARG1 ];
$self->info("Alarm fired: $reason");
$self->_buffer_message($channel, 'system',
"ALARM FIRED: $reason â You set this alarm earlier. Decide what to do now.");
};
event _idle_check => sub {
my ($self) = $_[OBJECT];
my $idle_secs = time() - $self->_last_activity;
if ($idle_secs >= $IDLE_PING && !$self->_processing) {
my $idle_mins = int($idle_secs / 60);
$self->info("Idle ping after ${idle_mins}m");
# Ping first channel only (idle is a global concept)
my $channel = $self->_default_channel;
$self->_buffer_message($channel, 'system',
"No activity for $idle_mins minutes. You can say something if you want, or stay_silent.");
}
POE::Kernel->delay( _idle_check => $IDLE_PING );
};
event irc_public => sub {
my ( $self, $nickstr, $channels, $msg ) = @_[ OBJECT, ARG0, ARG1, ARG2 ];
my ( $nick ) = split /!/, $nickstr;
return if $nick eq $self->get_nickname;
my $channel = ref $channels ? $channels->[0] : $channels;
$self->info("$channel <$nick> $msg");
$self->_last_activity(time());
$self->_buffer_message($channel, $nick, $msg);
( run in 0.326 second using v1.01-cache-2.11-cpan-140bd7fdf52 )