v0.9.6: documents, downloads, PDFs and the phone's own files
Four tools and a document writer, plus the storage half of the phone: until now the picker's answer (a content:// URI) reached the engine as a path, and it said "cannot read" -- the app had no way to hand a phone's file over. - md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one self-contained HTML document out -- doctype, utf-8, viewport, escaped title, a small stylesheet; no script, no external request. It is written into the workspace and delivered as a file card (for_user, so it never enters a request), the way deliver_file does. The default name comes from the source file's stem or, for pasted markdown, from the title with what a path cannot carry turned into '-'. It has a preview, so an approval prompt says "render x -> y.html" before anything is written. - download (src/tools/web.c): an http(s) URL into a file in the workspace, streamed with a 128 MB ceiling; a redirect chain, the same CA handling and the same refusals as web_fetch, but the bytes stay on disk (a half file is removed on any failure, including HTTP >= 400). The name comes from the URL when the caller names only a directory. - read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF, which read_file refuses because the bytes are binary -- and a PDF is exactly what arrives from a phone. Same extractor an attachment uses, its own 4 MiB input ceiling, 256 KiB of text by default with a note when cut. - session_search (new, src/tools/session_tools.c): every stored session -- this one and all the ones before it -- searched for a substring, reporting the session's id, message count and title, then the matching lines with their number and role. Case-insensitive by default, capped at 20 matches. - device_info (same file, perm_name "device"): version, system/arch/CPU count, workspace, sandbox, model and permissions, config/temp/program directories, pid -- so the model stops guessing where it runs. - Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files): the document is a whole page, a script tag and a javascript: link do not survive, a hostile title stays text and cannot write outside the workspace, the preview names the file, read_pdf/download refuse what they must (no network in a test), session_search finds a fixture session and says so when nothing matches, device_info names the workspace. README's tool table grew five rows. - Android storage: the picker (SAF) answers with a content:// URI, and the engine opens files by path, so the app now copies what it points at into its own directory first -- app/android/src/xyz/whcself/cstagent/ AttachFile.java, called through a new app::materializeAttachment() (ToolProvider.h: one API for every platform; a desktop strips file:// and refuses content:// with a reason). The copy needs no permission at all: the grant travels with the URI. - "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what lets the engine read a path in shared storage that a task names rather than picks. Android has no runtime dialog for it, so main.cpp opens the one Settings screen once (AttachFile.java's requestAllFilesAccess, with the all-apps screen as a fallback) and never insists: without it everything works, one picked file at a time. On the NX789J the screen came up, the app-op reads back "allow", and a relaunch no longer asks. - Composer.qml hands a content:// URI through untouched (it is not a path, and decoding it would corrupt it); AgentCore::attachFile() turns whatever the picker gave into a path first. Desktop: build clean, make check 36/36 (the new test included, also under ASan), --uitest all green, --selftest and cst-app-selftest OK. The APK builds and installs (86 MB, release-signed) with the new Java class in its dex. Version 0.9.6.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
#ifndef _XOPEN_SOURCE
|
||||
#define _XOPEN_SOURCE 700
|
||||
#endif
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_sandbox.h"
|
||||
#include "cst_tools.h"
|
||||
#include "cst_util.h"
|
||||
#include "cst_version.h"
|
||||
#include "os_compat.h"
|
||||
#include "tools_internal.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <sys/utsname.h>
|
||||
#include <unistd.h>
|
||||
#else
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
/* A search that does not care about case: the model asks "where did we talk
|
||||
* about the store", and nobody remembers how it was capitalised. */
|
||||
static const char *find_in(const char *hay, const char *needle, int icase) {
|
||||
size_t nn = strlen(needle);
|
||||
if (!nn)
|
||||
return NULL;
|
||||
for (const char *p = hay; *p; p++) {
|
||||
if (!icase) {
|
||||
if (strncmp(p, needle, nn) == 0)
|
||||
return p;
|
||||
continue;
|
||||
}
|
||||
size_t i = 0;
|
||||
while (i < nn && p[i] &&
|
||||
tolower((unsigned char)p[i]) == tolower((unsigned char)needle[i]))
|
||||
i++;
|
||||
if (i == nn)
|
||||
return p;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A line of context around a match: control characters become spaces (one
|
||||
* transcript line is one JSON object anyway) and the tail is marked. */
|
||||
static void say_context(cst_buf_t *b, const char *from, size_t max) {
|
||||
size_t i = 0;
|
||||
while (from[i] && i < max) {
|
||||
char c = from[i];
|
||||
if (c == '\n' || c == '\r' || c == '\t')
|
||||
c = ' ';
|
||||
cst_buf_append(b, &c, 1);
|
||||
i++;
|
||||
}
|
||||
if (from[i])
|
||||
cst_buf_puts(b, " ...");
|
||||
}
|
||||
|
||||
/* --- session_search ------------------------------------------------------ */
|
||||
|
||||
/* What was said before: every stored session's transcript, line by line. The
|
||||
* store is the same one the CLI, the TUI, the WebUI and the app use, so this
|
||||
* is how "we solved this last week" is answered without a shell. */
|
||||
int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
cst_tool_result_t *r) {
|
||||
const char *query = tool_arg(c, "query");
|
||||
if (!query || !query[0])
|
||||
return tool_set_result(r, "error: missing query", 1);
|
||||
while (*query == ' ')
|
||||
query++;
|
||||
if (!query[0])
|
||||
return tool_set_result(r, "error: missing query", 1);
|
||||
long want = tool_arg_long(c, "max_matches", 20);
|
||||
if (want <= 0)
|
||||
want = 20;
|
||||
if (want > 100)
|
||||
want = 100;
|
||||
int icase = tool_arg_bool(c, "ignore_case", 1);
|
||||
|
||||
static char ids[200][128];
|
||||
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
||||
cst_buf_t hits = {0};
|
||||
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
||||
for (int i = 0; i < n && matches < (int)want; i++) {
|
||||
char path[OS_PATH_MAX];
|
||||
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
||||
sizeof(path)) != 0)
|
||||
continue;
|
||||
FILE *fp = fopen(path, "rb");
|
||||
if (!fp)
|
||||
continue;
|
||||
scanned_sessions++;
|
||||
cst_session_info_t in;
|
||||
int have_info = cst_agent_store_info(ctx->workspace_root, ids[i], &in) == 0;
|
||||
int in_this = 0, lineno = 0;
|
||||
char *line = NULL;
|
||||
size_t cap = 0;
|
||||
cst_buf_t one = {0};
|
||||
ssize_t got;
|
||||
while ((got = getline(&line, &cap, fp)) > 0) {
|
||||
lineno++;
|
||||
if (got <= 1)
|
||||
continue;
|
||||
cJSON *o = cJSON_Parse(line);
|
||||
if (!o)
|
||||
continue;
|
||||
const cJSON *role = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||
const cJSON *content = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||
const char *txt = cJSON_IsString(content) ? content->valuestring : NULL;
|
||||
const char *at = txt ? find_in(txt, query, icase) : NULL;
|
||||
int stop = 0;
|
||||
if (at) {
|
||||
if (!in_this) {
|
||||
in_this = 1;
|
||||
in_sessions++;
|
||||
cst_buf_printf(&one, "\n%s", ids[i]);
|
||||
if (have_info && in.messages)
|
||||
cst_buf_printf(&one, " · %d msgs", in.messages);
|
||||
if (have_info && in.title[0])
|
||||
cst_buf_printf(&one, " · %s", in.title);
|
||||
else if (have_info && in.first_user[0])
|
||||
cst_buf_printf(&one, " · %s", in.first_user);
|
||||
cst_buf_puts(&one, "\n");
|
||||
}
|
||||
const char *who = cJSON_IsString(role) ? role->valuestring : "?";
|
||||
const char *meta = NULL;
|
||||
const cJSON *kind =
|
||||
cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||
if (cJSON_IsString(kind))
|
||||
meta = kind->valuestring;
|
||||
/* a little before the match, a line's worth after it */
|
||||
const char *start = at;
|
||||
for (int back = 0; start > txt && back < 60; back++)
|
||||
start--;
|
||||
cst_buf_printf(&one, " #%d %s%s: ", lineno, who,
|
||||
meta && strcmp(meta, "note") == 0 ? " (note)" : "");
|
||||
say_context(&one, start, (size_t)(at - start) + strlen(query) + 160);
|
||||
cst_buf_puts(&one, "\n");
|
||||
matches++;
|
||||
stop = matches >= (int)want;
|
||||
}
|
||||
cJSON_Delete(o);
|
||||
if (stop)
|
||||
break;
|
||||
}
|
||||
cst_buf_append(&hits, one.buf, one.buf ? one.len : 0);
|
||||
cst_buf_free(&one);
|
||||
free(line);
|
||||
fclose(fp);
|
||||
}
|
||||
if (matches == 0) {
|
||||
cst_buf_free(&hits);
|
||||
return tool_set_resultf(r, 0,
|
||||
"no match for \"%.80s\" in %d stored session%s "
|
||||
"(the store holds %d)\n",
|
||||
query, scanned_sessions,
|
||||
scanned_sessions == 1 ? "" : "s", n);
|
||||
}
|
||||
cst_buf_t out = {0};
|
||||
cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n",
|
||||
matches, matches == 1 ? "" : "es", query, in_sessions,
|
||||
scanned_sessions, scanned_sessions == 1 ? "" : "s");
|
||||
cst_buf_append(&out, hits.buf, hits.len);
|
||||
if (matches >= (int)want)
|
||||
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
||||
"query)\n");
|
||||
cst_buf_free(&hits);
|
||||
return tool_take_buf(r, &out, 0);
|
||||
}
|
||||
|
||||
/* --- device_info --------------------------------------------------------- */
|
||||
|
||||
/* Where this agent is running: enough for the model to stop guessing (a
|
||||
* phone in a pocket and a desktop in a terminal answer differently), and no
|
||||
* more than that -- no serial numbers, no accounts. */
|
||||
int cst_tool_device_info(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
cst_tool_result_t *r) {
|
||||
(void)c;
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_printf(&b, "cst-agent %s\n", CST_VERSION);
|
||||
#if defined(__ANDROID__)
|
||||
const char *plat = "android";
|
||||
#elif defined(_WIN32)
|
||||
const char *plat = "windows";
|
||||
#elif defined(__APPLE__)
|
||||
const char *plat = "macos";
|
||||
#elif defined(__linux__)
|
||||
const char *plat = "linux";
|
||||
#else
|
||||
const char *plat = "unknown";
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
char host[160] = "";
|
||||
os_windows_version(host, sizeof(host));
|
||||
SYSTEM_INFO si;
|
||||
memset(&si, 0, sizeof(si));
|
||||
GetSystemInfo(&si);
|
||||
cst_buf_printf(&b, "system: %s (%d-bit, %lu CPUs)\n",
|
||||
host[0] ? host : plat, (int)(sizeof(void *) * 8),
|
||||
(unsigned long)si.dwNumberOfProcessors);
|
||||
#else
|
||||
struct utsname u;
|
||||
long cpus = sysconf(_SC_NPROCESSORS_ONLN);
|
||||
if (uname(&u) == 0)
|
||||
cst_buf_printf(&b, "system: %s %.80s %.60s (%d-bit, %ld CPUs)\n",
|
||||
u.sysname, u.release, u.machine, (int)(sizeof(void *) * 8),
|
||||
cpus > 0 ? cpus : 0L);
|
||||
else
|
||||
cst_buf_printf(&b, "system: %s (%d-bit)\n", plat,
|
||||
(int)(sizeof(void *) * 8));
|
||||
#endif
|
||||
cst_buf_printf(&b, "workspace: %s\n",
|
||||
ctx->workspace_root ? ctx->workspace_root : "?");
|
||||
cst_buf_printf(&b, "sandbox: %s\n", cst_sandbox_name(cst_sandbox_probe()));
|
||||
const cst_agent_config_t *cfg = ctx->agent ? cst_agent_config(ctx->agent) : NULL;
|
||||
if (cfg) {
|
||||
char sid[128] = "";
|
||||
cst_agent_session_id(ctx->agent, sid, sizeof(sid));
|
||||
cst_buf_printf(&b, "session: %.120s\n", sid[0] ? sid : "(none)");
|
||||
cst_buf_printf(&b, "model: %.60s · %.30s · effort %.12s · mode %.12s\n",
|
||||
cfg->model, cfg->provider, cfg->effort, cfg->mode);
|
||||
cst_buf_printf(&b, "permissions: approval %s%s%s\n",
|
||||
cst_approval_name(cfg->approval),
|
||||
cfg->plan_mode ? ", plan mode (read-only)" : "",
|
||||
cfg->auto_approve ? ", unattended" : "");
|
||||
cst_buf_printf(&b, "steps: %d model calls per turn max\n", cfg->max_steps);
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
if (os_config_dir(dir, sizeof(dir)) == 0)
|
||||
cst_buf_printf(&b, "config: %s\n", dir);
|
||||
if (os_temp_dir(dir, sizeof(dir)) == 0)
|
||||
cst_buf_printf(&b, "temp: %s\n", dir);
|
||||
if (os_exe_dir(dir, sizeof(dir)) == 0)
|
||||
cst_buf_printf(&b, "program: %s\n", dir);
|
||||
cst_buf_printf(&b, "process: pid %d\n", (int)os_getpid());
|
||||
return tool_take_buf(r, &b, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user