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().
356 lines
13 KiB
C
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
|