v0.7.0: image input, model registry, prefix-cache savings
ci / linux (clang) (push) Successful in 4m41s
ci / linux (gcc) (push) Successful in 5m35s
ci / musl (push) Failing after 30s
ci / windows-cross (push) Successful in 9m59s

Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
This commit is contained in:
WHC
2026-10-08 20:34:09 +08:00
parent 0226f417ff
commit 895b47274c
32 changed files with 5825 additions and 429 deletions
+327 -24
View File
@@ -4,9 +4,12 @@
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_clipboard.h"
#include "cst_keymap.h"
#include "cst_ext.h"
#include "cst_mcp.h"
#include "cst_models.h"
#include "cst_plugins.h"
#include "cst_skills.h"
#include "cst_subagent.h"
@@ -340,6 +343,7 @@ typedef struct {
size_t cur; /* cursor byte offset into inbuf */
int in_paste;
int paste_cr; /* last pasted byte was CR (fold CRLF) */
size_t paste_at, paste_len0; /* cursor and length when the paste began */
char status_msg[512];
char title[128]; /* session title or first prompt, for the header */
struct timespec ctrlc_at;
@@ -1981,6 +1985,12 @@ static int ctx_percent(const tui_t *t, long *used) {
return win > 0 ? (int)(*used * 100 / win) : 0;
}
static const char *money_sym(const char *cur) {
if (g_ascii && strcmp(cur, "¥") == 0)
return "CNY ";
return cur;
}
static int approval_mode(const tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg)
@@ -2019,8 +2029,20 @@ static void draw_status(tui_t *t) {
int pct = ctx_percent(t, &used);
char tok[32];
fmt_tokens(used, tok, sizeof(tok));
snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands",
tok, pct, dot_sep());
char usage[64] = "";
cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL);
if (u.requests > 0 && u.prompt > 0) {
int n = snprintf(usage, sizeof(usage), "%shit %ld%%", dot_sep(),
u.cached * 100 / u.prompt);
if (u.priced && n > 0 && (size_t)n < sizeof(usage))
snprintf(usage + n, sizeof(usage) - (size_t)n, "%s%s%s%.2f",
dot_sep(), g_ascii ? "~" : "≈", money_sym(u.currency),
u.cost);
}
snprintf(right, sizeof(right),
"%s (%d%%)%s%s\033[97mctrl+p\033[39m commands", tok, pct, usage,
dot_sep());
rw = vis_cols(right);
if (rw > w)
rw = w;
@@ -2078,7 +2100,14 @@ static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) {
x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0);
if (eff) {
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
x += draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
}
int npick = cst_agent_pending_count(t->agent);
if (npick > 0) {
char mark[32];
snprintf(mark, sizeof(mark), "%d image%s", npick, npick > 1 ? "s" : "");
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
draw_ansi_at(w, y, x, room - x + 2, mark, CP_BOX_PRIMARY, 0, 0);
}
if (rw)
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
@@ -2789,11 +2818,23 @@ static void reload_hist_from_session(tui_t *t) {
else
b->expanded = 0;
t->last_tool_idx = t->hist.n - 1;
} else if (cst_message_is_note(m)) {
b->kind = BLK_SYSTEM;
const char *c = m->content ? m->content : "";
const char *nl = strchr(c, '\n');
block_set_body(b, c, nl ? (size_t)(nl - c) : strlen(c));
} else if (m->role == CST_ROLE_USER) {
b->kind = BLK_USER;
block_set_title(b, "You");
block_set_body(b, m->content ? m->content : "",
m->content ? strlen(m->content) : 0);
cst_buf_t ub = {0};
cst_buf_puts(&ub, m->content ? m->content : "");
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&ub, "\n[image %s · %s]", m->attach[k].name, d);
}
block_set_body(b, ub.buf ? ub.buf : "", ub.len);
cst_buf_free(&ub);
b->expanded = 1;
} else if (m->role == CST_ROLE_ASSISTANT) {
b->kind = BLK_ASSISTANT;
@@ -3839,6 +3880,7 @@ typedef struct {
char provider[32];
char base[512];
char key[512];
int have_cache; /* a cached list is showing: keep it if the fetch fails */
} probe_arg_t;
static void am_store(const char *provider, int r, char ids[][128],
@@ -3855,12 +3897,49 @@ static void am_store(const char *provider, int r, char ids[][128],
pthread_mutex_unlock(&g_am_mu);
}
/* GET /models; a good answer also refreshes models-cache/<provider>.json. */
static int fetch_models(const char *provider, const char *base,
const char *key, char ids[][128], int max, char *err,
size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m) {
snprintf(err, err_sz, "out of memory");
return -1;
}
int r = cst_llm_list_models_meta(base, key, m, max, err, err_sz);
if (r >= 0)
cst_models_cache_save(provider, base, m, r);
for (int i = 0; i < r; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
return r;
}
/* The cached list for provider into ids: count, or -1; *fresh when it is
* younger than CST_MODELS_CACHE_TTL. */
static int cached_models(const char *provider, char ids[][128], int max,
int *fresh) {
*fresh = 0;
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m)
return -1;
long age = 0;
int n = cst_models_cache_load(provider, m, max, &age);
for (int i = 0; i < n; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
return n > 0 ? n : -1;
}
static void *probe_thread(void *arg) {
probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256];
int r = cst_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err));
am_store(a->provider, r, ids, err);
int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err,
sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err);
memset(a->key, 0, sizeof(a->key));
free(a);
pthread_mutex_lock(&g_am_mu);
@@ -3883,16 +3962,28 @@ static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return;
static char ids[AM_MAX][128];
int fresh = 0;
int nc = cached_models(cfg->provider, ids, AM_MAX, &fresh);
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
if (nc > 0)
am_store(cfg->provider, nc, ids, "");
if (fresh)
return;
probe_arg_t *a = calloc(1, sizeof(*a));
if (!a)
return;
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
a->have_cache = nc > 0;
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
g_am.state = AM_RUNNING;
g_am.n = 0;
if (nc <= 0) {
g_am.state = AM_RUNNING;
g_am.n = 0;
}
g_am_threads++;
pthread_mutex_unlock(&g_am_mu);
pthread_t th;
@@ -3973,7 +4064,7 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
draw_status(t);
static char ids[AM_MAX][128];
char err[256];
int r = cst_llm_list_models(base, out, ids, AM_MAX, err, sizeof(err));
int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err));
if (r == CST_MODELS_AUTH) {
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one",
@@ -4202,6 +4293,16 @@ static void cmd_delete(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(deleted session %s)", victim);
}
/* "name 1M · vision (api)" for the /model picker. */
static void model_label(const char *provider, const char *name, int api,
char *out, size_t n) {
cst_model_info_t mi;
cst_model_info(provider, name, &mi);
char tag[40];
cst_model_info_label(&mi, tag, sizeof(tag));
snprintf(out, n, "%-40.72s %.32s%s", name, tag, api ? " (api)" : "");
}
static void cmd_model(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
@@ -4212,8 +4313,8 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
set_status(t, "fetching the model list…");
draw_status(t);
char err[256];
na = cst_llm_list_models(cfg->api_base, cfg->api_key, names, AM_MAX, err,
sizeof(err));
na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names,
AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
@@ -4226,7 +4327,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
if (na < 0)
na = 0;
for (int i = 0; i < na; i++)
snprintf(labels[i], 128, "%.100s (api)", names[i]);
model_label(cfg->provider, names[i], 1, labels[i], 128);
char builtin[32][128];
int nb = cst_models_list(cfg->model, builtin, 32);
int n = na;
@@ -4237,7 +4338,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
if (dup)
continue;
memcpy(names[n], builtin[i], 128);
memcpy(labels[n], builtin[i], 128);
model_label(cfg->provider, builtin[i], 0, labels[n], 128);
n++;
}
int cur_idx = -1;
@@ -4252,8 +4353,13 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
}
cst_settings_remember(cfg);
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
cst_model_context_tokens(cfg->model));
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
push_system_line(t, "(model → %s, context %ld tokens%s)", cfg->model,
mi.context,
mi.vision == 1 ? ", takes images"
: mi.vision == 0 ? ", text only"
: "");
}
static void cmd_key(tui_t *t, char *arg, int *quit) {
@@ -4320,16 +4426,18 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) {
}
} else {
int n = cst_provider_count();
char items[16][128];
char items[32][128];
int cur_idx = 0;
for (int i = 0; i < n && i < 16; i++) {
for (int i = 0; i < n && i < 32; i++) {
const cst_provider_t *q = cst_provider_at(i);
snprintf(items[i], 128, "%-11s %s (%s)", q->name, q->api_base,
q->model[0] ? q->model : "models from API");
snprintf(items[i], 128, "%-11.24s %.48s (%.24s%s)", q->name,
q->api_base, q->model[0] ? q->model : "models from API",
strcmp(cst_provider_source(q), "user") == 0 ? " · models.json"
: "");
if (strcmp(q->name, cfg->provider) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Provider", items, n < 16 ? n : 16, cur_idx);
int pick = picker_run(t, "Provider", items, n < 32 ? n : 32, cur_idx);
if (pick < 0)
return;
p = cst_provider_at(pick);
@@ -4568,6 +4676,15 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
cfg->model, used, win, win ? used * 100 / win : 0);
{
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
char tag[48];
cst_model_info_label(&mi, tag, sizeof(tag));
push_system_line(t, " model info %s · from %s%s%s", tag, mi.source,
cst_models_load_error()[0] ? " · " : "",
cst_models_load_error());
}
push_system_line(t, " effort %s%s", cfg->effort,
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
? ""
@@ -4596,6 +4713,57 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
push_system_line(t, " undo %d turn(s) can be undone",
cst_agent_undo_available(t->agent));
cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL);
if (u.requests > 0 && u.prompt > 0)
push_system_line(t, " cache %ld%% of %ld prompt tokens hit (/cost)",
u.cached * 100 / u.prompt, u.prompt);
}
static void push_usage(tui_t *t, const char *label, const cst_usage_t *u) {
if (u->requests == 0) {
push_system_line(t, " %-9s no requests yet", label);
return;
}
long hitp = u->prompt > 0 ? u->cached * 100 / u->prompt : 0;
push_system_line(t,
" %-9s %d request(s) · prompt %ld (hit %ld = %ld%%, miss "
"%ld) · output %ld",
label, u->requests, u->prompt, u->cached, hitp, u->miss,
u->out);
if (u->write)
push_system_line(t, " %-9s cache write %ld tokens", "", u->write);
if (u->priced)
push_system_line(t, " %-9s %s %s%.4f", "", g_ascii ? "~" : "≈",
money_sym(u->currency), u->cost);
}
static void cmd_cost(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
cst_usage_t s, last;
cst_agent_usage(t->agent, &s, &last);
push_system_line(t, "Token use and cost (%s):", cfg->model);
push_usage(t, "session", &s);
push_usage(t, "last turn", &last);
cst_price_t p;
if (cst_model_price(cfg->model, &p) != 0) {
push_system_line(t, " price unknown for this model; set "
"CST_PRICE=miss,hit,out[,write[,currency]] (per 1M "
"tokens)");
return;
}
const char *c = money_sym(p.currency);
push_system_line(t,
" price per 1M tokens: miss %s%.2f · hit %s%.2f · "
"output %s%.2f",
c, p.miss, c, p.hit, c, p.out);
if (p.offpeak_half)
push_system_line(t,
" now %s (off-peak is half price: outside "
"Mon–Fri 9–12 / 14–18 Beijing time)",
cst_price_is_peak(time(NULL)) ? "peak" : "off-peak");
}
static void cmd_todo(tui_t *t, char *arg, int *quit) {
@@ -4707,8 +4875,17 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
if (msg_is_tool_result(m)) {
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
md_fence(fp, c, 4000);
} else if (cst_message_is_note(m)) {
fprintf(fp, "> %.*s\n\n", (int)strcspn(c, "\n"), c);
} else if (m->role == CST_ROLE_USER) {
fprintf(fp, "## User\n\n%s\n\n", c);
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
fprintf(fp, "- image `%s` (%s)\n", m->attach[k].name, d);
}
if (m->nattach)
fputc('\n', fp);
} else {
fprintf(fp, "## Assistant\n\n%s\n\n", c);
if (m->tool_calls_json && m->tool_calls_json[0]) {
@@ -4722,6 +4899,116 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(exported to %s)", path);
}
/* The newest `added` picks, and a warning when the model would not see them. */
static void report_attached(tui_t *t, int added) {
int n = cst_agent_pending_count(t->agent);
for (int i = n - added; i < n; i++) {
char line[256];
if (i >= 0 &&
cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, "Attached %s — it goes with your next message.",
line);
}
if (added > 0 && cst_agent_sees_images(t->agent) == 0)
push_system_line(t, "Note: %s does not take images; they are sent as a "
"text note until you /model to one that does.",
cst_agent_config(t->agent)->model);
}
/* /attach: stage images for the next user message. The bytes are copied into
* the session's attachment directory now, so a picture picked from /tmp keeps
* working after the file is gone. */
static void cmd_attach(tui_t *t, char *arg, int *quit) {
(void)quit;
int n = cst_agent_pending_count(t->agent);
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
if (n == 0) {
push_system_line(t, "No images picked. Usage: /attach <path>");
return;
}
push_system_line(t, "%d image%s with the next message (sent once):", n,
n > 1 ? "s" : "");
for (int i = 0; i < n; i++) {
char line[256];
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, " %d. %s", i + 1, line);
}
push_system_line(t, "(/attach clear drops them)");
return;
}
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
cst_agent_pending_clear(t->agent);
push_system_line(t, "Dropped the picked images.");
return;
}
char err[256];
if (strcmp(arg, "paste") == 0) {
char *img = NULL;
size_t len = 0;
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
return;
}
char mime[32], name[32];
const char *ext = cst_attach_sniff(img, len, mime, sizeof(mime)) == 0 &&
strchr(mime, '/')
? strchr(mime, '/') + 1
: "png";
snprintf(name, sizeof(name), "clipboard.%s", ext);
int rc = cst_agent_attach_bytes(t->agent, name, img, len, err, sizeof(err));
free(img);
if (rc != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
return;
}
if (cst_agent_attach_file(t->agent, arg, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
}
static int file_is_image(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return 0;
unsigned char head[16];
size_t got = fread(head, 1, sizeof(head), f);
fclose(f);
char mime[32];
return cst_attach_sniff(head, got, mime, sizeof(mime)) == 0;
}
/* A paste that is nothing but image file paths (a file dropped on the
* terminal) attaches those files instead of typing the paths. */
static void paste_image_paths(tui_t *t) {
size_t from = t->paste_at, to = t->cur;
if (to <= from || to - from != t->in_len - t->paste_len0 ||
to - from > CST_PASTE_MAX_PATHS * CST_PASTE_PATH_MAX)
return;
static char paths[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
int n = cst_paste_paths(t->inbuf + from, to - from, paths,
CST_PASTE_MAX_PATHS);
if (n <= 0)
return;
for (int i = 0; i < n; i++)
if (!file_is_image(paths[i]))
return;
input_erase(t, from, to);
int added = 0;
for (int i = 0; i < n; i++) {
char err[256];
if (cst_agent_attach_file(t->agent, paths[i], err, sizeof(err)) == 0)
added++;
else
push_system_line(t, "Error: %s", err);
}
report_attached(t, added);
}
#ifdef _WIN32
#define COPY_VIA "clipboard"
#else
@@ -5015,7 +5302,7 @@ static void show_subagent(tui_t *t, const char *pid,
const char *final = NULL;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m->content)
if (!m->content || cst_message_is_note(m))
continue;
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
push_system_line(t, " prompt: %.300s", m->content);
@@ -5118,6 +5405,8 @@ static const slash_cmd_t SLASH[] = {
cmd_mouse},
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
{"status", "", "", "session, model, context and settings", 1, cmd_status},
{"cost", "usage", "", "tokens, cache hit rate and estimated cost", 1,
cmd_cost},
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
@@ -5127,6 +5416,9 @@ static const slash_cmd_t SLASH[] = {
{"export", "", "[file]", "write the transcript as Markdown", 1,
cmd_export},
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
{"attach", "image", "[path|paste|clear]",
"send image(s) with the next message",
1, cmd_attach},
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
@@ -5430,7 +5722,15 @@ static int begin_turn(tui_t *t, char *prompt) {
if (ub) {
ub->kind = BLK_USER;
block_set_title(ub, "You");
block_set_body(ub, prompt, strlen(prompt));
cst_buf_t body = {0};
cst_buf_puts(&body, prompt);
for (int k = 0; k < cst_agent_pending_count(t->agent); k++) {
char d[128];
if (cst_agent_pending_describe(t->agent, k, d, sizeof(d)) == 0)
cst_buf_printf(&body, "\n[image %s]", d);
}
block_set_body(ub, body.buf ? body.buf : "", body.len);
cst_buf_free(&body);
ub->expanded = 1;
}
t->think_idx = -1;
@@ -6646,10 +6946,13 @@ int cst_tui_run(cst_agent_t *agent) {
if (wch == KEY_PASTE_BEGIN) {
t.in_paste = 1;
t.paste_cr = 0;
t.paste_at = t.cur;
t.paste_len0 = t.in_len;
continue;
}
if (wch == KEY_PASTE_END) {
t.in_paste = 0;
paste_image_paths(&t);
input_changed(&t);
after_edit(&t);
continue;