zynk/ai.h
hanez 739e3622b5 Commit links for !gitlog, full changelog link for !changelog, CMake, multi-file refactor
- !gitlog now shows a link to each commit in the repository, using the
  git remote URL converted from SSH/SCP to HTTPS
- !changelog now shows a 'Full changelog: <url>' link to CHANGELOG.md
  in the repository as the last message after all entries
- Removed 'full' keyword from !gitlog and !changelog (previously showed
  full commit messages and complete changelog entries)
- Switched build system from Make to CMake with feature toggles
  (-DWITH_<FEATURE>=ON/OFF)
- Refactored single-file codebase into dedicated header files: each
  command now lives in its own cmd_*.h file, with separate modules for
  database (db.h), IRC protocol (irc.h), networking (net.h), TLS (tls.h),
  and AI (ai.h)
- Updated README.md to reflect the simplified !gitlog and !changelog
  syntax and the new CMake build instructions

(0.40.0)
2026-07-23 01:46:37 +02:00

346 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;
/*
* 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 - you can only answer questions and discuss code, "
"but you CANNOT make any changes to files or execute commands. If someone asks you to change or edit code, "
"tell them you are in plan mode and can only answer questions. Suggest they use !code <instruction> to make actual code changes. "
"Answer briefly. Answer in English when asked in English, answer in German when asked in German. Max 800 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", "--dangerously-skip-permissions", "--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) < 0) return;
if (pending_ai_pid > 0) {
irc_msg(s, reply_target, "Already processing an AI query, please wait.");
return;
}
db_update_lastq(question);
long long id = 0;
if (ai_start(question, reply_target, src_nick, &id) != 0) {
irc_msg(s, reply_target, "Error starting AI query");
return;
}
pid_t pid = fork();
if (pid == -1) { ai_reset(id); irc_msg(s, reply_target, "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;
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_msg(s, target, 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_msg(s, target, answer + pos);
answer[end] = save;
pos = end + 1;
chunks++;
}
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_msg(s, target, 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_msg(s, target, "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