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:
WHC
2026-10-11 18:33:41 +08:00
parent 08008a5a4c
commit 580d533656
20 changed files with 1408 additions and 10 deletions
+245
View File
@@ -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);
}