zynk/ai.h
hanez 3b97b4d902 0.41.2 Implement comprehensive flood protection for IRC bot.
Phase 1: Incoming Protocol-Level Protection
- Added MAX_LINES_PER_POLL = 50 constant to limit lines processed per
  poll iteration in irc_feed(), preventing burst flooding.
- Added poll_lines_left counter to Session struct, reset each poll cycle.
- Changed RateLimitEntry to track by user@host instead of nick, preventing
  trivial bypass via nick cycling.
- Parse userhost from IRC prefix (nick!user@host) in irc_handle().
- Added violation tracking to RateLimitEntry for escalating penalties.

Phase 2: Outgoing Message Throttling
- Added OUTGOING_DELAY_MS = 100 constant for minimum delay between sends.
- Modified net_send() with timestamp-based throttling using clock_gettime().
- Added 200ms delay between lines in !gitlog output (cmd_gitlog.h).
- Added 150ms delay between AI response chunks (ai.h).

Phase 3: Rate Limit Improvements
- Increased RATE_LIMIT_MAX from 8 to 32 for larger tracking buffer.
- Added TELL_DELIVER_MAX = 5 to limit messages per nick delivery.
- Added AI_COOLDOWN_SECS = 5 to prevent rapid AI query re-submission.

Phase 4: Command-Specific Fixes
- Added rate_limit_check() to cmd_greeting_or_chat.h for casual chat.
- Limited tell_deliver() to 5 messages per nick, excess stays queued.
- Added 5-second cooldown after AI query completion before next query.

Files modified: zynk.h, zynk.c, irc.h, net.h, ai.h, db.h, and all
19 command header files to pass current_userhost to rate_limit_check().
2026-07-25 20:37:07 +02:00

356 lines
13 KiB
C

#ifndef AI_H
#define AI_H
#include "zynk.h"
/*
* strip_ai_phrases - Remove common AI disclaimer phrases from text.
*
* Scans the input string and removes occurrences of phrases like "as an AI",
* "I'm a large language model", "I don't have personal opinions", etc.
* Matching is case-insensitive. Also strips trailing punctuation and spaces
* left after phrase removal. Used to clean up opencode output before sending
* it to IRC, making responses more natural.
*
* Parameters:
* s - The string to clean in-place. Must be writable.
*
* Returns: void. The string is modified in-place.
*/
void strip_ai_phrases(char *s) {
static const char *phrases[] = {
"as an ai", "as an ai language model", "as an ai assistant",
"i'm an ai", "i am an ai", "i'm just an ai", "i am just an ai",
"as a large language model", "as an llm",
"i'm an ai assistant", "i am an ai assistant",
"i don't have personal", "i do not have personal",
"i don't have emotions", "i do not have emotions",
"i'm a large language model", "i am a large language model",
"i'm an llm", "i am an llm",
};
char buf[6144];
size_t len = strlen(s);
if (len >= sizeof buf) return;
memcpy(buf, s, len + 1);
char *dst = s;
for (char *src = buf; *src; ) {
int skipped = 0;
for (size_t i = 0; i < sizeof phrases / sizeof phrases[0]; i++) {
size_t plen = strlen(phrases[i]);
if (strncasecmp(src, phrases[i], plen) == 0) {
src += plen;
while (*src == ' ' || *src == ',' || *src == '.' || *src == '!' || *src == '?') src++;
skipped = 1;
break;
}
}
if (!skipped) *dst++ = *src++;
}
*dst = 0;
}
/*
* strip_opencode_header - Strip the opencode header line from AI output.
*
* Removes the leading header line produced by opencode (e.g.
* "> plan · big-pickle " or "> build · big-pickle ") and any blank
* lines that follow it. Must be called AFTER strip_formatting() has
* removed ANSI escape codes so the '>' prefix is at position 0.
* If the string does not start with '>', it is left unchanged.
*
* Parameters:
* s - The string to modify in-place. Must be writable.
*
* Returns: void. The string is modified in-place.
*/
void strip_opencode_header(char *s) {
while (*s == '\n') {
memmove(s, s + 1, strlen(s));
}
if (s[0] != '>') return;
char *nl = strchr(s, '\n');
if (!nl) { s[0] = 0; return; }
char *p = nl + 1;
while (*p == '\n') p++;
size_t len = strlen(p);
memmove(s, p, len + 1);
}
/* ---- AI pipeline ---- */
volatile pid_t pending_ai_pid;
volatile long long pending_ai_id;
const char *g_opencode_bin = OPENCODE_BIN;
time_t last_ai_completion;
/*
* ai_child_task - Child process entry point for running an AI query.
*
* Runs in a forked child process. Sets an alarm timeout (AI_TIMEOUT seconds),
* closes the parent's socket fd (but doesn't TLS-shutdown to avoid breaking
* the parent's connection), opens its own DB handle, retrieves the question
* and recent answer history, builds a prompt prefix with system instructions
* (different for "build" agent vs. "plan" agent), then fork+exec's opencode
* with the combined prompt. Reads up to 6KB of output, strips formatting
* and AI phrases, stores the result in the database, and exits. The parent
* polls for completion and delivers the answer to IRC.
*
* Parameters:
* s - The IRC session (only fd is used; ssl is intentionally not touched).
* id - The ai_pending row ID for this query.
* agent - The opencode agent name ("build" for code changes, NULL for plan mode).
*
* Returns: Does not return (calls exit(0) or exit(1)).
*/
void ai_child_task(Session *s, long long id, const char *agent) {
signal(SIGALRM, SIG_DFL);
alarm(AI_TIMEOUT);
/* Don't call tls_cleanup() — SSL_shutdown would send a close_notify on
* the shared fd, breaking the parent's connection. Just release our
* fd reference; the kernel will close it on exit. */
if (s->fd >= 0) close(s->fd);
s->fd = -1;
s->ssl = NULL;
db_close(DB);
db_handle = NULL;
if (db_open("zynk.db", &db_handle) != SQLITE_OK) exit(1);
char *q = ai_get_question(id);
if (!q) { db_close(DB); exit(1); }
char *history[MAX_HISTORY];
char hkey[MAX_HISTORY][12];
for (int i = 0; i < MAX_HISTORY; i++) {
snprintf(hkey[i], sizeof hkey[i], "lasta%02d", i + 1);
history[i] = db_get_raw(hkey[i]);
}
int have_history = 0;
for (int i = 0; i < MAX_HISTORY; i++) { if (history[i]) { have_history = 1; break; } }
char prefix[2048];
int plen;
if (agent) {
plen = snprintf(prefix, sizeof prefix,
"Your name is zynk. You are a coding assistant in a project. Execute code changes as requested. "
"Make real file edits and run commands as needed. Answer in English when asked in English, answer in German when asked in German. "
"Max 2000 chars. No disclaimers or AI-sounding phrases. No markdown/formatting. "
"Just make the changes directly and confirm what you did.");
} else {
plen = snprintf(prefix, sizeof prefix,
"Your name is zynk. You are in plan mode. Answer briefly. Answer in English when asked in English, answer in German when asked in German. "
"Max 1000 chars. No disclaimers or AI-sounding phrases. No markdown/formatting. "
"Just answer directly.");
}
if (have_history) {
int space = (int)sizeof prefix - plen;
if (space > 0)
plen += snprintf(prefix + plen, space,
" My recent answers for context (only use if absolutely necessary): ");
if (plen >= (int)sizeof prefix) plen = (int)sizeof prefix - 1;
for (int i = 0; i < MAX_HISTORY; i++) {
space = (int)sizeof prefix - plen;
if (space <= 0) break;
if (history[i]) {
plen += snprintf(prefix + plen, space, "[%s] ", history[i]);
if (plen >= (int)sizeof prefix) plen = (int)sizeof prefix - 1;
}
}
}
for (int i = 0; i < MAX_HISTORY; i++) free(history[i]);
char combined[8192];
snprintf(combined, sizeof combined, "%s%s", prefix, q ? q : "");
free(q);
int ai_pipe[2];
if (pipe(ai_pipe) < 0) { ai_set_answer(id, "AI query failed (pipe error)"); db_close(DB); exit(0); }
pid_t cmd_pid = fork();
if (cmd_pid < 0) { close(ai_pipe[0]); close(ai_pipe[1]); ai_set_answer(id, "AI query failed (fork error)"); db_close(DB); exit(0); }
if (cmd_pid == 0) {
close(ai_pipe[0]);
dup2(ai_pipe[1], STDOUT_FILENO);
dup2(ai_pipe[1], STDERR_FILENO);
close(ai_pipe[1]);
if (agent)
execl(g_opencode_bin, "opencode", "run", "--dangerously-skip-permissions", "--agent", agent, combined, (char *)NULL);
else
execl(g_opencode_bin, "opencode", "run", "--agent", "plan", combined, (char *)NULL);
_exit(127);
}
close(ai_pipe[1]);
char out[6144], tmp[1024];
size_t olen = 0;
ssize_t n;
while ((n = read(ai_pipe[0], tmp, sizeof tmp)) > 0) {
size_t avail = sizeof out - 1 - olen;
if (avail > 0) {
size_t copy = (size_t)n < avail ? (size_t)n : avail;
memcpy(out + olen, tmp, copy);
olen += copy;
}
}
close(ai_pipe[0]);
int status;
waitpid(cmd_pid, &status, 0);
while (olen > 0 && (out[olen-1] == '\n' || out[olen-1] == '\r')) olen--;
out[olen] = 0;
int rc = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
if (rc != 0) {
char errmsg[4200];
strip_formatting(out);
if (olen > 0)
snprintf(errmsg, sizeof errmsg, "AI error (exit %d): %.4000s", rc, out);
else
snprintf(errmsg, sizeof errmsg, "AI error (exit %d, no output)", rc);
ai_set_answer(id, errmsg);
} else if (olen == 0) {
ai_set_answer(id, "AI returned an empty response");
} else {
strip_formatting(out);
strip_opencode_header(out);
strip_ai_phrases(out);
collapse_spaces(out);
ai_set_answer(id, out);
db_update_lasta(out);
}
db_close(DB);
exit(0);
}
/*
* ai_ask - Queue a new AI query and spawn a child process to handle it.
*
* Rate-limits the requesting nick, checks that no other AI query is
* already in progress (only one concurrent query allowed), records the
* question in the history ring buffer, creates a database record via
* ai_start(), then fork()s a child to run ai_child_task(). Stores the
* child PID and query ID in global variables for polling in the main loop.
* Logs the query to stderr.
*
* Parameters:
* s - The IRC session.
* question - The user's question or instruction.
* reply_target - Channel or nick where the answer should be sent.
* src_nick - The IRC nick of the user who asked.
* agent - The opencode agent ("build" for code, NULL for plan), or
* "plan" passed as string for plan-mode queries.
*
* Returns: void. Sends error messages to IRC on failure.
*/
void ai_ask(Session *s, const char *question, const char *reply_target, const char *src_nick, const char *agent) {
if (rate_limit_check(src_nick, reply_target, current_userhost) < 0) return;
if (pending_ai_pid > 0) {
irc_reply(s, reply_target, src_nick, "Already processing an AI query, please wait.");
return;
}
if (last_ai_completion > 0) {
time_t now = time(NULL);
if (now - last_ai_completion < AI_COOLDOWN_SECS) {
char msg[128];
snprintf(msg, sizeof msg, "AI cooldown, please wait %lld seconds.",
(long long)(AI_COOLDOWN_SECS - (now - last_ai_completion)));
irc_reply(s, reply_target, src_nick, msg);
return;
}
}
db_update_lastq(question);
long long id = 0;
if (ai_start(question, reply_target, src_nick, &id) != 0) {
irc_reply(s, reply_target, src_nick, "Error starting AI query");
return;
}
pid_t pid = fork();
if (pid == -1) { ai_reset(id); irc_reply(s, reply_target, src_nick, "Error forking"); return; }
if (pid == 0) { ai_child_task(s, id, agent); }
pending_ai_pid = pid;
pending_ai_id = id;
log_stamp(); fprintf(stderr, CLR_BLUE "AI started (PID %d, ID %lld) from %s:" CLR_RESET " %s\n", pid, id, src_nick, question);
}
/*
* ai_check_completion - Poll for completed AI child processes.
*
* Called from the main poll loop on every iteration. Uses waitpid(WNOHANG)
* to check if the pending AI child has exited without blocking. If done,
* retrieves the answer from the database, splits on the \x1f separator to
* extract the delivery target, and sends the answer to IRC in chunks of
* up to AI_CHUNK_MAX characters (word-wrapped). Logs the result. If the
* query was a code change request (!code), triggers try_compile_and_restart()
* after delivering the answer. Handles timeouts (SIGALRM) and other failures
* with appropriate error messages.
*
* Parameters:
* s - The IRC session.
*
* Returns: void.
*/
void ai_check_completion(Session *s) {
if (pending_ai_pid <= 0) return;
int wstatus;
pid_t done_pid = pending_ai_pid;
long long done_id = pending_ai_id;
if (waitpid(pending_ai_pid, &wstatus, WNOHANG) <= 0) return;
pending_ai_pid = 0;
last_ai_completion = time(NULL);
char *nick = ai_get_nick(done_id);
char *result = ai_get_answer(done_id);
int is_code_change = 0;
char *question = ai_get_question(done_id);
if (question && strncmp(question, "[CODE CHANGE REQUEST", 20) == 0) is_code_change = 1;
free(question);
if (result) {
char *answer = result;
char *sep = strchr(result, '\037');
char *target = NULL;
if (sep) { *sep = 0; target = sep + 1; }
if (target) {
size_t alen = strlen(answer);
#define AI_CHUNK_MAX 450
size_t pos = 0;
int chunks = 0;
while (pos < alen) {
size_t end = pos + AI_CHUNK_MAX;
if (end >= alen) {
irc_reply(s, target, nick, answer + pos);
chunks++;
break;
}
while (end > pos && answer[end] != ' ') end--;
if (end <= pos) end = pos + AI_CHUNK_MAX;
char save = answer[end];
answer[end] = 0;
irc_reply(s, target, nick, answer + pos);
answer[end] = save;
pos = end + 1;
chunks++;
if (pos < alen) { struct timespec ts = { .tv_sec = 0, .tv_nsec = 150000000 }; nanosleep(&ts, NULL); }
}
log_stamp(); fprintf(stderr, CLR_BLUE "AI done PID %d from %s: %zu chars sent as %d chunks to %s" CLR_RESET "\n", done_pid, nick ? nick : "?", alen, chunks, target);
if (is_code_change) try_compile_and_restart(s, target);
}
free(result);
ai_reset(done_id);
} else {
char *target = NULL;
void *stmt;
if (db_prepare(DB, "SELECT target FROM ai_pending WHERE id=?", -1, &stmt, NULL) == SQLITE_OK) {
db_bind_int64(stmt, 1, done_id);
if (db_step(stmt) == SQLITE_ROW) {
const char *t = (const char*)db_column_text(stmt, 0);
if (t) target = strdup(t);
}
db_finalize(stmt);
}
if (WIFSIGNALED(wstatus) && WTERMSIG(wstatus) == SIGALRM) {
if (target) {
char msg[96];
snprintf(msg, sizeof msg, "AI query timed out after %d seconds.", AI_TIMEOUT);
irc_reply(s, target, nick, msg);
log_stamp(); fprintf(stderr, CLR_RED "AI done PID %d from %s: timed out for %s" CLR_RESET "\n", done_pid, nick ? nick : "?", target);
}
} else if (target) {
irc_reply(s, target, nick, "AI query failed.");
log_stamp(); fprintf(stderr, CLR_RED "AI done PID %d from %s: failed for %s" CLR_RESET "\n", done_pid, nick ? nick : "?", target);
}
free(target);
ai_reset(done_id);
}
free(nick);
}
#endif