Compare commits

..

7 Commits

Author SHA1 Message Date
0096b11d24 fix(ui): reset paged flag at buffer bottom for non-chat windows
All checks were successful
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 2m43s
CI Code / Linux (debian) (pull_request) Successful in 4m43s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m56s
CI Code / Linux (arch) (pull_request) Successful in 5m48s
2026-05-15 14:32:34 +00:00
06b80bc89a fix(ai): fix memory leak of local provider in error paths
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 2m44s
CI Code / Linux (debian) (pull_request) Successful in 4m42s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m56s
CI Code / Linux (arch) (pull_request) Successful in 5m49s
CI Code / Check spelling (push) Successful in 16s
CI Code / Check coding style (push) Successful in 31s
CI Code / Code Coverage (push) Successful in 2m42s
CI Code / Linux (debian) (push) Successful in 4m45s
CI Code / Linux (ubuntu) (push) Successful in 4m57s
CI Code / Linux (arch) (push) Successful in 5m50s
Add missing unref calls for local_provider in _ai_request_thread
error handling to prevent memory leaks.
2026-05-15 14:14:11 +00:00
f9e0ba9630 fix(ai): fix memory leaks in ai session handling
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 38s
CI Code / Code Coverage (pull_request) Successful in 2m55s
CI Code / Linux (debian) (pull_request) Successful in 4m39s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m51s
CI Code / Linux (arch) (pull_request) Successful in 5m34s
Add missing `ai_session_unref` calls to prevent memory leaks.

- In `_ai_request_thread`, release session on error paths and
  after successful processing.
- In `cmd_ai_start`, release reference after passing ownership
  to the AI window.
2026-05-15 11:58:25 +00:00
7469f31c78 fix(ai): fix memory leaks in _ai_request_thread
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 34s
CI Code / Code Coverage (pull_request) Successful in 2m43s
CI Code / Linux (debian) (pull_request) Successful in 4m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m55s
CI Code / Linux (arch) (pull_request) Successful in 5m45s
Replace manual g_free calls with auto_gchar for local_provider_name,
local_model, local_api_key, and response_data to ensure automatic
cleanup and prevent memory leaks.
2026-05-15 11:29:10 +00:00
5e329c77e1 fix(ai): fix memory leak in AI message stanza ID
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 2m43s
CI Code / Linux (debian) (pull_request) Successful in 4m41s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m55s
CI Code / Linux (arch) (pull_request) Successful in 5m44s
Store stanza ID in an auto_gchar variable to ensure automatic memory cleanup.
2026-05-15 08:55:56 +00:00
9e5dfb14f8 feat(ai): add AI client with multi-provider chat support
All checks were successful
CI Code / Check spelling (push) Successful in 18s
CI Code / Check coding style (push) Successful in 29s
CI Code / Code Coverage (push) Successful in 2m44s
CI Code / Linux (debian) (push) Successful in 4m46s
CI Code / Linux (ubuntu) (push) Successful in 4m59s
CI Code / Linux (arch) (push) Successful in 5m56s
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom endpoints) to provide
AI-assisted chat within CProof. Users can start sessions with /ai start,
send prompts, receive responses in a dedicated AI window, switch between
providers and models, and manage API keys — all with tab-completion.

Providers are configured via /ai set commands with per-provider API keys,
endpoints, default models, and custom settings. Two default providers
(openai, perplexity) are seeded on first use. Provider state persists in
[ai/<name>] sections of the preferences keyfile with automatic migration
from the previous flat-key format.

The /ai command integrates into the existing command system with 8
subcommands covering provider management, session lifecycle, model
fetching, and conversation clearing. Autocomplete uses the standard
flat prefix-matching chain for reliable tab-completion at every nesting
level. A new ProfAiWin window type is added to the window system.

Architecture:

Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls
Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call
Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s)
Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training
Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM
JSON parsing uses unified helpers for both chat responses and error
envelopes with consistent escape decoding. The response parser tries
Perplexity /v1/responses "text" field first, then falls back to OpenAI
"content". Error parsing extracts provider error.message from the
standard envelope format. Model parsing handles multiple API response
formats (OpenAI list, Perplexity, array) including edge cases.

Tests include 470+ lines of unit tests covering provider management,
session lifecycle, JSON parsing (multiple formats), autocomplete cycling,
and error handling, plus functional tests for /ai command dispatch.
A stub_ai.c module isolates unit tests from UI dependencies.
2026-05-15 02:21:54 +00:00
1aaa382d5e fix(verify): per-contact context in output, demote duplicate stanza-id to debug
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 31s
CI Code / Code Coverage (pull_request) Successful in 2m39s
CI Code / Linux (debian) (pull_request) Successful in 4m39s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m51s
CI Code / Linux (arch) (pull_request) Successful in 5m40s
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 36s
CI Code / Code Coverage (push) Successful in 2m42s
CI Code / Linux (debian) (push) Successful in 4m43s
CI Code / Linux (ubuntu) (push) Successful in 4m58s
CI Code / Linux (arch) (push) Successful in 5m44s
2026-05-06 17:02:27 +03:00
6 changed files with 73 additions and 39 deletions

View File

@@ -1419,10 +1419,10 @@ _ai_request_thread(gpointer data)
* list modifications). We must snapshot all fields atomically.
* ===================================================================== */
pthread_mutex_lock(&session->lock);
gchar* local_provider_name = g_strdup(session->provider_name);
auto_gchar gchar* local_provider_name = g_strdup(session->provider_name);
AIProvider* local_provider = ai_provider_ref(session->provider);
gchar* local_model = g_strdup(session->model);
gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
auto_gchar gchar* local_model = g_strdup(session->model);
auto_gchar gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
pthread_mutex_unlock(&session->lock);
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
@@ -1434,6 +1434,8 @@ _ai_request_thread(gpointer data)
local_provider_name, local_provider_name);
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
_aiwin_display_error(user_data, error_msg);
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1444,6 +1446,8 @@ _ai_request_thread(gpointer data)
log_error("AI request failed for %s/%s: Failed to initialize curl",
local_provider_name, local_model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
ai_provider_unref(local_provider);
ai_session_unref(session);
g_free(args);
return NULL;
}
@@ -1492,6 +1496,7 @@ _ai_request_thread(gpointer data)
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
auto_gchar gchar* response_data = g_steal_pointer(&response.data);
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
@@ -1500,17 +1505,14 @@ _ai_request_thread(gpointer data)
} else if (http_code >= 400) {
/* Handle HTTP errors */
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response.data ? response.data : "NULL");
response.size, response_data);
/* Try to extract the actual error message from the JSON response */
auto_gchar gchar* parsed_error = ai_parse_error_message(response.data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
auto_gchar gchar* parsed_error = ai_parse_error_message(response_data);
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error");
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
/* Parse response - transfer ownership to auto_gchar for cleanup */
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
auto_gchar gchar* response_data = response.data;
response.data = NULL;
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response_data);
auto_gchar gchar* content = ai_parse_response(response_data);
if (content) {
/* Add assistant response to history (under lock) */
@@ -1531,12 +1533,9 @@ _ai_request_thread(gpointer data)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
/* Cleanup local copies */
g_free(local_provider_name);
ai_provider_unref(local_provider);
g_free(local_model);
g_free(local_api_key);
ai_session_unref(session);
g_free(args);
return NULL;

View File

@@ -8516,7 +8516,8 @@ _cmd_execute_default(ProfWin* window, const char* inp)
case WIN_AI:
{
ProfAiWin* aiwin = (ProfAiWin*)window;
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
auto_gchar gchar* stanza_id = connection_create_stanza_id();
cl_ev_send_ai_msg(aiwin, inp, stanza_id);
break;
}
default:
@@ -10817,7 +10818,6 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
cons_show("Use '/ai models <provider>' to fetch available models.");
cons_show("Available models: https://models.litellm.ai/");
return TRUE;
}
@@ -10981,6 +10981,9 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
}
log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type);
// Release the reference held by cmd_ai_start, since the window now holds one
ai_session_unref(session);
// Add welcome message to the AI window
log_debug("[AI-CMD] Adding welcome messages...");
win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model);

View File

@@ -211,9 +211,13 @@ _first_pass(FILE* fp, const char* basename,
if (pl->stanza_id && pl->stanza_id[0] != '\0') {
if (g_hash_table_contains(seen_stanza_ids, pl->stanza_id)) {
*issues = g_slist_prepend(*issues,
_issue_new(INTEGRITY_WARNING, basename, lineno,
g_strdup_printf("Duplicate stanza-id \"%s\"", pl->stanza_id)));
// Older clients (Pidgin, Adium, even early Profanity)
// sometimes reuse client-generated stanza-ids across
// distinct messages, so a collision is not a real
// integrity defect — log it for diagnostics but don't
// surface it through /history verify.
log_debug("flatfile verify: duplicate stanza-id \"%s\" at %s:%d",
pl->stanza_id, basename, lineno);
} else {
g_hash_table_insert(seen_stanza_ids, g_strdup(pl->stanza_id), GINT_TO_POINTER(lineno));
}
@@ -302,7 +306,13 @@ _verify_contact_dir(const char* cdir_path, GSList** issues)
return;
}
const char* basename = "history.log";
// Reconstruct the contact JID from the directory name so issue reports
// identify which contact the problem belongs to (every contact has its
// own history.log, so the bare filename is ambiguous).
auto_gchar gchar* dir_name = g_path_get_basename(cdir_path);
auto_gcharv gchar** parts = g_strsplit(dir_name, "_at_", -1);
auto_gchar gchar* contact_jid = g_strjoinv("@", parts);
auto_gchar gchar* basename = g_strdup_printf("%s/history.log", contact_jid);
_check_permissions(filepath, basename, issues);

View File

@@ -466,23 +466,36 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
sqlite3_finalize(stmt);
}
// Check timestamp ordering for a specific contact or all
// Check timestamp ordering, restricted to consecutive rows belonging
// to the same conversation pair so adjacency does not span unrelated
// contacts. Adjacent rows by id from different conversations are
// legitimately out-of-order and should not be reported.
const Jid* myjid = connection_get_jid();
if (myjid && myjid->barejid) {
auto_sqlite char* query = NULL;
if (contact_barejid) {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"WHERE A.`timestamp` > B.`timestamp` "
"AND ((A.`from_jid` = %Q AND A.`to_jid` = %Q) OR (A.`from_jid` = %Q AND A.`to_jid` = %Q)) "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50",
contact_barejid, myjid->barejid, myjid->barejid, contact_barejid);
} else {
query = sqlite3_mprintf(
"SELECT A.`id`, A.`timestamp`, B.`id`, B.`timestamp` FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`id` = A.`id` + 1 "
"SELECT A.`id`, A.`timestamp`, A.`from_jid`, A.`to_jid`, B.`id`, B.`timestamp` "
"FROM `ChatLogs` A "
"JOIN `ChatLogs` B ON B.`from_jid` = A.`from_jid` AND B.`to_jid` = A.`to_jid` "
" AND B.`id` > A.`id` "
"WHERE A.`timestamp` > B.`timestamp` "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` C "
" WHERE C.`from_jid` = A.`from_jid` AND C.`to_jid` = A.`to_jid` "
" AND C.`id` > A.`id` AND C.`id` < B.`id`) "
"LIMIT 50");
}
@@ -490,15 +503,19 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id_a = sqlite3_column_int(stmt, 0);
const char* ts_a = (const char*)sqlite3_column_text(stmt, 1);
int id_b = sqlite3_column_int(stmt, 2);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 3);
const char* from_a = (const char*)sqlite3_column_text(stmt, 2);
const char* to_a = (const char*)sqlite3_column_text(stmt, 3);
int id_b = sqlite3_column_int(stmt, 4);
const char* ts_b = (const char*)sqlite3_column_text(stmt, 5);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_WARNING;
issue->file = g_strdup("chatlog.db");
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from_a ? from_a : "?", to_a ? to_a : "?");
issue->line = id_a;
issue->message = g_strdup_printf("Timestamp out of order: row %d (%s) > row %d (%s)",
id_a, ts_a ? ts_a : "NULL", id_b, ts_b ? ts_b : "NULL");
id_a, ts_a ? ts_a : "NULL",
id_b, ts_b ? ts_b : "NULL");
issues = g_slist_append(issues, issue);
}
sqlite3_finalize(stmt);
@@ -506,7 +523,7 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
// Check broken LMC references
auto_sqlite char* lmc_query = sqlite3_mprintf(
"SELECT A.`id`, A.`replaces_db_id` FROM `ChatLogs` A "
"SELECT A.`id`, A.`replaces_db_id`, A.`from_jid`, A.`to_jid` FROM `ChatLogs` A "
"WHERE A.`replaces_db_id` IS NOT NULL "
"AND NOT EXISTS (SELECT 1 FROM `ChatLogs` B WHERE B.`id` = A.`replaces_db_id`) "
"LIMIT 50");
@@ -515,10 +532,13 @@ _sqlite_verify_integrity(const gchar* const contact_barejid)
while (sqlite3_step(stmt) == SQLITE_ROW) {
int id = sqlite3_column_int(stmt, 0);
int replaces_id = sqlite3_column_int(stmt, 1);
const char* from = (const char*)sqlite3_column_text(stmt, 2);
const char* to = (const char*)sqlite3_column_text(stmt, 3);
integrity_issue_t* issue = g_malloc0(sizeof(integrity_issue_t));
issue->level = INTEGRITY_ERROR;
issue->file = g_strdup("chatlog.db");
issue->file = g_strdup_printf("%s↔%s/chatlog.db",
from ? from : "?", to ? to : "?");
issue->line = id;
issue->message = g_strdup_printf("Broken LMC reference: row %d references non-existent row %d",
id, replaces_id);

View File

@@ -848,6 +848,14 @@ win_page_down(ProfWin* window, int scroll_size)
window->layout->paged = 0;
window->layout->unread_msg = 0;
}
// Non-chat windows have no DB to fetch from, so WIN_SCROLL_REACHED_BOTTOM
// never fires; reset paged once the buffer's last line is on screen.
if (window->type != WIN_CHAT
&& (total_rows - *page_start) <= page_space + 1) {
window->layout->paged = 0;
window->layout->unread_msg = 0;
}
}
void

View File

@@ -1,3 +1,4 @@
#include "glib.h"
#include "prof_cmocka.h"
#include "common.h"
#include "ai/ai_client.h"
@@ -703,7 +704,6 @@ test_ai_parse_models_openai_format(void** state)
models = g_list_next(models);
assert_string_equal("google/gemini-3-flash-preview", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -776,7 +776,6 @@ test_ai_parse_models_perplexity_format(void** state)
models = g_list_last(models);
assert_string_equal("xai/grok-4.3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -801,7 +800,6 @@ test_ai_parse_models_array_format(void** state)
models = g_list_next(models);
assert_string_equal("model3", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -821,7 +819,6 @@ test_ai_parse_models_empty_json(void** state)
assert_null(provider->models);
assert_int_equal(0, g_list_length(provider->models));
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -837,7 +834,6 @@ test_ai_parse_models_null_handling(void** state)
ai_parse_models_from_json(provider, NULL);
assert_null(provider->models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -861,7 +857,6 @@ test_ai_parse_models_escaped_quotes(void** state)
GList* models = provider->models;
assert_non_null(models);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -885,7 +880,6 @@ test_ai_parse_models_with_whitespace(void** state)
GList* models = provider->models;
assert_string_equal("model-with-spaces", models->data);
ai_provider_unref(provider);
ai_client_shutdown();
}
@@ -1142,7 +1136,7 @@ test_ai_providers_find_cycles_through_many(void** state)
ai_add_provider("alpine", "https://al.example/");
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
for (int i = 0; i < 3; i++) {
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);