#ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #include "cJSON.h" #include "cst_agent.h" #include "os_compat.h" #include #include #include #include #include #include #ifdef _WIN32 #include #define OS_ACCESS _access #define OS_F_OK 0 #else #include #define OS_ACCESS access #define OS_F_OK F_OK #endif /* --- the store's shape ---------------------------------------------------- * A session owns a directory: everything the session keeps is inside it, so * "delete this conversation" is one rmdir and nothing has to be found by * guesswork (a phone's app data directory has no user to tidy it by hand). * * /.agent/sessions/ * /session.jsonl the transcript * /title the window title, one line * /attachments/ what the messages carry: images, delivered * files (.png, art-.md, ...) * /sub-1.jsonl a sub-agent's own transcript * CURRENT the id the next run opens * * Sessions written before this layout are flat, with one shared attachment * directory, and keep working: every reader here takes either shape and * cst_agent_store_migrate() (--migrate-store) moves them in. * * .jsonl * .title * attachments//... (shared by every session) * /sub-1.jsonl (already per session) */ int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) { if (!workspace || !out || out_sz < 16) return -1; return os_path_join(out, out_sz, workspace, ".agent/sessions"); } int cst_agent_store_ensure(const char *workspace) { char base[OS_PATH_MAX]; char sess[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, sess, sizeof(sess)) != 0) return -1; if (os_path_join(base, sizeof(base), workspace, ".agent") != 0) return -1; if (os_mkdir(base, 0755) != 0) return -1; if (os_mkdir(sess, 0755) != 0) return -1; /* keep sessions, undo snapshots and exports out of git */ char gi[OS_PATH_MAX]; if (os_path_join(gi, sizeof(gi), base, ".gitignore") == 0 && OS_ACCESS(gi, OS_F_OK) != 0) { FILE *fp = fopen(gi, "wb"); if (fp) { fputs("*\n", fp); fclose(fp); } } return 0; } /* An id is a directory name: anything that could leave the store, and the two * names the store itself uses, is not one. */ static int id_ok(const char *id) { if (!id || !id[0] || id[0] == '.') return 0; for (const char *p = id; *p; p++) { char c = *p; if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.')) return 0; } if (strstr(id, "..")) return 0; return strcmp(id, "CURRENT") != 0 && strcmp(id, "attachments") != 0; } /* 16 random bytes, whatever the platform offers; the clock and the pid are * the fallback (a collision only costs a reopen, not data). */ static void random_bytes(unsigned char *out, size_t n) { FILE *f = fopen("/dev/urandom", "rb"); if (f) { size_t got = fread(out, 1, n, f); fclose(f); if (got == n) return; } static unsigned seq; unsigned long long x = (unsigned long long)time(NULL) ^ ((unsigned long long)os_getpid() << 20) ^ ((unsigned long long)++seq << 8); for (size_t i = 0; i < n; i++) { x = x * 6364136223846793005ULL + 1442695040888963407ULL; out[i] = (unsigned char)(x >> 33); } } /* A uuid v4: unique across machines and time, which a timestamp is not (two * phones, one synced workspace, and the ids have to stay apart). */ int cst_agent_store_new_id(char *id_out, size_t id_sz) { if (!id_out || id_sz < 37) return -1; unsigned char b[16]; random_bytes(b, sizeof(b)); b[6] = (unsigned char)((b[6] & 0x0f) | 0x40); /* version 4 */ b[8] = (unsigned char)((b[8] & 0x3f) | 0x80); /* variant 1 */ snprintf(id_out, id_sz, "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-" "%02x%02x%02x%02x%02x%02x", b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); return 0; } /* /, the session's own directory (not made here). */ static int dir_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { if (!workspace || !id_ok(id)) return -1; char dir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return -1; return os_path_join(out, out_sz, dir, id); } /* /.jsonl -- the shape before per-session directories. */ static int flat_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { if (!workspace || !id_ok(id)) return -1; char dir[OS_PATH_MAX], leaf[192]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return -1; snprintf(leaf, sizeof(leaf), "%s.jsonl", id); return os_path_join(out, out_sz, dir, leaf); } /* The transcript of an id in the per-session layout. */ static int nested_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { char d[OS_PATH_MAX]; if (dir_for_id(workspace, id, d, sizeof(d)) != 0) return -1; return os_path_join(out, out_sz, d, "session.jsonl"); } int cst_agent_store_id_of_path(const char *session_path, char *out, size_t out_sz) { if (!session_path || !session_path[0] || !out || out_sz < 2) return -1; const char *base = session_path; for (const char *p = session_path; *p; p++) if (*p == '/' || *p == '\\') base = p + 1; size_t n = strlen(base); if (n > 6 && strcmp(base + n - 6, ".jsonl") == 0) { size_t stem = n - 6; if (stem == 7 && strncmp(base, "session", 7) == 0) { /* /session.jsonl: the id is the directory the file sits in. */ if (base == session_path) return -1; /* a bare "session.jsonl" has no session to name */ const char *end = base - 1; /* the separator before the leaf */ const char *start = end; while (start > session_path && start[-1] != '/' && start[-1] != '\\') start--; size_t dn = (size_t)(end - start); if (!dn || dn >= out_sz) return -1; memcpy(out, start, dn); out[dn] = 0; return 0; } n = stem; } if (!n || n >= out_sz) return -1; memcpy(out, base, n); out[n] = 0; return 0; } /* Which shape this id has. 1 = its own directory, 0 = flat, -1 = neither * (and not a usable id). */ static int shape_of_id(const char *workspace, const char *id) { char p[OS_PATH_MAX]; os_stat_t st; if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 && !st.is_dir) return 1; if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 && !st.is_dir) return 0; return -1; } int cst_agent_store_path_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { if (!workspace || !id || !out) return -1; /* Accept bare id or full path. */ if (strchr(id, '/') || strchr(id, '\\')) { snprintf(out, out_sz, "%s", id); return 0; } int shape = shape_of_id(workspace, id); if (shape == 0) return flat_for_id(workspace, id, out, out_sz); if (nested_for_id(workspace, id, out, out_sz) != 0) return -1; if (shape < 0) { /* A new session: its directory is made here, so whoever opens the * transcript next has a place to put it (and its attachments). */ char d[OS_PATH_MAX]; if (dir_for_id(workspace, id, d, sizeof(d)) == 0) os_mkdir_p(d, 0700); } return 0; } int cst_agent_store_set_current(const char *workspace, const char *id) { if (cst_agent_store_ensure(workspace) != 0) return -1; char dir[OS_PATH_MAX]; char path[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return -1; if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0) return -1; FILE *fp = fopen(path, "wb"); if (!fp) return -1; fprintf(fp, "%s\n", id); fclose(fp); return 0; } int cst_agent_store_get_current(const char *workspace, char *id_out, size_t id_sz) { char dir[OS_PATH_MAX]; char path[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return -1; if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0) return -1; FILE *fp = fopen(path, "rb"); if (!fp) return -1; if (!fgets(id_out, (int)id_sz, fp)) { fclose(fp); return -1; } fclose(fp); size_t n = strlen(id_out); while (n > 0 && (id_out[n - 1] == '\n' || id_out[n - 1] == '\r')) id_out[--n] = 0; return id_out[0] ? 0 : -1; } int cst_agent_store_resolve_default(const char *workspace, char *path_out, size_t path_sz, char *id_out, size_t id_sz) { if (cst_agent_store_ensure(workspace) != 0) return -1; char id[128]; if (cst_agent_store_get_current(workspace, id, sizeof(id)) == 0) { char p[OS_PATH_MAX]; if (cst_agent_store_path_for_id(workspace, id, p, sizeof(p)) == 0 && OS_ACCESS(p, OS_F_OK) == 0) { snprintf(path_out, path_sz, "%s", p); if (id_out && id_sz) snprintf(id_out, id_sz, "%s", id); return 0; } } if (cst_agent_store_new_id(id, sizeof(id)) != 0) return -1; if (cst_agent_store_path_for_id(workspace, id, path_out, path_sz) != 0) return -1; cst_agent_store_set_current(workspace, id); if (id_out && id_sz) snprintf(id_out, id_sz, "%s", id); return 0; } int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) { if (!workspace || !ids || max_ids <= 0) return 0; char dir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return 0; os_dir_t *d = os_dir_open(dir); if (!d) return 0; typedef struct { char id[128]; double mtime; } ent_t; ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t)); int n = 0, cap = max_ids * 4; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { if (n >= cap) break; if (de->is_dir) { /* a per-session directory: only one that holds a transcript counts */ if (!id_ok(de->name)) continue; char p[OS_PATH_MAX]; if (nested_for_id(workspace, de->name, p, sizeof(p)) != 0) continue; os_stat_t st; if (os_lstat(p, &st) != 0 || st.is_dir) continue; snprintf(ents[n].id, sizeof(ents[n].id), "%s", de->name); ents[n].mtime = st.mtime; n++; continue; } size_t len = strlen(de->name); if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0) continue; char id[128]; snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name); if (!id_ok(id)) continue; char full[OS_PATH_MAX]; if (os_path_join(full, sizeof(full), dir, de->name) != 0) continue; os_stat_t st; snprintf(ents[n].id, sizeof(ents[n].id), "%s", id); ents[n].mtime = os_lstat(full, &st) == 0 ? st.mtime : 0; n++; } os_dir_close(d); for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (ents[j].mtime > ents[i].mtime) { ent_t tmp = ents[i]; ents[i] = ents[j]; ents[j] = tmp; } } } int out_n = n < max_ids ? n : max_ids; for (int i = 0; i < out_n; i++) snprintf(ids[i], 128, "%s", ents[i].id); free(ents); return out_n; } /* The title of a session: inside its directory in the new layout, a .title * beside the transcript in the old one. */ static int title_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { if (shape_of_id(workspace, id) == 0) { char dir[OS_PATH_MAX]; char leaf[192]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) return -1; snprintf(leaf, sizeof(leaf), "%s.title", id); return os_path_join(out, out_sz, dir, leaf); } char d[OS_PATH_MAX]; if (dir_for_id(workspace, id, d, sizeof(d)) != 0) return -1; return os_path_join(out, out_sz, d, "title"); } int cst_agent_store_title_set(const char *workspace, const char *id, const char *title) { char p[OS_PATH_MAX]; if (title_for_id(workspace, id, p, sizeof(p)) != 0) return -1; if (!title || !title[0]) return remove(p) == 0 || errno == ENOENT ? 0 : -1; FILE *fp = fopen(p, "wb"); if (!fp) return -1; for (const char *c = title; *c; c++) fputc(*c == '\n' || *c == '\r' ? ' ' : *c, fp); fputc('\n', fp); return fclose(fp) == 0 ? 0 : -1; } /* Cut to n bytes without splitting a UTF-8 sequence; newlines → spaces. */ static void clip_line(char *dst, size_t n, const char *src) { size_t i = 0; while (src[i] && i + 1 < n) { dst[i] = (src[i] == '\n' || src[i] == '\r' || src[i] == '\t') ? ' ' : src[i]; i++; } while (i > 0 && src[i] && ((unsigned char)src[i] & 0xC0) == 0x80) i--; dst[i] = 0; } int cst_agent_store_info(const char *workspace, const char *id, cst_session_info_t *info) { if (!info) return -1; memset(info, 0, sizeof(*info)); snprintf(info->id, sizeof(info->id), "%s", id ? id : ""); char p[OS_PATH_MAX]; if (shape_of_id(workspace, id) == 0) { if (flat_for_id(workspace, id, p, sizeof(p)) != 0) return -1; } else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) { return -1; } os_stat_t st; if (os_lstat(p, &st) != 0) return -1; info->mtime = (long long)st.mtime; char tp[OS_PATH_MAX]; if (title_for_id(workspace, id, tp, sizeof(tp)) == 0) { FILE *tf = fopen(tp, "rb"); if (tf) { if (fgets(info->title, sizeof(info->title), tf)) { size_t n = strlen(info->title); while (n && (info->title[n - 1] == '\n' || info->title[n - 1] == '\r')) info->title[--n] = 0; } fclose(tf); } } FILE *fp = fopen(p, "rb"); if (!fp) return 0; /* Reading a transcript must stay cheap: the session list asks for this for * every stored session, on the GUI thread, every time the store changes. * So a line is only ever *parsed* while the first user message is still * missing and only within the first kFirstUserBytes; everything else is * counted by looking for newlines, which costs a memchr per block instead * of a cJSON tree per line. */ enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 }; char *buf = malloc(kBlock); if (!buf) { fclose(fp); return 0; } size_t have = 0; /* bytes in buf that belong to the current line */ size_t seen = 0; /* bytes already consumed by finished lines */ int slice = 0; /* the current line did not fit: its tail is dropped */ int want_first = 1; /* still looking for the first user message */ for (;;) { size_t got = fread(buf + have, 1, kBlock - have, fp); size_t total = have + got; size_t at = 0; for (;;) { char *nl = memchr(buf + at, '\n', total - at); if (!nl) break; const size_t len = (size_t)(nl - (buf + at)); /* A line counts once. A line that ran past a whole block was counted * when its tail was dropped, so the newline that ends it is not a line * of its own. */ if (len > 0 && !slice) info->messages++; if (!slice && len > 0 && want_first && seen < kFirstUserBytes) { *nl = 0; /* the parse reads a C string; the newline is ours */ cJSON *o = cJSON_Parse(buf + at); const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role"); const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content"); const cJSON *k = cJSON_GetObjectItemCaseSensitive( cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind"); const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0; if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note && cJSON_IsString(c) && c->valuestring[0]) { clip_line(info->first_user, sizeof(info->first_user), c->valuestring); want_first = 0; } cJSON_Delete(o); } seen += len + 1; at += len + 1; slice = 0; } have = total - at; if (got == 0) break; /* EOF */ if (have == kBlock) { /* a line longer than one block: count it, drop its tail, keep going */ info->messages++; seen += have; have = 0; slice = 1; } else if (have > 0) { memmove(buf, buf + at, have); } } if (have > 0 && !slice) info->messages++; /* the last line, without a newline */ free(buf); fclose(fp); return 0; } /* Remove a tree (a session's directory: its transcript, its attachments and * its sub-agent files). Links are removed, never followed. */ static int rm_tree(const char *path) { os_stat_t st; if (os_lstat(path, &st) != 0) return -1; if (!st.is_dir) return remove(path) == 0 ? 0 : -1; os_dir_t *d = os_dir_open(path); if (d) { const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { char f[OS_PATH_MAX]; if (os_path_join(f, sizeof(f), path, de->name) == 0) rm_tree(f); } os_dir_close(d); } return rmdir(path) == 0 ? 0 : -1; } int cst_agent_store_delete(const char *workspace, const char *id) { if (!id_ok(id)) return -1; char p[OS_PATH_MAX]; int found = 0; /* both shapes, whichever this session has (or had) */ if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0) found = 1; if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0) found = 1; if (!found) return -1; if (title_for_id(workspace, id, p, sizeof(p)) == 0) remove(p); /* the session's own directory: attachments, sub-agent transcripts */ if (dir_for_id(workspace, id, p, sizeof(p)) == 0) rm_tree(p); /* and the shared attachment directory of the old layout */ char dir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 && os_path_join(p, sizeof(p), dir, "attachments") == 0) { char one[OS_PATH_MAX]; if (os_path_join(one, sizeof(one), p, id) == 0) rm_tree(one); } char cur[128]; if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 && strcmp(cur, id) == 0) { if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 && os_path_join(p, sizeof(p), dir, "CURRENT") == 0) remove(p); } return 0; } /* Move a directory: rename if the OS likes it, otherwise copy file by file * (a synced workspace can be odd about renames). */ static int move_file(const char *from, const char *to) { if (os_rename_replace(from, to) == 0) return 0; FILE *in = fopen(from, "rb"); if (!in) return -1; FILE *out = fopen(to, "wb"); if (!out) { fclose(in); return -1; } char buf[16384]; size_t n; int rc = 0; while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { if (fwrite(buf, 1, n, out) != n) { rc = -1; break; } } fclose(in); if (fclose(out) != 0) rc = -1; if (rc == 0) remove(from); return rc; } static int move_tree(const char *from, const char *to) { os_stat_t st; if (os_lstat(from, &st) != 0) return -1; if (!st.is_dir) return move_file(from, to); if (os_mkdir_p(to, 0700) != 0) return -1; os_dir_t *d = os_dir_open(from); if (!d) return -1; int rc = 0; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { char f[OS_PATH_MAX], t[OS_PATH_MAX]; if (os_path_join(f, sizeof(f), from, de->name) != 0 || os_path_join(t, sizeof(t), to, de->name) != 0 || move_tree(f, t) != 0) rc = -1; } os_dir_close(d); if (rc == 0) rmdir(from); return rc; } int cst_agent_store_migrate(const char *workspace, int *moved_out, int *failed_out, char *err, size_t err_sz) { char dummy[8]; if (!err || !err_sz) { err = dummy; err_sz = sizeof(dummy); } err[0] = 0; if (moved_out) *moved_out = 0; if (failed_out) *failed_out = 0; if (!workspace || !workspace[0]) { snprintf(err, err_sz, "no workspace"); return -1; } if (cst_agent_store_ensure(workspace) != 0) { snprintf(err, err_sz, "cannot use the store under %s", workspace); return -1; } char dir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) { snprintf(err, err_sz, "no sessions directory"); return -1; } os_dir_t *d = os_dir_open(dir); if (!d) { snprintf(err, err_sz, "cannot read %s", dir); return -1; } int moved = 0, failed = 0; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { if (de->is_dir) continue; size_t len = strlen(de->name); if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0) continue; char id[128]; snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name); if (!id_ok(id)) continue; char nested_dir[OS_PATH_MAX], nested[OS_PATH_MAX], flat[OS_PATH_MAX]; if (dir_for_id(workspace, id, nested_dir, sizeof(nested_dir)) != 0 || nested_for_id(workspace, id, nested, sizeof(nested)) != 0 || flat_for_id(workspace, id, flat, sizeof(flat)) != 0) { failed++; continue; } /* already moved (a second run, or a session that never was flat) */ os_stat_t st; if (os_lstat(nested, &st) == 0) continue; if (os_mkdir_p(nested_dir, 0700) != 0 || move_file(flat, nested) != 0) { failed++; continue; } /* the title and the attachments that belong to this session */ char sdir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, sdir, sizeof(sdir)) == 0) { char t_from[OS_PATH_MAX], t_to[OS_PATH_MAX]; char leaf[192]; snprintf(leaf, sizeof(leaf), "%s.title", id); if (os_path_join(t_from, sizeof(t_from), sdir, leaf) == 0 && os_path_join(t_to, sizeof(t_to), nested_dir, "title") == 0) move_file(t_from, t_to); /* no title is not a failure */ } char att_dir[OS_PATH_MAX], att_one[OS_PATH_MAX]; char a_from[OS_PATH_MAX], a_to[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, att_dir, sizeof(att_dir)) == 0 && os_path_join(att_one, sizeof(att_one), att_dir, "attachments") == 0 && os_path_join(a_from, sizeof(a_from), att_one, id) == 0 && os_path_join(a_to, sizeof(a_to), nested_dir, "attachments") == 0) { os_stat_t ast; if (os_lstat(a_from, &ast) == 0 && ast.is_dir) move_tree(a_from, a_to); } moved++; } os_dir_close(d); /* the shared directory itself is empty now (and has no other purpose) */ char att[OS_PATH_MAX]; if (os_path_join(att, sizeof(att), dir, "attachments") == 0) rmdir(att); /* fails while another session still uses it: fine */ if (moved_out) *moved_out = moved; if (failed_out) *failed_out = failed; return 0; }