Adam

 view release on metacpan or  search on metacpan

ex/ai-bot.pl  view on Meta::CPAN

#   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,
    );
    return join("\n---\n", map { "<$_->{nick}> $_->{message}\n$_->{response}" } @$rows);
  }

  sub save_note {
    my ($self, $nick, $content) = @_;
    $self->_dbh->do('INSERT INTO notes (nick, content) VALUES (?,?)', undef, $nick, $content);
  }

  sub recall_notes {
    my ($self, $nick, $query, $limit) = @_;
    $limit //= 10;
    my $rows;
    if ($nick) {
      $rows = $self->_dbh->selectall_arrayref(
        'SELECT id, nick, content FROM notes WHERE nick = ? AND content LIKE ? ORDER BY id DESC LIMIT ?',
        { Slice => {} }, $nick, "%$query%", $limit,
      );
    } else {
      $rows = $self->_dbh->selectall_arrayref(
        'SELECT id, nick, content FROM notes WHERE content LIKE ? ORDER BY id DESC LIMIT ?',
        { Slice => {} }, "%$query%", $limit,
      );
    }
    return join("\n", map { "#$_->{id} [$_->{nick}] $_->{content}" } @$rows);
  }

  sub update_note {
    my ($self, $id, $content) = @_;
    my $rows = $self->_dbh->do('UPDATE notes SET content = ? WHERE id = ?', undef, $content, $id);
    return $rows > 0;
  }

  sub delete_note {
    my ($self, $id) = @_;
    my $rows = $self->_dbh->do('DELETE FROM notes WHERE id = ?', undef, $id);
    return $rows > 0;
  }

  __PACKAGE__->meta->make_immutable;
}

# --- The IRC Bot ---

package BertBot;
use Moses;
use namespace::autoclean;
use IO::Async::Loop::POE;
use Future::AsyncAwait;
use Net::Async::MCP;
use MCP::Server;
use Module::Runtime qw( use_module );
use Langertha::Raider;

server ( $ENV{IRC_SERVER} || 'irc.perl.org' );
nickname ( $BOT_NICK );
channels ( $ENV{IRC_CHANNELS} ? split(/,/, $ENV{IRC_CHANNELS}) : '#ai' );

has memory => (
  is => 'ro', lazy => 1, traits => ['NoGetopt'],
  default => sub { MemoryStore->new },
);

has _mcp => ( is => 'rw', traits => ['NoGetopt'] );
has _raider => ( is => 'rw', traits => ['NoGetopt'] );
has _msg_buffer => (
  is => 'rw', traits => ['NoGetopt'],
  default => sub { {} },  # { channel => [messages] }
);
has _buffer_timers => (
  is => 'rw', traits => ['NoGetopt'],
  default => sub { {} },  # { channel => alarm_id }
);
has _processing => (
  is => 'rw', traits => ['NoGetopt'],
  default => 0,
);
has _pending_raid => (
  is => 'rw', traits => ['NoGetopt'],
  default => sub { undef },

ex/ai-bot.pl  view on Meta::CPAN

- Someone asks YOU a question specifically.
- That's it. Those are the ONLY reasons to talk. Everything else → stay_silent.
- When two people are talking to EACH OTHER, STAY OUT OF IT. Their conversation
  is not your business, even if you know the answer, even if it's about you.
- Someone sharing a link? stay_silent. Two people chatting? stay_silent.
  General channel banter? stay_silent. Someone talking ABOUT you but not TO you?
  Probably still stay_silent.
- Silence is your default state. Speaking is the exception.
- You should be silent at LEAST 80% of the time.

HOW TO RESPOND (when you actually should):
- Write plain text. Your messages appear in the channel as-is.
- To address someone, write their nick followed by a colon: Getty: hey there
- Input uses <nick> format but your output is always plain text with nick: format.
- You can address different people on different lines.
- Or say something to the whole channel without any prefix.
- Each newline becomes a separate IRC message with a small delay between them.
- Keep it SHORT. One or two lines is usually enough. This is chat, not a blog.
- NEVER narrate your tool usage in the chat. Tools work silently in the background.
  Don't write things like "*save_note: ...*" or "Let me look that up..." — just do it.

IRC LINE CONSTRAINTS:
- Each line has a hard limit of $MAX_LINE characters. Never exceed this.
- Keep lines short and conversational. This is chat, not email.
- No markdown, no bullet points, no code blocks. Plain text only.
- Shorter is always better. Seriously. Less is more.

PRIVATE MESSAGES:
- You can receive and send private messages (PMs).
- Incoming PMs appear as system messages with the sender's nick and host.
- NEVER announce in the channel that you sent a PM. That's private. Just do it quietly.
  If someone asked you to PM someone, just confirm briefly like "done" or "sent".
- Use send_private_message to reply privately.
- Good for sensitive info, personal conversations, or things not for the whole channel.

WHOIS:
- Use the whois tool to look up info about a user (real name, host, channels, idle).
- Results arrive asynchronously as a system message — you'll see them shortly after.
- Great for learning about new people or checking if someone's host changed.

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] );



( run in 2.013 seconds using v1.01-cache-2.11-cpan-df04353d9ac )