diff --git a/CMakeLists.txt b/CMakeLists.txt index ee6bc3f..484e0db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,7 @@ set(CST_CORE_SOURCES src/subagent.c src/attach.c src/clipboard.c + src/pdf_text.c src/session.c src/tools/tools.c src/tools/patch.c @@ -279,7 +280,7 @@ endif() enable_testing() set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline test_replay_starvation test_replay_real test_mm_tool test_textsel - test_markdown) + test_pdf_text test_markdown) foreach(t ${_CST_SMALL_TESTS}) if(t STREQUAL test_textsel) add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c @@ -287,6 +288,12 @@ foreach(t ${_CST_SMALL_TESTS}) if(WIN32) target_link_libraries(${t} PRIVATE advapi32 user32) endif() + elseif(t STREQUAL test_pdf_text) + add_executable(${t} tests/${t}.c src/pdf_text.c src/os_compat.c src/os_win.c + src/util.c) + if(WIN32) + target_link_libraries(${t} PRIVATE advapi32 user32) + endif() elseif(t STREQUAL test_markdown) add_executable(${t} tests/${t}.c) target_include_directories(${t} PRIVATE "${SD_DIR}/include") diff --git a/Makefile b/Makefile index 6718306..11290ac 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,7 @@ CORE_SRC = \ src/keymap.c \ src/skills.c \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \ + src/pdf_text.c \ src/session.c \ src/tools/tools.c \ src/tools/patch.c \ @@ -135,6 +136,7 @@ TESTS = \ tests/test_apply_patch \ tests/test_session_roundtrip \ tests/test_attach \ + tests/test_pdf_text \ tests/test_shell \ tests/test_diff \ tests/test_tools_fs \ @@ -229,6 +231,9 @@ tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(TEST_ tests/test_textsel: tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) include/cst_textsel.h $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) $(TEST_SYS_LIBS) +tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TEST_OS_SRC) + $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_pdf_text.c src/pdf_text.c $(TEST_OS_SRC) $(TEST_SYS_LIBS) + tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm diff --git a/README.md b/README.md index 179b8a6..6b77617 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。 |---|---| | `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) | | `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) | +| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) | | `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` | | `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` | | `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | @@ -276,6 +277,7 @@ cst-agent --headless --prompt 'List files' | `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 | | `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | | `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` | +| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` | | `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) | | `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key | @@ -297,6 +299,8 @@ cst-agent --headless --prompt 'List files' | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | +| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) | +| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) | | `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) | | `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | | `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | @@ -308,6 +312,26 @@ cst-agent --headless --prompt 'List files' `--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。 TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 +### 工作模式(coding / general) + +`--mode general`(或 TUI 里 `/mode general`、WebUI 运行设置里的「工作模式」、`CST_MODE=general`)把默认口径 +从"代码助手"换成"通用助手":工作流从改代码/跑测试换成"先弄清要什么、再动手、给出处和不确定的地方", +工具说明从"仓库"改成"文件与数据",并说明 `web_fetch` 可以拿外部资料;`/status` 第一行显示当前模式, +切换后 history 里会留一条系统提示。默认仍是 `coding`,选择记在 `settings.json`; +`--tools minimal`(论文口径)的提示词按字节冻结,不受模式影响。 + +### 剪贴板与文档附件 + +- **剪贴板**:`clipboard_read` / `clipboard_write` 两个工具(写剪贴板是不可逆发射,要确认); + TUI 里 `/attach paste` 在剪贴板里没有图片时,把剪贴板的**文本**放进输入框(丢掉 CR 与控制字节,尊重输入长度上限)。 +- **附件**(`/attach `、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 image part; + **PDF 在摄取时就抽出文本层**(零依赖解析器:Flate / ASCIIHex / ASCII85、对象流、CID 字体 ToUnicode、 + 内联图像跳过),存的是文本、`mime` 仍是 `application/pdf`;其余文件必须是 UTF-8 文本。 + 文档以 **text part 内联**发出,所以不受 `CST_NO_IMAGES` 或非视觉模型影响;图片照旧会被换成一行"未发送"说明。 + 上限:单个附件 ≤16 MB(文档文本 ≤4 MiB),每条消息最多 8 个。 + 加密 PDF、扫描件(无文本层)会被明确拒绝并说明原因; + `view_image` 仍只收图片,文本/PDF 走 `/attach`。 + ### 权限、撤销与项目说明 - **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`): @@ -426,6 +450,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中 | `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 | | `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) | | `/protocol [name]` | 切换工具调用协议 | +| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) | | `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 | | `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo | | `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 | @@ -435,6 +460,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中 | `/diff [path]` * | `git status` + `git diff` | | `/compact` | 手动压缩上下文 | | `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | +| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 | | `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) | | `/init` | 让 agent 写 `AGENTS.md` | | `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 | diff --git a/include/cst_agent.h b/include/cst_agent.h index 429c83f..66c7feb 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -47,15 +47,18 @@ int cst_protocol_is_text(cst_protocol_t p); /* Eager protocols execute a call during the stream, at confirmation time. */ int cst_protocol_is_eager(cst_protocol_t p); -/* One image carried by a message (see cst_attach.h). The bytes live beside - * the session; this is the metadata plus the name of the stored copy. */ +/* One file carried by a message (see cst_attach.h): an image, or a document + * whose stored payload is its text. The payload lives beside the session; + * this is the metadata plus the name of the stored copy. */ typedef struct { char id[32]; /* unique inside the session ("img-1a2b3c4d") */ char name[256]; /* display name (original file name) */ - char mime[32]; /* image/png, image/jpeg, image/gif, image/webp, image/bmp */ + char mime[32]; /* image/png, ...; application/pdf for a PDF whose text + * layer the stored file holds; text/plain for text */ char rel[256]; /* file name inside the session's attachment directory */ - long long bytes; /* stored size */ + long long bytes; /* stored size (the text, for documents) */ int width, height; /* pixels, 0 when the header could not be read */ + int pages; /* PDF page count, 0 for anything else */ char detail[8]; /* auto | low | high (wire hint, "" = auto) */ char origin[32]; /* user | view_image | mcp (for display only) */ } cst_attachment_t; @@ -82,6 +85,7 @@ typedef struct { char model[128]; char provider[32]; char effort[16]; /* low | medium | high | max */ + char mode[16]; /* coding | general (CST_DEFAULT_MODE) */ cst_protocol_t protocol; int max_steps; /* model calls per user turn */ size_t tool_out_cap; /* bytes of one tool output sent to the model */ @@ -114,6 +118,14 @@ typedef enum { const char *cst_approval_name(int a); int cst_approval_parse(const char *s, int *out); +/* Working mode: how the system prompt frames the agent. "coding" (default) + * is code framed; "general" is for tasks that are not about a codebase + * (documents, data, research, everyday files). Orthogonal to plan mode. */ +#define CST_DEFAULT_MODE "coding" +int cst_mode_valid(const char *m); +/* Canonical name of m, or the default when m is unknown / empty. */ +const char *cst_mode_name(const char *m); + typedef struct cst_session cst_session_t; typedef struct cst_agent cst_agent_t; @@ -406,13 +418,14 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz, /* Hidden console prompt (no echo). */ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); -/* Last used provider, model per provider and effort, in +/* Last used provider, model per provider, effort and working mode, in * /settings.json. */ #define CST_SETTINGS_MAX_MODELS 16 #define CST_SETTINGS_MAX_TRUSTED 32 typedef struct { char provider[32]; char effort[16]; + char mode[16]; /* coding | general, "" = not chosen yet */ char approval[8]; /* "" = not chosen yet */ int show_thinking; /* TUI: Thought blocks start expanded */ /* workspaces whose .agent/plugins and .agent/mcp.json may run */ @@ -427,11 +440,13 @@ typedef struct { int cst_settings_load(cst_settings_t *s); int cst_settings_save(const cst_settings_t *s); const char *cst_settings_model(const cst_settings_t *s, const char *provider); -/* Record cfg's provider, model and effort as the last used ones. */ +/* Record cfg's provider, model, effort and mode as the last used ones. */ int cst_settings_remember(const cst_agent_config_t *cfg); /* Record an approval mode the user picked (CST_APPROVAL_*). */ int cst_settings_remember_approval(int approval); int cst_settings_remember_thinking(int show_thinking); +/* Record the working mode the user picked (coding | general). */ +int cst_settings_remember_mode(const char *mode); /* Workspace trust for project plugins / MCP servers (by real path). */ int cst_settings_trusted(const char *workspace); int cst_settings_trust(const char *workspace, int on); diff --git a/include/cst_attach.h b/include/cst_attach.h index 30e7454..2e5db60 100644 --- a/include/cst_attach.h +++ b/include/cst_attach.h @@ -9,16 +9,24 @@ extern "C" { #endif -/* --- image attachments --------------------------------------------------- - * An attachment is a validated copy of an image file, stored beside the - * session so the bytes a model is shown stay the bytes that were picked. - * Only the metadata travels in the JSONL; the bytes are encoded into the - * request when it is built. */ +/* --- attachments --------------------------------------------------------- + * An attachment is a validated copy of a file, stored beside the session so + * what a model is shown stays what was picked. Images are stored as they are + * and encoded into the request when it is built; a PDF is reduced to its text + * layer and a text file is stored as is, both of which travel inline as text + * (so a text-only model still gets them). Only the metadata goes in the + * JSONL. */ -/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */ -#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */ +/* Supported: PNG, JPEG, GIF, WebP, BMP (static images), PDF, UTF-8 text. */ +#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one picked file */ #define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */ #define CST_ATTACH_MAX_COUNT 8 /* per message */ +/* Text stored and inlined per document (a PDF also caps its text here). */ +#define CST_ATTACH_TEXT_MAX (4 * 1024 * 1024) + +/* Images go on the wire as image parts; everything else is inlined as text. */ +int cst_attach_is_image(const cst_attachment_t *a); +int cst_attach_is_text(const cst_attachment_t *a); /* Magic-byte test: 0 and a canonical mime when data holds a supported * image, -1 otherwise (an extension alone never decides). */ @@ -35,23 +43,25 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz); int cst_attach_full_path(const char *session_path, const cst_attachment_t *a, char *out, size_t out_sz); -/* Read src_path, verify it is a supported image within limits, and copy it - * into the session's attachment directory. Returns a malloc'd entry the - * caller owns (cst_attach_free), or NULL with err filled. */ +/* Read src_path and copy it into the session's attachment directory: an image + * as it is, a PDF as its extracted text, anything else only when it is text. + * Returns a malloc'd entry the caller owns (cst_attach_free), or NULL with + * err filled. */ cst_attachment_t *cst_attach_import_file(const char *session_path, const char *src_path, char *err, size_t err_sz); -/* Store already-read bytes (clipboard, downloads); name is display-only. */ +/* Store already-read image bytes (clipboard, downloads); name is + * display-only. */ cst_attachment_t *cst_attach_import_bytes(const char *session_path, const char *name, const char *data, size_t n, char *err, size_t err_sz); -/* Validate and read back a stored image, or NULL when it is gone. */ +/* Validate and read back a stored payload, or NULL when it is gone. */ char *cst_attach_read(const char *session_path, const cst_attachment_t *a, size_t *out_len, char *err, size_t err_sz); -/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */ +/* "data:image/png;base64,...." for the wire; images only, NULL otherwise. */ char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a); -/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */ +/* "1920x1080 png 412 KB" / "pdf 1.2 MB, 12 pages" one-liner for prompts. */ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz); void cst_attach_free(cst_attachment_t *a); diff --git a/include/cst_clipboard.h b/include/cst_clipboard.h index 16db43a..e75d616 100644 --- a/include/cst_clipboard.h +++ b/include/cst_clipboard.h @@ -13,6 +13,20 @@ extern "C" { * ("no image on the clipboard", "install wl-clipboard", ...). */ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz); +/* Cap on text moved through the clipboard (read and write). */ +#define CST_CLIPBOARD_TEXT_MAX (1024 * 1024) + +/* UTF-8 text on the system clipboard: Wayland (wl-paste), X11 (xclip), + * macOS (pbpaste), Windows (CF_UNICODETEXT). Returns 0 with *data malloc'd + * and NUL-terminated, or -1 with err filled in ("no text on the clipboard", + * "reading the clipboard needs wl-clipboard", ...). */ +int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz); + +/* Replace the clipboard with UTF-8 text: wl-copy / xclip / pbcopy, or the + * Windows clipboard API. Returns 0 on success, else -1 with err. */ +int cst_clipboard_set_text(const char *text, size_t n, char *err, + size_t err_sz); + /* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG * for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */ char *cst_dib_to_image(const void *dib, size_t sz, size_t *n); diff --git a/include/cst_pdf.h b/include/cst_pdf.h new file mode 100644 index 0000000..407e4f5 --- /dev/null +++ b/include/cst_pdf.h @@ -0,0 +1,27 @@ +#ifndef CST_PDF_H +#define CST_PDF_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Text layer of a PDF, in page order, with no external dependency (no + * poppler, no zlib): classic objects and object streams, FlateDecode plus + * ASCIIHex/ASCII85, PNG/TIFF predictors, Tj/TJ/'/" text with per-font + * ToUnicode CMaps and a WinAnsi / Latin-1 fallback. Scanned pages have no + * text layer and are reported as such. + * Returns a malloc'd NUL-terminated UTF-8 string, or NULL with err filled in + * ("PDF is encrypted ...", "no extractable text ..."). *pages (optional) gets + * the page count; the result is truncated at CST_PDF_TEXT_MAX bytes. */ +char *cst_pdf_text(const void *data, size_t n, int *pages, char *err, + size_t err_sz); + +#define CST_PDF_TEXT_MAX (4u * 1024u * 1024u) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_version.h b/include/cst_version.h index 4cce114..a64aa6f 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 5 +#define CST_VERSION_PATCH 6 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/agent_loop.c b/src/agent_loop.c index ab36db2..dfaecaa 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -177,8 +177,62 @@ static int tool_on(const cst_agent_config_t *cfg, const char *name) { return cst_tool_active(cfg, cst_tool_find(name)); } +/* /mode general (cst_mode_name covers "" and unknown values). */ +static int general_mode(const cst_agent_config_t *cfg) { + return cfg && strcmp(cst_mode_name(cfg->mode), "general") == 0; +} + +/* Workflow for /mode general: gather, then do the work, then check it. */ +static void put_workflow_general(cst_buf_t *b, const cst_agent_config_t *cfg) { + cst_buf_puts(b, "\nWorkflow:\n1) Gather what you need"); + const char *explore[] = {"list_dir", "glob", "grep"}; + int n = 0; + for (int i = 0; i < 3; i++) { + if (tool_on(cfg, explore[i])) + cst_buf_printf(b, "%s%s", n++ ? ", " : " (", explore[i]); + } + if (n) + cst_buf_puts(b, ")"); + if (tool_on(cfg, "read_file")) + cst_buf_puts(b, "; read big files in slices with read_file offset/limit, " + "and use the documents the user attached"); + cst_buf_puts(b, ".\n2) Do the work"); + n = 0; + if (tool_on(cfg, "write_file")) { + cst_buf_puts(b, ": write_file for new files"); + n++; + } + if (tool_on(cfg, "edit_file")) + cst_buf_printf(b, "%sedit_file for small exact replacements", + n++ ? ", " : ": "); + if (tool_on(cfg, "apply_patch")) + cst_buf_printf(b, "%sapply_patch for multi-hunk diffs to existing files", + n++ ? ", " : ": "); + if (tool_on(cfg, "run_shell")) + cst_buf_printf(b, "%srun_shell for commands, computations, data " + "processing and other CLIs", + n++ ? ", " : ": "); + cst_buf_puts(b, ".\n"); + if (tool_on(cfg, "run_shell")) + cst_buf_puts(b, "3) Check the result before saying it works (re-read it, " + "run it, or verify the numbers); fix and re-run until it " + "holds.\n"); + if (tool_on(cfg, "todo_write")) + cst_buf_puts(b, "- For work with several steps keep a todo list with " + "todo_write and update it as you go.\n"); + if (tool_on(cfg, "ask_user")) + cst_buf_puts(b, "- Use ask_user only when a decision is truly the user's; " + "otherwise choose sensibly and say so.\n"); + cst_buf_puts(b, "Finish with a short plain-text summary of the result.\n" + "Never invent other tool names.\n"); +} + /* Workflow section for the default tool profile; names only active tools. */ static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) { + if (general_mode(cfg)) { + put_workflow_general(b, cfg); + return; + } cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need"); const char *explore[] = {"list_dir", "glob", "grep"}; int n = 0; @@ -235,6 +289,25 @@ static const char DEF_INTRO[] = "could not verify, say so.\n" "- Keep going until the task is done or truly blocked.\n\n"; +/* Same role, framed for /mode general: the workspace may hold documents, + * data or anything else, not just code. */ +static const char DEF_INTRO_GENERAL[] = + "You are CST-Agent, a general-purpose assistant working in the user's " + "local workspace. All tool paths are relative to the workspace root and " + "shell commands run there; the material you work on may be code, " + "documents, data or anything else.\n\n" + "Principles:\n" + "- Reply in the user's language. Be concise: lead with the result, no " + "filler.\n" + "- Understand before answering: read the files, documents and sources the " + "task refers to instead of guessing; never invent content, numbers or " + "tool results.\n" + "- Do what was asked, at the scope asked: leave unrelated files, data and " + "material alone.\n" + "- Check your work when you can (re-read it, run it, cross-check the " + "source); if you could not verify something, say so.\n" + "- Keep going until the task is done or truly blocked.\n\n"; + static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) { cst_buf_puts(b, "\nTool use:\n"); if (tool_on(cfg, "run_shell") && @@ -248,15 +321,26 @@ static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) { cst_buf_puts(b, "- Read a file before editing it and again after a failed " "edit. The `N|` prefixes in read_file output are not part " "of the file.\n"); + if (general_mode(cfg) && tool_on(cfg, "web_fetch")) + cst_buf_puts(b, "- Use web_fetch for pages the user points you to; say so " + "when a page could not be fetched instead of guessing its " + "content.\n"); cst_buf_puts(b, "- When a call fails, read the error and change approach " "instead of repeating the same call.\n"); } static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) { - cst_buf_puts(b, "\nSafety:\n" - "- Do not commit, push or rewrite git history unless asked. " - "Do not delete or overwrite files you did not create unless " - "the task requires it. Never print secrets or API keys.\n"); + if (general_mode(cfg)) + cst_buf_puts(b, "\nSafety:\n" + "- Do not delete or overwrite files or data you did not " + "create unless the task requires it, and do not send the " + "user's content anywhere the task did not ask for. Never " + "print secrets or API keys.\n"); + else + cst_buf_puts(b, "\nSafety:\n" + "- Do not commit, push or rewrite git history unless asked. " + "Do not delete or overwrite files you did not create unless " + "the task requires it. Never print secrets or API keys.\n"); if (tool_on(cfg, "run_shell")) cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the " "workspace and temp dir; the network works). If a command is " @@ -365,7 +449,9 @@ char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p, const char *project_notes) { int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL; cst_buf_t b = {0}; - cst_buf_puts(&b, minimal ? PROMPT_BASE : DEF_INTRO); + cst_buf_puts(&b, minimal ? PROMPT_BASE + : general_mode(cfg) ? DEF_INTRO_GENERAL + : DEF_INTRO); if (cst_protocol_is_native(p)) cst_buf_puts(&b, minimal ? SEC_NATIVE : "Call tools through the native " @@ -2241,11 +2327,12 @@ int cst_agent_undo_available(const cst_agent_t *a) { * later changes reach the model as persisted notes appended to the history, * which keeps the cached prefix intact. */ -#define PROMPT_VERSION 2 +#define PROMPT_VERSION 3 typedef struct { int plan; char approval[96]; + char mode[16]; char branch[128]; char day[16]; char notes[24]; /* hash of AGENTS.md, "" = none */ @@ -2255,6 +2342,7 @@ static void state_now(const cst_agent_t *a, pstate_t *s) { memset(s, 0, sizeof(*s)); s->plan = a->cfg.plan_mode; snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg)); + snprintf(s->mode, sizeof(s->mode), "%s", cst_mode_name(a->cfg.mode)); git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch)); today(s->day, sizeof(s->day)); if (a->notes && a->notes[0]) @@ -2267,6 +2355,7 @@ static cJSON *state_json(const pstate_t *s) { cJSON *o = cJSON_CreateObject(); cJSON_AddNumberToObject(o, "plan", s->plan); cJSON_AddStringToObject(o, "approval", s->approval); + cJSON_AddStringToObject(o, "mode", s->mode); cJSON_AddStringToObject(o, "branch", s->branch); cJSON_AddStringToObject(o, "day", s->day); cJSON_AddStringToObject(o, "notes", s->notes); @@ -2282,6 +2371,7 @@ static void state_parse(const cJSON *o, pstate_t *s) { if (cJSON_IsString(v)) \ snprintf(s->field, sizeof(s->field), "%s", v->valuestring); STATE_STR("approval", approval) + STATE_STR("mode", mode) STATE_STR("branch", branch) STATE_STR("day", day) STATE_STR("notes", notes) @@ -2293,6 +2383,7 @@ static char *system_meta(const cst_agent_t *a, const pstate_t *s) { cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol)); cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION); cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile); + cJSON_AddStringToObject(o, "mode", cst_mode_name(a->cfg.mode)); cJSON_AddItemToObject(o, "state", state_json(s)); char *out = cJSON_PrintUnformatted(o); cJSON_Delete(o); @@ -2312,10 +2403,13 @@ static int system_current(const cst_message_t *m, const cst_agent_config_t *c) { const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv"); const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol"); const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools"); + const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode"); int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION && cJSON_IsString(pr) && strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 && cJSON_IsNumber(tp) && tp->valueint == c->tool_profile; + ok = ok && cJSON_IsString(md) && + strcmp(md->valuestring, cst_mode_name(c->mode)) == 0; cJSON_Delete(meta); return ok; } @@ -2393,6 +2487,14 @@ static void sync_notes(cst_agent_t *a) { "is available again.\n"); if (strcmp(now.approval, was.approval) != 0) cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval); + if (strcmp(now.mode, was.mode) != 0) + cst_buf_printf(&b, + "[cst] Working mode is now %s%s. Earlier instructions about " + "how to frame the work no longer apply.\n", + now.mode, + strcmp(now.mode, "general") == 0 + ? " (general: documents, data, research)" + : " (coding: repo, build, tests)"); if (strcmp(now.branch, was.branch) != 0) cst_buf_printf(&b, "[cst] The git branch is now %s.\n", now.branch[0] ? now.branch : "(none)"); diff --git a/src/attach.c b/src/attach.c index 51ac7ce..b5c89c4 100644 --- a/src/attach.c +++ b/src/attach.c @@ -4,6 +4,7 @@ #include "cJSON.h" #include "cst_attach.h" +#include "cst_pdf.h" #include "cst_util.h" #include "os_compat.h" @@ -102,6 +103,9 @@ static int le16(const unsigned char *p) { } static const char *mime_of_ext(const char *mime) { + if (strcmp(mime, "application/pdf") == 0 || + strcmp(mime, "text/plain") == 0) + return "txt"; /* a document is stored as the text it was reduced to */ if (strcmp(mime, "image/png") == 0) return "png"; if (strcmp(mime, "image/jpeg") == 0) @@ -352,23 +356,73 @@ static void make_id(const char *name, const char *data, size_t n, char *out, snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu)); } +/* n bytes, NUL-terminated: a text payload is handed around as a C string. */ +static char *memdup_z(const char *data, size_t n) { + char *copy = malloc(n + 1); + if (!copy) + return NULL; + memcpy(copy, data, n); + copy[n] = 0; + return copy; +} + +/* What a picked file is, plus the payload to store for it: an image stays as + * it is, a PDF is reduced to its text layer (the mime still says PDF, the + * stored file holds text), anything else must be UTF-8 text. The caller frees + * the result; *out_len is its length. */ +static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz, + int *pages, size_t *out_len, char *err, size_t err_sz) { + *pages = 0; + *out_len = n; + if (cst_attach_sniff(data, n, mime, mime_sz) == 0) { + if (n > CST_ATTACH_MAX_BYTES) { + snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB", + (double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20)); + return NULL; + } + char *copy = memdup_z(data, n); + if (!copy) + snprintf(err, err_sz, "out of memory"); + return copy; + } + if (n >= 4 && memcmp(data, "%PDF", 4) == 0) { + int pg = 0; + char perr[256]; + char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr)); + if (!text) { + snprintf(err, err_sz, "cannot read the PDF: %.200s", perr); + return NULL; + } + snprintf(mime, mime_sz, "application/pdf"); + *pages = pg; + *out_len = strlen(text); + return text; + } + if (n > CST_ATTACH_TEXT_MAX) { + snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB", + (double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20)); + return NULL; + } + if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) { + snprintf(err, err_sz, + "not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 " + "text; the content decides, not the extension"); + return NULL; + } + snprintf(mime, mime_sz, "text/plain"); + char *copy = memdup_z(data, n); + if (!copy) + snprintf(err, err_sz, "out of memory"); + return copy; +} + +/* Write one payload whose kind is already known. */ static cst_attachment_t *store(const char *session_path, const char *name, - const char *data, size_t n, const char *origin, - char *err, size_t err_sz) { + const char *data, size_t n, const char *mime, + int pages, const char *origin, char *err, + size_t err_sz) { if (!name || !name[0]) name = "image"; - char mime[32]; - if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) { - snprintf(err, err_sz, - "not a supported image (PNG, JPEG, GIF, WebP or BMP; " - "checked by content, not by extension)"); - return NULL; - } - if (n > CST_ATTACH_MAX_BYTES) { - snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB", - (double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20)); - return NULL; - } char dir[OS_PATH_MAX]; if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) { snprintf(err, err_sz, "cannot create the attachment directory"); @@ -386,7 +440,9 @@ static cst_attachment_t *store(const char *session_path, const char *name, snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user"); snprintf(a->detail, sizeof(a->detail), "auto"); a->bytes = (long long)n; - cst_attach_dimensions(data, n, &a->width, &a->height); + a->pages = pages; + if (cst_attach_is_image(a)) + cst_attach_dimensions(data, n, &a->width, &a->height); char full[OS_PATH_MAX]; if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) { @@ -419,14 +475,26 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path, } err[0] = 0; if (!session_path || !session_path[0]) { - snprintf(err, err_sz, "no session to store the image in"); + snprintf(err, err_sz, "no session to store the file in"); return NULL; } if (!data || !n) { - snprintf(err, err_sz, "image is empty"); + snprintf(err, err_sz, "the file is empty"); return NULL; } - return store(session_path, name, data, n, "user", err, err_sz); + char mime[32]; + if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) { + snprintf(err, err_sz, + "not a supported image (PNG, JPEG, GIF, WebP or BMP; " + "checked by content, not by extension)"); + return NULL; + } + if (n > CST_ATTACH_MAX_BYTES) { + snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB", + (double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20)); + return NULL; + } + return store(session_path, name, data, n, mime, 0, "user", err, err_sz); } cst_attachment_t *cst_attach_import_file(const char *session_path, @@ -449,11 +517,30 @@ cst_attachment_t *cst_attach_import_file(const char *session_path, for (const char *p = src_path; *p; p++) if (*p == '/' || *p == '\\') leaf = p + 1; - cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz); + char mime[32]; + int pages = 0; + size_t pn = 0; + char *payload = + prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz); free(data); + if (!payload) + return NULL; + cst_attachment_t *a = + store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz); + free(payload); return a; } +/* Images are opaque and travel as image parts; everything else is text the + * store holds and the request inlines. */ +int cst_attach_is_image(const cst_attachment_t *a) { + return a && strncmp(a->mime, "image/", 6) == 0; +} + +int cst_attach_is_text(const cst_attachment_t *a) { + return a && !cst_attach_is_image(a); +} + char *cst_attach_read(const char *session_path, const cst_attachment_t *a, size_t *out_len, char *err, size_t err_sz) { char dummy[8]; @@ -470,11 +557,12 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a, char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES); if (!data) { snprintf(err, err_sz, - "%.200s is missing; the image was deleted from the session " - "store", + "%.200s is missing; it was deleted from the session store", a->name); return NULL; } + if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */ + return data; char mime[32]; if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) { snprintf(err, err_sz, "%.200s in the session store is no longer an image", @@ -486,6 +574,8 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a, } char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) { + if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */ + return NULL; char err[256]; size_t n = 0; char *data = cst_attach_read(session_path, a, &n, err, sizeof(err)); @@ -509,10 +599,21 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) { snprintf(out, out_sz, "(none)"); return; } - const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime; + const char *kind = a->mime; char dim[32] = ""; - if (a->width > 0 && a->height > 0) - snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height); + if (cst_attach_is_image(a)) { + if (strncmp(a->mime, "image/", 6) == 0) + kind = a->mime + 6; + if (a->width > 0 && a->height > 0) + snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height); + } else if (strcmp(a->mime, "application/pdf") == 0) { + kind = "pdf"; + if (a->pages > 0) + snprintf(dim, sizeof(dim), "%d page%s ", a->pages, + a->pages == 1 ? "" : "s"); + } else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) { + kind = "text"; + } out[0] = 0; if (a->bytes >= 1024 * 1024) snprintf(out, out_sz, "%s%s %.1f MB", dim, kind, @@ -580,6 +681,8 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) { cJSON_AddNumberToObject(o, "width", a->width); cJSON_AddNumberToObject(o, "height", a->height); } + if (a->pages > 0) + cJSON_AddNumberToObject(o, "pages", a->pages); if (a->detail[0] && strcmp(a->detail, "auto") != 0) cJSON_AddStringToObject(o, "detail", a->detail); if (a->origin[0]) @@ -628,6 +731,8 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) a->width = cJSON_IsNumber(v) ? v->valueint : 0; v = cJSON_GetObjectItemCaseSensitive(it, "height"); a->height = cJSON_IsNumber(v) ? v->valueint : 0; + v = cJSON_GetObjectItemCaseSensitive(it, "pages"); + a->pages = cJSON_IsNumber(v) ? v->valueint : 0; v = cJSON_GetObjectItemCaseSensitive(it, "detail"); snprintf(a->detail, sizeof(a->detail), "%s", cJSON_IsString(v) ? v->valuestring : "auto"); diff --git a/src/clipboard.c b/src/clipboard.c index e0555c0..1c5fc3c 100644 --- a/src/clipboard.c +++ b/src/clipboard.c @@ -390,6 +390,175 @@ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) { #endif +/* --- text clipboard ------------------------------------------------------ */ + +#ifdef _WIN32 + +int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) { + *data = NULL; + *n = 0; + if (!IsClipboardFormatAvailable(CF_UNICODETEXT) || !OpenClipboard(NULL)) { + set_err(err, err_sz, "no text on the clipboard"); + return -1; + } + HANDLE h = GetClipboardData(CF_UNICODETEXT); + const wchar_t *w = h ? GlobalLock(h) : NULL; + if (!w) { + if (h) + GlobalUnlock(h); + CloseClipboard(); + set_err(err, err_sz, "no text on the clipboard"); + return -1; + } + char *utf8 = os_wide_to_utf8(w); + size_t len = utf8 ? strlen(utf8) : 0; + GlobalUnlock(h); + CloseClipboard(); + if (!utf8 || !len) { + free(utf8); + set_err(err, err_sz, "no text on the clipboard"); + return -1; + } + if (len > CST_CLIPBOARD_TEXT_MAX) { + free(utf8); + set_err(err, err_sz, "clipboard text is too large"); + return -1; + } + *data = utf8; + *n = len; + return 0; +} + +int cst_clipboard_set_text(const char *text, size_t n, char *err, + size_t err_sz) { + if (!text || os_clipboard_set(text, n) != 0) { + set_err(err, err_sz, "could not put text on the clipboard"); + return -1; + } + return 0; +} + +#else + +/* stdout of a clipboard reader, capped and NUL-terminated. */ +static int clip_read(const char *cmd, const char *tool, char **data, size_t *n, + char *err, size_t err_sz) { + os_run_result_t r; + memset(&r, 0, sizeof(r)); + if (os_run_shell_c(NULL, cmd, CST_CLIPBOARD_TEXT_MAX + 1, &r) != 0) { + os_run_result_free(&r); + set_err(err, err_sz, "could not read the clipboard"); + return -1; + } + if (r.exit_code != 0 || r.out_len == 0) { + int missing = r.exit_code == 127; + os_run_result_free(&r); + if (missing) { + char msg[96]; + snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool); + set_err(err, err_sz, msg); + } else { + set_err(err, err_sz, "no text on the clipboard"); + } + return -1; + } + if (r.out_len > CST_CLIPBOARD_TEXT_MAX) { + os_run_result_free(&r); + set_err(err, err_sz, "clipboard text is too large"); + return -1; + } + char *copy = malloc(r.out_len + 1); + if (!copy) { + os_run_result_free(&r); + set_err(err, err_sz, "out of memory"); + return -1; + } + memcpy(copy, r.out, r.out_len); + copy[r.out_len] = 0; + os_run_result_free(&r); + *data = copy; + *n = r.out_len; + return 0; +} + +int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) { + *data = NULL; + *n = 0; +#ifdef __APPLE__ + return clip_read("pbpaste 2>/dev/null", "pbpaste", data, n, err, err_sz); +#else + const char *wl = getenv("WAYLAND_DISPLAY"); + if (wl && *wl) + return clip_read("wl-paste --type text/plain --no-newline 2>/dev/null", + "wl-clipboard (wl-paste)", data, n, err, err_sz); + const char *x = getenv("DISPLAY"); + if (x && *x) + return clip_read("xclip -selection clipboard -o 2>/dev/null", "xclip", + data, n, err, err_sz); + set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)"); + return -1; +#endif +} + +int cst_clipboard_set_text(const char *text, size_t n, char *err, + size_t err_sz) { + if (!text) + return -1; + if (n > CST_CLIPBOARD_TEXT_MAX) { + set_err(err, err_sz, "text is too large for the clipboard"); + return -1; + } + const char *fmt = NULL, *tool = NULL; +#ifdef __APPLE__ + fmt = "pbcopy < '%s'"; + tool = "pbcopy"; +#else + if (getenv("WAYLAND_DISPLAY")) { + fmt = "wl-copy < '%s'"; + tool = "wl-clipboard (wl-copy)"; + } else if (getenv("DISPLAY")) { + fmt = "xclip -selection clipboard -i < '%s'"; + tool = "xclip"; + } +#endif + if (!fmt) { + set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)"); + return -1; + } + char tmp[OS_PATH_MAX]; + int fd = os_mkstemp(tmp, sizeof(tmp), "cst-clip"); + if (fd < 0) { + set_err(err, err_sz, "could not create a temporary file"); + return -1; + } + FILE *fp = fdopen(fd, "wb"); + int ok = fp && fwrite(text, 1, n, fp) == n && fclose(fp) == 0; + if (!ok) { + if (fp) + fclose(fp); + remove(tmp); + set_err(err, err_sz, "could not write the text"); + return -1; + } + char cmd[OS_PATH_MAX + 64]; + snprintf(cmd, sizeof(cmd), fmt, tmp); + os_run_result_t r; + memset(&r, 0, sizeof(r)); + int rc = os_run_shell_c(NULL, cmd, 4096, &r); + int code = r.exit_code; + os_run_result_free(&r); + remove(tmp); + if (rc != 0 || code != 0) { + char msg[96]; + snprintf(msg, sizeof(msg), "writing the clipboard needs %s", tool); + set_err(err, err_sz, msg); + return -1; + } + return 0; +} + +#endif + static int hexval(int c) { if (c >= '0' && c <= '9') return c - '0'; diff --git a/src/llm_openai.c b/src/llm_openai.c index 0561efd..52bde6f 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -255,28 +255,78 @@ static cJSON *image_part(const cst_llm_request_t *req, return p; } -/* Append every attachment of m as image parts (or a note saying why not). */ -static void parts_add_images(const cst_llm_request_t *req, cJSON *parts, - const cst_message_t *m, size_t *budget) { +/* One stored document as a text part: its text, framed so the model knows + * where it came from. Documents are plain text on the wire, so a text-only + * model and CST_NO_IMAGES both still get them. */ +static cJSON *document_part(const cst_llm_request_t *req, + const cst_attachment_t *a, size_t *budget, + char *why, size_t why_sz) { + why[0] = 0; + if (!req->session_path || !req->session_path[0]) { + snprintf(why, why_sz, "there is no session store to read it from"); + return NULL; + } + char err[256]; + size_t n = 0; + char *text = cst_attach_read(req->session_path, a, &n, err, sizeof(err)); + if (!text) { + snprintf(why, why_sz, "%s", err); + return NULL; + } + int cut = 0; + if (n > *budget) { + n = *budget; + cut = 1; + } + *budget -= n; + char desc[128]; + cst_attach_describe(a, desc, sizeof(desc)); + cst_buf_t b = {0}; + cst_buf_printf(&b, "[document \"%.120s\" (%s)%s]\n", a->name, desc, + cut ? " -- only the start is included" : ""); + cst_buf_append(&b, text, n); + cst_buf_puts(&b, cut ? "\n[-- document truncated --]" + : "\n[-- end of document --]"); + free(text); + cJSON *p = cJSON_CreateObject(); + cJSON_AddStringToObject(p, "type", "text"); + cJSON_AddStringToObject(p, "text", b.buf ? b.buf : ""); + cst_buf_free(&b); + return p; +} + +/* Append every attachment of m: images as image parts (or a note saying why + * not), documents inline as text. */ +static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, + const cst_message_t *m, size_t *budget, + size_t *doc_budget, const char *img_off) { int n = m->nattach; if (n > CST_ATTACH_MAX_COUNT) { char note[160]; - snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT, - n); + snprintf(note, sizeof(note), + "[only the first %d of %d attachments are sent]", + CST_ATTACH_MAX_COUNT, n); parts_add_text(parts, cJSON_CreateString(note)); n = CST_ATTACH_MAX_COUNT; } for (int i = 0; i < n; i++) { const cst_attachment_t *a = &m->attach[i]; + int is_img = cst_attach_is_image(a); char why[256]; - cJSON *p = image_part(req, a, budget, why, sizeof(why)); + cJSON *p = NULL; + if (is_img && img_off) + snprintf(why, sizeof(why), "%s", img_off); + else if (is_img) + p = image_part(req, a, budget, why, sizeof(why)); + else + p = document_part(req, a, doc_budget, why, sizeof(why)); if (p) { cJSON_AddItemToArray(parts, p); continue; } char note[512]; - snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]", - a->name, why); + snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]", + is_img ? "image" : "document", a->name, why); parts_add_text(parts, cJSON_CreateString(note)); } } @@ -284,8 +334,10 @@ static void parts_add_images(const cst_llm_request_t *req, cJSON *parts, /* Images a tool result produced are deferred: an assistant tool_call must be * answered by tool messages only, so they ride in one extra user message * after the run of tool messages ends. *pending collects it. */ -static void defer_images(const cst_llm_request_t *req, const cst_message_t *m, - cJSON **pending, size_t *budget) { +static void defer_attachments(const cst_llm_request_t *req, + const cst_message_t *m, cJSON **pending, + size_t *budget, size_t *doc_budget, + const char *img_off) { cJSON *p = *pending; if (!p) { p = cJSON_CreateObject(); @@ -293,15 +345,18 @@ static void defer_images(const cst_llm_request_t *req, const cst_message_t *m, cJSON_AddArrayToObject(p, "content"); *pending = p; } - parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, - budget); + parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, + budget, doc_budget, img_off); } -/* m's text plus one line per image it would have carried (malloc'd). */ +/* m's text plus one line per image it would have carried (malloc'd); a + * document is not affected (it is text and is sent as such). */ static char *image_notes(const cst_message_t *m, const char *why) { cst_buf_t b = {0}; cst_buf_puts(&b, m->content ? m->content : ""); for (int i = 0; i < m->nattach; i++) { + if (!cst_attach_is_image(&m->attach[i])) + continue; char d[128]; cst_attach_describe(&m->attach[i], d, sizeof(d)); cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]", @@ -394,6 +449,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { cJSON *prev_user = NULL; /* merge consecutive user messages */ cJSON *pending = NULL; /* images from tool results, waiting for a break */ size_t img_budget = CST_ATTACH_MAX_TOTAL; + size_t doc_budget = CST_ATTACH_MAX_TOTAL; const char *noimg = getenv("CST_NO_IMAGES"); const char *img_off = noimg && noimg[0] && noimg[0] != '0' @@ -407,15 +463,26 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { cst_role_t role = m->role; if (role == CST_ROLE_TOOL && !m->tool_call_id[0]) role = CST_ROLE_USER; - int has_img = m->attach && m->nattach > 0; + int n_att = m->attach ? m->nattach : 0; + int has_img = 0, has_doc = 0; + for (int k = 0; k < n_att; k++) { + if (cst_attach_is_image(&m->attach[k])) + has_img = 1; + else + has_doc = 1; + } char *noted = NULL; if (has_img && img_off) { - /* plain text: text-only APIs may refuse content arrays */ - noted = image_notes(m, img_off); - if (noted) - mm.content = noted; has_img = 0; + if (!has_doc) { + /* plain text: text-only APIs may refuse content arrays */ + noted = image_notes(m, img_off); + if (noted) + mm.content = noted; + } } + /* a document needs a content array even when no image can go out */ + int att_parts = has_img || has_doc; /* Every tool_call must be answered by tool messages, so tool-produced * images only leave the queue when the run of tool results is over. */ @@ -431,7 +498,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { cJSON *pc = prev_user ? cJSON_GetObjectItemCaseSensitive(prev_user, "content") : NULL; - if (!has_img && !cJSON_IsArray(pc)) { + if (!att_parts && !cJSON_IsArray(pc)) { cJSON *c = clean_string(m->content, 0); if (prev_user) { cst_buf_t b = {0}; @@ -463,8 +530,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { } cJSON *parts = content_parts(o); parts_add_text(parts, clean_string(m->content, 0)); - if (has_img) - parts_add_images(req, parts, m, &img_budget); + if (att_parts) + parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off); free(noted); continue; } @@ -498,8 +565,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { cJSON_AddTrueToObject(o, "prefix"); } cJSON_AddItemToArray(msgs, o); - if (role == CST_ROLE_TOOL && has_img) - defer_images(req, m, &pending, &img_budget); + if (role == CST_ROLE_TOOL && n_att) + defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off); free(noted); } if (pending) diff --git a/src/main.c b/src/main.c index dc9f220..a4c4923 100644 --- a/src/main.c +++ b/src/main.c @@ -26,6 +26,8 @@ static void usage(const char *argv0) { " (last used)\n" " -m, --model NAME model (last used for the provider)\n" " -e, --effort LEVEL low | medium | high | max (last used)\n" + " -M, --mode NAME coding (default, code framed) | general\n" + " (documents, data, research; last used)\n" " -w, --workspace DIR project directory (default: current)\n" " -c, --continue reopen the last session (default)\n" " -N, --new start a new session\n" @@ -59,13 +61,13 @@ static void usage(const char *argv0) { "API keys: the TUI asks for a missing key and saves it per provider\n" " in the config dir (credentials, mode 0600); /key changes it.\n" " Fallbacks: (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n" - "Settings: provider, models and effort are remembered in\n" + "Settings: provider, models, effort and mode are remembered in\n" " settings.json next to the credentials.\n" - "Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_PROTOCOL CST_TOOLS\n" - " CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n" - " CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n" - " CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n" - " CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n" + "Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n" + " CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n" + " CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n" + " CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n" + " CST_MODELS CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n" " CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n" "TUI: type /help inside for commands and keys\n", argv0); @@ -374,6 +376,7 @@ int main(int argc, char **argv) { {"approval", required_argument, 0, 'A'}, {"confirm", required_argument, 0, 'C'}, {"effort", required_argument, 0, 'e'}, + {"mode", required_argument, 0, 'M'}, {"help", no_argument, 0, 'h'}, {"version", no_argument, 0, 'V'}, {"list-models", optional_argument, 0, 0x100}, @@ -385,8 +388,8 @@ int main(int argc, char **argv) { {"web-open", no_argument, 0, 0x106}, {0, 0, 0, 0}}; int c; - while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) != - -1) { + while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:", + opts, NULL)) != -1) { switch (c) { case 'w': snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg); @@ -478,6 +481,14 @@ int main(int argc, char **argv) { } snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg); break; + case 'M': + if (!cst_mode_valid(optarg)) { + fprintf(stderr, "bad --mode %s (coding | general)\n", optarg); + curl_global_cleanup(); + return 1; + } + snprintf(cfg.mode, sizeof(cfg.mode), "%s", optarg); + break; case 'h': usage(argv[0]); curl_global_cleanup(); @@ -557,6 +568,12 @@ int main(int argc, char **argv) { snprintf(cfg.effort, sizeof(cfg.effort), "%s", cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT); } + if (!cfg.mode[0]) { + const char *m = getenv("CST_MODE"); + if (!cst_mode_valid(m)) + m = saved.mode; + snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m)); + } if (!cfg.api_base[0]) { const char *env_base = getenv("CST_API_BASE"); if (!env_base || !env_base[0]) diff --git a/src/pdf_text.c b/src/pdf_text.c new file mode 100644 index 0000000..8dbaa5f --- /dev/null +++ b/src/pdf_text.c @@ -0,0 +1,1901 @@ +#include "cst_pdf.h" +#include "cst_util.h" + +#include +#include +#include + +/* Text layer of an ordinary PDF (papers, reports, contracts, invoices), in + * one file and with no external dependency. Deliberately not a renderer, a + * repair tool or OCR: scanned pages, encrypted files and exotic filters come + * back as a clear message instead of garbage. + * + * Pipeline: inflate (RFC 1951) -> stream filters (Flate/ASCIIHex/ASCII85 + + * predictors) -> object scan (classic objects and object streams) -> page + * tree -> content operators with per-font ToUnicode CMaps. */ + +enum { + PDF_MAX_OBJECTS = 20000, + PDF_MAX_PAGES = 2000, + PDF_MAX_CMAP = 32768, /* mapping entries per font */ + PDF_MAX_RUN = 4096, /* bytes of one string operand */ + PDF_MAX_KIDS = 4096, /* page-tree nodes we are willing to visit */ + PDF_MAX_ARR = 256 /* items of one TJ array */ +}; +#define PDF_MAX_STREAM (32u * 1024u * 1024u) + +static void set_err(char *err, size_t err_sz, const char *msg) { + if (err && err_sz) + snprintf(err, err_sz, "%s", msg); +} + +static int is_ws(int c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || + c == '\0'; +} + +static const char *skip_ws(const char *p, const char *end) { + while (p < end && is_ws((unsigned char)*p)) + p++; + return p; +} + +/* --- inflate (RFC 1951), puff-style canonical Huffman --------------------- */ + +typedef struct { + const unsigned char *p; + size_t n, i; + unsigned bit; + int nbits, err; +} bits_t; + +static int bits_get(bits_t *b, int need) { + while (b->nbits < need) { + if (b->i >= b->n) { + b->err = 1; + return 0; + } + b->bit |= (unsigned)b->p[b->i++] << b->nbits; + b->nbits += 8; + } + int v = (int)(b->bit & ((1u << need) - 1)); + b->bit >>= need; + b->nbits -= need; + return v; +} + +typedef struct { + short count[16]; + short symbol[288]; +} huff_t; + +static void huff_build(huff_t *h, const unsigned char *lengths, int n) { + short offs[16]; + int len; + for (len = 0; len < 16; len++) + h->count[len] = 0; + for (int i = 0; i < n; i++) + h->count[lengths[i]]++; + offs[1] = 0; + for (len = 1; len < 15; len++) + offs[len + 1] = (short)(offs[len] + h->count[len]); + for (int i = 0; i < n; i++) + if (lengths[i]) + h->symbol[offs[lengths[i]]++] = (short)i; +} + +static int huff_decode(bits_t *b, const huff_t *h) { + int code = 0, first = 0, index = 0; + for (int len = 1; len < 16; len++) { + code |= bits_get(b, 1); + if (b->err) + return -1; + int count = h->count[len]; + if (code - count < first) + return h->symbol[index + (code - first)]; + index += count; + first += count; + first <<= 1; + code <<= 1; + } + return -1; +} + +static const short LENS[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, + 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, + 67, 83, 99, 115, 131, 163, 195, 227, 258}; +static const short LEXT[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, + 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0}; +static const short DISTS[30] = {1, 2, 3, 4, 5, 7, 9, + 13, 17, 25, 33, 49, 65, 97, + 129, 193, 257, 385, 513, 769, 1025, + 1537, 2049, 3073, 4097, 6145, 8193, 12289, + 16385, 24577}; +static const short DEXT[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, + 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, + 13}; +static const short ORD[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, + 13, 2, 14, 1, 15}; + +/* Raw deflate, appended to out. 0 on success. */ +static int inflate_raw(const unsigned char *in, size_t n, cst_buf_t *out, + size_t max) { + bits_t b = {in, n, 0, 0, 0, 0}; + huff_t lencode, distcode; + for (;;) { + int last = bits_get(&b, 1); + int type = bits_get(&b, 2); + if (b.err) + return -1; + if (type == 0) { + unsigned len, nlen; + b.bit = 0; + b.nbits = 0; + if (b.i + 4 > b.n) + return -1; + len = (unsigned)in[b.i] | (unsigned)in[b.i + 1] << 8; + nlen = (unsigned)in[b.i + 2] | (unsigned)in[b.i + 3] << 8; + b.i += 4; + if ((len ^ 0xffffu) != nlen || b.i + len > b.n || + out->len + len > max) + return -1; + if (cst_buf_append(out, (const char *)in + b.i, len) != 0) + return -1; + b.i += len; + } else if (type == 1 || type == 2) { + if (type == 1) { + unsigned char lengths[288]; + for (int i = 0; i < 144; i++) + lengths[i] = 8; + for (int i = 144; i < 256; i++) + lengths[i] = 9; + for (int i = 256; i < 280; i++) + lengths[i] = 7; + for (int i = 280; i < 288; i++) + lengths[i] = 8; + huff_build(&lencode, lengths, 288); + for (int i = 0; i < 30; i++) + lengths[i] = 5; + huff_build(&distcode, lengths, 30); + } else { + unsigned char lengths[320], clen[19]; + huff_t clcode; + int hlit = bits_get(&b, 5) + 257; + int hdist = bits_get(&b, 5) + 1; + int hclen = bits_get(&b, 4) + 4; + if (b.err || hlit > 286 || hdist > 30) + return -1; + memset(lengths, 0, sizeof(lengths)); + memset(clen, 0, sizeof(clen)); + for (int i = 0; i < hclen; i++) + clen[ORD[i]] = (unsigned char)bits_get(&b, 3); + huff_build(&clcode, clen, 19); + int i = 0; + while (i < hlit + hdist) { + int sym = huff_decode(&b, &clcode); + if (sym < 0) + return -1; + if (sym < 16) { + lengths[i++] = (unsigned char)sym; + continue; + } + int rep, val = 0; + if (sym == 16) { + if (i == 0) + return -1; + val = lengths[i - 1]; + rep = 3 + bits_get(&b, 2); + } else if (sym == 17) { + rep = 3 + bits_get(&b, 3); + } else { + rep = 11 + bits_get(&b, 7); + } + if (b.err || i + rep > hlit + hdist) + return -1; + while (rep-- > 0) + lengths[i++] = (unsigned char)val; + } + if (lengths[256] == 0) + return -1; + huff_build(&lencode, lengths, hlit); + huff_build(&distcode, lengths + hlit, hdist); + } + for (;;) { + int sym = huff_decode(&b, &lencode); + if (sym < 0) + return -1; + if (sym < 256) { + char ch = (char)sym; + if (out->len + 1 > max || cst_buf_append(out, &ch, 1) != 0) + return -1; + continue; + } + if (sym == 256) + break; + sym -= 257; + if (sym >= 29) + return -1; + int len = LENS[sym] + bits_get(&b, LEXT[sym]); + int dsym = huff_decode(&b, &distcode); + if (b.err || dsym < 0 || dsym >= 30) + return -1; + size_t dist = (size_t)(DISTS[dsym] + bits_get(&b, DEXT[dsym])); + if (b.err || dist == 0 || dist > out->len || + out->len + (size_t)len > max) + return -1; + for (int k = 0; k < len; k++) { + char ch = out->buf[out->len - dist]; + if (cst_buf_append(out, &ch, 1) != 0) + return -1; + } + } + } else { + return -1; /* type 3 is reserved */ + } + if (last) + break; + } + return 0; +} + +/* --- stream filters ------------------------------------------------------ */ + +static int hex_digit(int c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static int decode_ahx(const char *in, size_t n, cst_buf_t *out, size_t max) { + int hi = -1; + for (size_t i = 0; i < n; i++) { + int c = (unsigned char)in[i]; + if (c == '>') + break; + if (is_ws(c)) + continue; + int v = hex_digit(c); + if (v < 0) + return -1; + if (hi < 0) { + hi = v; + continue; + } + if (out->len + 1 > max) + return -1; + char ch = (char)(hi * 16 + v); + if (cst_buf_append(out, &ch, 1) != 0) + return -1; + hi = -1; + } + if (hi >= 0) { + char ch = (char)(hi * 16); + if (cst_buf_append(out, &ch, 1) != 0) + return -1; + } + return 0; +} + +static int decode_a85(const char *in, size_t n, cst_buf_t *out, size_t max) { + unsigned long tuple = 0; + int count = 0; + for (size_t i = 0; i < n; i++) { + int c = (unsigned char)in[i]; + if (is_ws(c)) + continue; + if (c == '~') + break; + if (c == 'z' && count == 0) { + for (int k = 0; k < 4; k++) + if (out->len + 1 > max || cst_buf_append(out, "\0", 1) != 0) + return -1; + continue; + } + if (c < '!' || c > 'u') + return -1; + tuple = tuple * 85 + (unsigned long)(c - '!'); + if (++count == 5) { + if (out->len + 4 > max) + return -1; + for (int k = 3; k >= 0; k--) { + char ch = (char)((tuple >> (8 * k)) & 0xff); + if (cst_buf_append(out, &ch, 1) != 0) + return -1; + } + tuple = 0; + count = 0; + } + } + if (count > 1) { + for (int k = count; k < 5; k++) + tuple = tuple * 85 + 84; + for (int k = 0; k < count - 1; k++) { + char ch = (char)((tuple >> (8 * (3 - k))) & 0xff); + if (cst_buf_append(out, &ch, 1) != 0) + return -1; + } + } + return 0; +} + +static unsigned char paeth(int a, int b, int c) { + int p = a + b - c; + int pa = abs(p - a), pb = abs(p - b), pc = abs(p - c); + if (pa <= pb && pa <= pc) + return (unsigned char)a; + return (unsigned char)(pb <= pc ? b : c); +} + +/* PNG (10-15) and TIFF (2) predictors, in place. */ +static int unfilter(char *d, size_t len, int pred, int colors, int bpc, + int columns) { + if (pred < 2) + return 0; + if (colors < 1) + colors = 1; + if (bpc < 1) + bpc = 8; + if (columns < 1) + columns = 1; + size_t bpp = ((size_t)colors * (size_t)bpc + 7) / 8; + if (bpp < 1) + bpp = 1; + if (pred == 2) { + if (bpc != 8) + return -1; + size_t row = (size_t)columns * (size_t)colors; + if (!row) + return 0; + for (size_t r = 0; r + row <= len; r += row) + for (size_t i = bpp; i < row; i++) + d[r + i] = (char)((unsigned char)d[r + i] + + (unsigned char)d[r + i - bpp]); + return 0; + } + if (pred < 10 || pred > 15) + return -1; + size_t rowbytes = ((size_t)columns * (size_t)colors * (size_t)bpc + 7) / 8; + size_t stride = rowbytes + 1; + size_t rows = len / stride; + for (size_t r = 0; r < rows; r++) { + unsigned char ft = (unsigned char)d[r * stride]; + char *cur = d + r * stride + 1; + const char *prev = r ? d + (r - 1) * stride + 1 : NULL; + if (ft == 0) + continue; + if (ft == 1) { + for (size_t i = bpp; i < rowbytes; i++) + cur[i] = (char)((unsigned char)cur[i] + (unsigned char)cur[i - bpp]); + } else if (ft == 2 && prev) { + for (size_t i = 0; i < rowbytes; i++) + cur[i] = (char)((unsigned char)cur[i] + (unsigned char)prev[i]); + } else if (ft == 3 && prev) { + for (size_t i = 0; i < rowbytes; i++) { + int a = i >= bpp ? (unsigned char)cur[i - bpp] : 0; + cur[i] = (char)((unsigned char)cur[i] + + (unsigned char)((a + (unsigned char)prev[i]) / 2)); + } + } else if (ft == 4 && prev) { + for (size_t i = 0; i < rowbytes; i++) { + int a = i >= bpp ? (unsigned char)cur[i - bpp] : 0; + int cc = i >= bpp ? (unsigned char)prev[i - bpp] : 0; + cur[i] = (char)((unsigned char)cur[i] + + paeth(a, (unsigned char)prev[i], cc)); + } + } else if (ft != 2 && ft != 3) { + return -1; + } + } + return 0; +} + +/* --- objects ------------------------------------------------------------- */ + +typedef struct { + const unsigned char *file; + size_t flen; + struct obj { + long num; + const char *p; + size_t n; + } *objs; + int nobjs, cap, sorted; + char **owned; /* object-stream buffers we must keep alive */ + int nowned, cap_owned; +} doc_t; + +static void doc_own(doc_t *d, char *buf) { + if (d->nowned == d->cap_owned) { + int cap = d->cap_owned ? d->cap_owned * 2 : 4; + char **n = realloc(d->owned, (size_t)cap * sizeof(*n)); + if (!n) + return; + d->owned = n; + d->cap_owned = cap; + } + d->owned[d->nowned++] = buf; +} + +static void doc_add(doc_t *d, long num, const char *p, size_t n) { + if (d->nobjs >= PDF_MAX_OBJECTS) + return; + if (d->nobjs == d->cap) { + int cap = d->cap ? d->cap * 2 : 512; + if (cap > PDF_MAX_OBJECTS) + cap = PDF_MAX_OBJECTS; + struct obj *v = realloc(d->objs, (size_t)cap * sizeof(*v)); + if (!v) + return; + d->objs = v; + d->cap = cap; + } + d->objs[d->nobjs].num = num; + d->objs[d->nobjs].p = p; + d->objs[d->nobjs].n = n; + d->nobjs++; + d->sorted = 0; +} + +static int obj_cmp(const void *a, const void *b) { + const struct obj *x = a, *y = b; + if (x->num != y->num) + return x->num < y->num ? -1 : 1; + if (x->n != y->n) + return x->n > y->n ? -1 : 1; /* an object stream entry is a fallback */ + return 0; +} + +static const struct obj *obj_find(doc_t *d, long num) { + if (!d->sorted) { + qsort(d->objs, (size_t)d->nobjs, sizeof(*d->objs), obj_cmp); + d->sorted = 1; + } + int lo = 0, hi = d->nobjs - 1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (d->objs[mid].num == num) + return &d->objs[mid]; + if (d->objs[mid].num < num) + lo = mid + 1; + else + hi = mid - 1; + } + return NULL; +} + +/* "N G obj" ... "endobj" scan. Deliberately layout independent: no xref is + * trusted, which also covers files with a broken or missing xref table. */ +static void scan_objects(doc_t *d) { + const char *s = (const char *)d->file; + const char *end = s + d->flen; + for (const char *p = s; p + 3 < end; p++) { + if (p[0] != 'o' || p[1] != 'b' || p[2] != 'j') + continue; + if (p > s && !is_ws((unsigned char)p[-1]) && p[-1] != '>') + continue; + /* walk back over "num ws gen ws" */ + const char *q = p; + while (q > s && is_ws((unsigned char)q[-1])) + q--; + const char *gend = q; + while (q > s && q[-1] >= '0' && q[-1] <= '9') + q--; + if (q == gend || (q > s && !is_ws((unsigned char)q[-1]))) + continue; + while (q > s && is_ws((unsigned char)q[-1])) + q--; + const char *numend = q; + while (q > s && q[-1] >= '0' && q[-1] <= '9') + q--; + if (q == numend) + continue; + long num = 0; + for (const char *t = q; t < numend; t++) + num = num * 10 + (*t - '0'); + const char *body = p + 3; + const char *e = NULL; + for (const char *t = body; t + 6 <= end; t++) + if (strncmp(t, "endobj", 6) == 0) { + e = t; + break; + } + if (!e) + e = end; + doc_add(d, num, body, (size_t)(e - body)); + p = e; + } +} + +/* "N 0 R" -> num; *p advanced past it. */ +static int read_ref(const char **p, const char *end, long *num) { + const char *q = skip_ws(*p, end); + if (q >= end || *q < '0' || *q > '9') + return -1; + long v = 0; + while (q < end && *q >= '0' && *q <= '9') + v = v * 10 + (*q++ - '0'); + q = skip_ws(q, end); + if (q >= end || *q < '0' || *q > '9') + return -1; + while (q < end && *q >= '0' && *q <= '9') + q++; + q = skip_ws(q, end); + if (q >= end || *q != 'R') + return -1; + *p = q + 1; + *num = v; + return 0; +} + +/* --- dictionaries -------------------------------------------------------- */ + +static size_t name_len(const char *p, const char *end) { + if (p >= end || *p != '/') + return 0; + const char *q = p + 1; + while (q < end && !is_ws((unsigned char)*q) && !strchr("/<>[](){}%", *q)) + q++; + return (size_t)(q - (p + 1)); +} + +/* Position just after the literal string starting at p ('(' ... ')'). */ +static const char *skip_string(const char *p, const char *end) { + int depth = 1; + p++; + while (p < end && depth) { + if (*p == '\\' && p + 1 < end) { + p += 2; + continue; + } + if (*p == '(') + depth++; + else if (*p == ')') + depth--; + p++; + } + return p; +} + +static int is_token_end(int c) { + return is_ws(c) || strchr("/<>[](){}%", c) != NULL; +} + +/* Position just after one object (number, name, string, array, dict, ...). + * "12 0 R" counts as one value, which is what dictionary lookups want. */ +static const char *skip_value(const char *p, const char *end) { + p = skip_ws(p, end); + if (p >= end) + return p; + char c = *p; + if (c == '<' && p + 1 < end && p[1] == '<') { /* dictionary */ + int depth = 0; + while (p < end) { + if (p[0] == '<' && p + 1 < end && p[1] == '<') { + depth++; + p += 2; + continue; + } + if (p[0] == '>' && p + 1 < end && p[1] == '>') { + depth--; + p += 2; + if (depth <= 0) + return p; + continue; + } + p = *p == '(' ? skip_string(p, end) : p + 1; + } + return p; + } + if (c == '[' || c == '{') { /* array */ + char close = c == '[' ? ']' : '}'; + int depth = 0; + while (p < end) { + if (*p == c) { + depth++; + p++; + continue; + } + if (*p == close) { + depth--; + p++; + if (depth <= 0) + return p; + continue; + } + if (p[0] == '<' && p + 1 < end && p[1] == '<') { + p = skip_value(p, end); + continue; + } + p = *p == '(' ? skip_string(p, end) : p + 1; + } + return p; + } + if (c == '(') + return skip_string(p, end); + if (c == '<') { /* hex string */ + while (p < end && *p != '>') + p++; + return p < end ? p + 1 : end; + } + if (c == '/') { + size_t l = name_len(p, end); + return p + 1 + (l ? l : 1); + } + const char *q = p; + while (q < end && !is_token_end((unsigned char)*q)) + q++; + if (q > p) { /* "N G R" is a single value */ + const char *r = skip_ws(q, end); + if (r > q && r < end && *r >= '0' && *r <= '9') { + const char *s = r; + while (s < end && *s >= '0' && *s <= '9') + s++; + s = skip_ws(s, end); + if (s < end && *s == 'R') + q = s + 1; + } + } + return q; +} + +/* Value of /key in the dictionary at p (single dict, shallow keys). */ +static int dict_get(const char *p, const char *end, const char *key, + const char **v, size_t *vlen) { + p = skip_ws(p, end); + if (end - p < 2 || p[0] != '<' || p[1] != '<') + return -1; + p = skip_ws(p + 2, end); + while (p < end && !(p[0] == '>' && p + 1 < end && p[1] == '>')) { + if (*p != '/') { /* stray value */ + const char *e = skip_value(p, end); + p = skip_ws(e > p ? e : p + 1, end); + continue; + } + size_t nl = name_len(p, end); + const char *val = skip_ws(p + 1 + nl, end); + const char *e = skip_value(val, end); + if (strlen(key) == nl && nl > 0 && strncmp(p + 1, key, nl) == 0) { + *v = val; + *vlen = (size_t)(e - val); + return 0; + } + p = skip_ws(e, end); + } + return -1; +} + +/* Follow "N 0 R" chains (bounded); returns the object the value points at, + * or NULL when it is not a reference or does not resolve. */ +static const struct obj *resolve(doc_t *d, const char *p, size_t n) { + for (int hop = 0; hop < 8; hop++) { + long num = 0; + if (read_ref(&p, p + n, &num) != 0) + return NULL; + const struct obj *o = obj_find(d, num); + if (!o) + return NULL; + const char *q = o->p; + long next = 0; + if (read_ref(&q, o->p + o->n, &next) != 0) + return o; /* a plain object: done */ + p = o->p; /* the object is itself a reference: follow it */ + n = o->n; + } + return NULL; +} + +/* Direct object if the value is one, else the object it points at. */ +static const struct obj *value_obj(doc_t *d, const char *p, size_t n) { + const char *q = skip_ws(p, p + n); + if (q < p + n && *q >= '0' && *q <= '9') + return resolve(d, q, (size_t)(p + n - q)); + return NULL; +} + +/* --- streams ------------------------------------------------------------- */ + +/* Decode the stream of object o; 0 on success, -1 on failure, -2 when the + * stream is image data (DCT/JPX/CCITT/JBIG2). */ +static int stream_decode(doc_t *d, const struct obj *o, cst_buf_t *out) { + const char *p = skip_ws(o->p, o->p + o->n); + const char *end = o->p + o->n; + const char *dict = p; + const char *kw = NULL; + if (p + 1 < end && p[0] == '<' && p[1] == '<') { + const char *e = skip_value(p, end); + dict = p; + for (const char *t = e; t + 6 <= end; t++) + if (strncmp(t, "stream", 6) == 0) { + kw = t; + break; + } + } + if (!kw) + return -1; + size_t dlen = (size_t)(end - dict); + const char *data = kw + 6; + if (data < end && *data == '\r') + data++; + if (data < end && *data == '\n') + data++; + size_t rawlen = (size_t)(end - data); + const char *lv = NULL; + size_t lvlen = 0; + if (dict_get(dict, dict + dlen, "Length", &lv, &lvlen) == 0) { + long n = 0; + const char *q = skip_ws(lv, lv + lvlen); + if (q < lv + lvlen && *q >= '0' && *q <= '9') { + while (q < lv + lvlen && *q >= '0' && *q <= '9') + n = n * 10 + (*q++ - '0'); + } else { + const struct obj *lo = value_obj(d, lv, lvlen); + if (lo) { + q = skip_ws(lo->p, lo->p + lo->n); + while (q < lo->p + lo->n && *q >= '0' && *q <= '9') + n = n * 10 + (*q++ - '0'); + } + } + if (n > 0 && (size_t)n <= rawlen) + rawlen = (size_t)n; + } + /* /Length is optional here: the first endstream ends the data */ + for (size_t i = 0; i + 9 <= rawlen; i++) { + if (memcmp(data + i, "endstream", 9) == 0) { + rawlen = i; + break; + } + } + while (rawlen && (data[rawlen - 1] == '\n' || data[rawlen - 1] == '\r')) + rawlen--; + cst_buf_t cur = {0}; + if (cst_buf_append(&cur, data, rawlen) != 0) { + cst_buf_free(&cur); + return -1; + } + /* /Filter may be one name or an array; /DecodeParms matches it */ + const char *fv = NULL, *dv = NULL, *q = NULL; + size_t fvlen = 0, dvlen = 0; + int filters = 0; + if (dict_get(dict, dict + dlen, "Filter", &fv, &fvlen) == 0) { + q = skip_ws(fv, fv + fvlen); + filters = (q < fv + fvlen && *q == '[') ? -1 : 1; + } + dict_get(dict, dict + dlen, "DecodeParms", &dv, &dvlen); + if (dict_get(dict, dict + dlen, "DP", &dv, &dvlen) != 0 && !dv) + dv = NULL; + for (int step = 0;; step++) { + const char *name = NULL; + size_t nlen = 0; + const char *parm = NULL; + size_t parmlen = 0; + if (filters == 1) { + if (step > 0) + break; + const char *f = skip_ws(fv, fv + fvlen); + size_t l = name_len(f, fv + fvlen); + if (!l) + break; + name = f + 1; + nlen = l; + parm = dv; + parmlen = dvlen; + } else if (filters == -1) { + const char *f = skip_ws(fv + 1, fv + fvlen); + const char *pp = skip_ws(dv + 1, dv + dvlen); + int idx = 0; + while (f < fv + fvlen && idx < step) { + f = skip_value(f, fv + fvlen); + f = skip_ws(f, fv + fvlen); + if (f < fv + fvlen && *f == ',') + f = skip_ws(f + 1, fv + fvlen); + if (pp && pp < dv + dvlen) { + pp = skip_value(pp, dv + dvlen); + pp = skip_ws(pp, dv + dvlen); + if (pp < dv + dvlen && *pp == ',') + pp = skip_ws(pp + 1, dv + dvlen); + } + idx++; + } + if (f >= fv + fvlen || *f == ']') + break; + size_t l = name_len(f, fv + fvlen); + if (!l) + break; + name = f + 1; + nlen = l; + if (pp && pp < dv + dvlen) + parm = pp, parmlen = (size_t)(dv + dvlen - pp); + } else { + break; + } + cst_buf_t next = {0}; + if (nlen == 11 && strncmp(name, "FlateDecode", 11) == 0) { + /* PDF's Flate is zlib-framed (RFC 1950): skip the 2-byte header when + * it checks out. A few writers emit bare deflate, so that passes too; + * the Adler-32 trailer is simply left over (and ignored). */ + const unsigned char *src = (const unsigned char *)cur.buf; + size_t srcn = cur.len; + if (srcn >= 2 && (src[0] & 0x0f) == 8 && + ((unsigned)src[0] * 256u + src[1]) % 31u == 0) { + if (src[1] & 0x20) { /* FDICT: a preset dictionary we do not have */ + cst_buf_free(&next); + cst_buf_free(&cur); + return -1; + } + src += 2; + srcn -= 2; + } + if (inflate_raw(src, srcn, &next, PDF_MAX_STREAM) != 0) { + cst_buf_free(&next); + cst_buf_free(&cur); + return -1; + } + if (parm && parmlen) { + const char *pv = NULL, *tmp = NULL; + size_t pvlen = 0, tmplen = 0; + int pred = 1, colors = 1, bpc = 8, columns = 1; + const char *pd = skip_ws(parm, parm + parmlen); + if (dict_get(pd, parm + parmlen, "Predictor", &pv, &pvlen) == 0) { + pred = atoi(pv); + if (dict_get(pd, parm + parmlen, "Colors", &tmp, &tmplen) == 0) + colors = atoi(tmp); + if (dict_get(pd, parm + parmlen, "BitsPerComponent", &tmp, + &tmplen) == 0) + bpc = atoi(tmp); + if (dict_get(pd, parm + parmlen, "Columns", &tmp, &tmplen) == 0) + columns = atoi(tmp); + } + if (pred > 1) { + if (unfilter(next.buf, next.len, pred, colors, bpc, columns) != 0) + pred = 1; + if (pred >= 10 && pred <= 15) { + size_t rb = ((size_t)(columns < 1 ? 1 : columns) * + (size_t)(colors < 1 ? 1 : colors) * + (size_t)(bpc < 1 ? 8 : bpc) + + 7) / 8; + size_t stride = rb + 1; + if (stride > 1 && next.len >= stride && next.len % stride) + next.len -= next.len % stride; + } + } + } + } else if (nlen == 13 && strncmp(name, "ASCIIHexDecode", 13) == 0) { + if (decode_ahx(cur.buf, cur.len, &next, PDF_MAX_STREAM) != 0) + next.len = 0; + } else if (nlen >= 13 && strncmp(name, "ASCII85Decode", 13) == 0) { + if (decode_a85(cur.buf, cur.len, &next, PDF_MAX_STREAM) != 0) + next.len = 0; + } else { + cst_buf_free(&next); + cst_buf_free(&cur); + return -2; + } + cst_buf_free(&cur); + cur = next; + if (filters == 1) + break; + } + *out = cur; + return 0; +} + +/* Object streams (PDF 1.5+): the contained objects are added to the table, + * their bodies pointing into the decoded buffer, which doc_t owns. */ +static void expand_objstm(doc_t *d) { + int n0 = d->nobjs; + for (int i = 0; i < n0; i++) { + struct obj o = d->objs[i]; /* by value: doc_add may move the table */ + if (o.n < 6) + continue; + const char *tv = NULL; + size_t tvlen = 0; + if (dict_get(skip_ws(o.p, o.p + o.n), o.p + o.n, "Type", &tv, &tvlen) != 0) + continue; + if (tvlen < 6 || strncmp(tv, "/ObjStm", 7) != 0) + continue; + cst_buf_t dec = {0}; + if (stream_decode(d, &o, &dec) != 0) { + cst_buf_free(&dec); + continue; + } + size_t len = dec.len; + char *buf = cst_buf_steal(&dec); /* steal resets dec.len */ + doc_own(d, buf); + /* /N and /First live in the dictionary: the stream itself is just the + * "num offset" pairs, with the bodies starting at /First. */ + const char *body = skip_ws(o.p, o.p + o.n); + const char *dv = NULL; + size_t dvlen = 0; + long count = 0, first = 0; + if (dict_get(body, o.p + o.n, "N", &dv, &dvlen) == 0) + count = strtol(skip_ws(dv, dv + dvlen), NULL, 10); + dv = NULL; + dvlen = 0; + if (dict_get(body, o.p + o.n, "First", &dv, &dvlen) == 0) + first = strtol(skip_ws(dv, dv + dvlen), NULL, 10); + if (count <= 0 || first < 0 || (size_t)first > len || + count > PDF_MAX_OBJECTS) + continue; + const char *hdr = skip_ws(buf, buf + len); + for (long k = 0; k < count; k++) { + long num = strtol(hdr, (char **)&hdr, 10); + long off = strtol(hdr, (char **)&hdr, 10); + if (num <= 0 || off < 0) + continue; + size_t start = (size_t)first + (size_t)off; + if (start >= len) + continue; + size_t next = len; + /* the next object's offset bounds this one */ + const char *probe = hdr; + long nnum = strtol(probe, (char **)&probe, 10); + long noff = strtol(probe, (char **)&probe, 10); + if (nnum > 0 && noff > off) + next = (size_t)first + (size_t)noff; + if (next > len) + next = len; + doc_add(d, num, buf + start, next - start); + } + } +} + +/* --- ToUnicode CMaps ----------------------------------------------------- */ + +typedef struct { + struct glyph { + unsigned code; + unsigned off; + unsigned len; + } *glyphs; + int n, cap; + char *bytes; + size_t blen, bcap; + int two_byte; +} cmap_t; + +static void cmap_add(cmap_t *m, unsigned code, const char *s, size_t n) { + if (m->n >= PDF_MAX_CMAP || !n || n > 8) + return; + if (m->n == m->cap) { + int cap = m->cap ? m->cap * 2 : 256; + struct glyph *g = realloc(m->glyphs, (size_t)cap * sizeof(*g)); + if (!g) + return; + m->glyphs = g; + m->cap = cap; + } + if (m->blen + n > m->bcap) { + size_t cap = m->bcap ? m->bcap * 2 : 1024; + while (cap < m->blen + n) + cap *= 2; + char *b = realloc(m->bytes, cap); + if (!b) + return; + m->bytes = b; + m->bcap = cap; + } + memcpy(m->bytes + m->blen, s, n); + m->glyphs[m->n].code = code; + m->glyphs[m->n].off = (unsigned)m->blen; + m->glyphs[m->n].len = (unsigned)n; + m->blen += n; + m->n++; +} + +static int glyph_cmp(const void *a, const void *b) { + const struct glyph *x = a, *y = b; + return x->code < y->code ? -1 : x->code > y->code ? 1 : 0; +} + +/* One token; returns the byte count or 0. */ +static size_t take_hex(const char **p, const char *end, unsigned char *out, + size_t cap) { + const char *q = skip_ws(*p, end); + if (q >= end || *q != '<') + return 0; + q++; + size_t k = 0; + int hi = -1; + while (q < end && *q != '>') { + int c = (unsigned char)*q++; + if (is_ws(c)) + continue; + int v = hex_digit(c); + if (v < 0) + break; + if (hi < 0) { + hi = v; + continue; + } + if (k < cap) + out[k++] = (unsigned char)(hi * 16 + v); + hi = -1; + } + if (q < end && *q == '>') + q++; + else if (q < end) + return 0; + *p = q; + return k; +} + +static size_t utf16be_to_utf8(const unsigned char *s, size_t n, + unsigned char *out, size_t cap) { + size_t k = 0; + for (size_t i = 0; i + 1 < n; i += 2) { + unsigned cp = ((unsigned)s[i] << 8) | s[i + 1]; + if (cp >= 0xD800 && cp <= 0xDBFF && i + 3 < n) { + unsigned lo = ((unsigned)s[i + 2] << 8) | s[i + 3]; + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + i += 2; + } + } + if (cp < 0x80) { + if (k + 1 > cap) + break; + out[k++] = (unsigned char)cp; + } else if (cp < 0x800) { + if (k + 2 > cap) + break; + out[k++] = (unsigned char)(0xC0 | (cp >> 6)); + out[k++] = (unsigned char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + if (k + 3 > cap) + break; + out[k++] = (unsigned char)(0xE0 | (cp >> 12)); + out[k++] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + out[k++] = (unsigned char)(0x80 | (cp & 0x3F)); + } else if (cp <= 0x10FFFF) { + if (k + 4 > cap) + break; + out[k++] = (unsigned char)(0xF0 | (cp >> 18)); + out[k++] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F)); + out[k++] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + out[k++] = (unsigned char)(0x80 | (cp & 0x3F)); + } + } + return k; +} + +static void parse_cmap(cmap_t *m, const char *s, size_t n) { + const char *end = s + n; + const char *p = s; + while (p < end) { + if ((size_t)(end - p) >= 11 && strncmp(p, "beginbfchar", 11) == 0) { + p += 11; + for (;;) { + unsigned char src[8], dst[16], utf8[32]; + p = skip_ws(p, end); + if (p >= end || strncmp(p, "endbfchar", 9) == 0) + break; + size_t sn = take_hex(&p, end, src, sizeof(src)); + if (!sn) + break; + size_t dn = take_hex(&p, end, dst, sizeof(dst)); + unsigned code = 0; + for (size_t i = 0; i < sn; i++) + code = (code << 8) | src[i]; + if (sn > 1) + m->two_byte = 1; + size_t un = dn ? utf16be_to_utf8(dst, dn, utf8, sizeof(utf8)) : 0; + if (un) + cmap_add(m, code, (const char *)utf8, un); + } + continue; + } + if ((size_t)(end - p) >= 12 && strncmp(p, "beginbfrange", 12) == 0) { + p += 12; + for (;;) { + unsigned char lo[8], hi[8], dst[16], utf8[32]; + p = skip_ws(p, end); + if (p >= end || strncmp(p, "endbfrange", 10) == 0) + break; + size_t ln = take_hex(&p, end, lo, sizeof(lo)); + if (!ln) + break; + size_t hn = take_hex(&p, end, hi, sizeof(hi)); + if (!hn) + break; + unsigned clo = 0, chi = 0; + for (size_t i = 0; i < ln; i++) + clo = (clo << 8) | lo[i]; + for (size_t i = 0; i < hn; i++) + chi = (chi << 8) | hi[i]; + if (ln > 1 || hn > 1) + m->two_byte = 1; + if (chi < clo) + continue; + p = skip_ws(p, end); + if (p < end && *p == '[') { /* one destination per code */ + p++; + unsigned code = clo; + for (;;) { + p = skip_ws(p, end); + if (p >= end || *p == ']') + break; + size_t dn = take_hex(&p, end, dst, sizeof(dst)); + if (!dn) + break; + size_t un = utf16be_to_utf8(dst, dn, utf8, sizeof(utf8)); + if (un) + cmap_add(m, code, (const char *)utf8, un); + code++; + } + if (p < end && *p == ']') + p++; + continue; + } + size_t dn = take_hex(&p, end, dst, sizeof(dst)); + if (!dn) + break; + size_t un = utf16be_to_utf8(dst, dn, utf8, sizeof(utf8)); + if (!un) + continue; + if (un == 1) { /* sequential code points */ + for (unsigned code = clo; code <= chi && code - clo < 65536; code++) { + unsigned char one = (unsigned char)(utf8[0] + (code - clo)); + if (utf8[0] + (code - clo) > 0x7f) + break; + cmap_add(m, code, (const char *)&one, 1); + } + } else { + for (unsigned code = clo; code <= chi && code - clo < 65536; code++) + cmap_add(m, code, (const char *)utf8, un); + } + } + continue; + } + p++; + } + if (m->n) + qsort(m->glyphs, (size_t)m->n, sizeof(*m->glyphs), glyph_cmp); +} + +static const char *cmap_lookup(const cmap_t *m, unsigned code, size_t *n) { + int lo = 0, hi = m->n - 1; + while (lo <= hi) { + int mid = (lo + hi) / 2; + if (m->glyphs[mid].code == code) { + *n = m->glyphs[mid].len; + return m->bytes + m->glyphs[mid].off; + } + if (m->glyphs[mid].code < code) + lo = mid + 1; + else + hi = mid - 1; + } + return NULL; +} + +/* --- fonts --------------------------------------------------------------- */ + +typedef struct { + char name[64]; + cmap_t map; + int used; +} font_t; + +typedef struct { + doc_t *d; + cst_buf_t out; + font_t fonts[32]; + int nfonts; + int cur; /* index into fonts, -1 = default encoding */ + int pages; + int saw_text; + double y; /* current baseline, for line breaks */ + int in_text; + int pending_space; +} text_t; + +static font_t *font_find(text_t *t, const char *name, size_t n, int create) { + for (int i = 0; i < t->nfonts; i++) + if (strlen(t->fonts[i].name) == n && + strncmp(t->fonts[i].name, name, n) == 0) + return &t->fonts[i]; + if (!create || t->nfonts >= 32 || n == 0 || n >= sizeof(t->fonts[0].name)) + return NULL; + font_t *f = &t->fonts[t->nfonts++]; + memset(f, 0, sizeof(*f)); + memcpy(f->name, name, n); + return f; +} + +/* Text of one string operand, decoded through the current font's CMap. */ +static void emit_bytes(text_t *t, const unsigned char *s, size_t n) { + if (t->out.len >= CST_PDF_TEXT_MAX) + return; + font_t *f = t->cur >= 0 ? &t->fonts[t->cur] : NULL; + for (size_t i = 0; i < n;) { + unsigned code; + size_t step; + if (f && f->map.n > 0 && f->map.two_byte) { + if (i + 1 >= n) + break; + code = ((unsigned)s[i] << 8) | s[i + 1]; + step = 2; + } else { + code = s[i]; + step = 1; + } + size_t gl = 0; + const char *g = (f && f->map.n) ? cmap_lookup(&f->map, code, &gl) : NULL; + if (g) { + if (t->pending_space && t->out.len) { + cst_buf_puts(&t->out, " "); + t->pending_space = 0; + } + cst_buf_append(&t->out, g, gl); + t->saw_text = 1; + } else if (code >= 32 && code != 127 && code < 256) { + /* no CMap entry: plain Latin-1 / WinAnsi */ + unsigned char utf8[4]; + size_t un; + if (code < 128) { + utf8[0] = (unsigned char)code; + un = 1; + } else { + utf8[0] = (unsigned char)(0xC0 | (code >> 6)); + utf8[1] = (unsigned char)(0x80 | (code & 0x3F)); + un = 2; + } + if (t->pending_space && t->out.len) { + cst_buf_puts(&t->out, " "); + t->pending_space = 0; + } + cst_buf_append(&t->out, (const char *)utf8, un); + t->saw_text = 1; + } else if (code == 32) { + t->pending_space = 1; + } + i += step; + } +} + +static void emit_string_operand(text_t *t, const char *s, size_t n) { + emit_bytes(t, (const unsigned char *)s, n); +} + +static void newline(text_t *t) { + if (!t->out.len) + return; + while (t->out.len && (t->out.buf[t->out.len - 1] == ' ' || + t->out.buf[t->out.len - 1] == '\n')) + t->out.len--; + cst_buf_puts(&t->out, "\n"); + t->pending_space = 0; +} + +/* --- content streams ----------------------------------------------------- */ + +typedef struct { + const char *p, *end; + /* operand stack */ + struct operand { + int kind; /* 0 none, 1 number, 2 string, 3 name, 4 array */ + double num; + char name[64]; + unsigned char str[PDF_MAX_RUN]; + size_t slen; + } ops[8]; + int nops; + /* the array being read, for TJ */ + struct item { + int is_str; + double num; + unsigned char s[256]; + size_t n; + } arr[PDF_MAX_ARR]; + int narr; + int in_array; +} lexer_t; + +static void lexer_push(lexer_t *lx, struct operand *op) { + if (lx->nops >= 8) { + memmove(&lx->ops[0], &lx->ops[1], sizeof(lx->ops[0]) * 7); + lx->nops = 7; + } + lx->ops[lx->nops++] = *op; +} + +static int lex_token(lexer_t *lx, char *op_out, size_t op_sz) { + const char *p = skip_ws(lx->p, lx->end); + if (p >= lx->end) { + lx->p = lx->end; + return 0; + } + lx->p = p; + struct operand op; + memset(&op, 0, sizeof(op)); + int c = (unsigned char)*p; + if (c == '(') { /* literal string */ + const char *q = p + 1; + int depth = 1; + size_t k = 0; + while (q < lx->end && depth) { + char ch = *q++; + if (ch == '\\') { + if (q >= lx->end) + break; + char e = *q++; + switch (e) { + case 'n': + ch = '\n'; + break; + case 'r': + ch = '\r'; + break; + case 't': + ch = '\t'; + break; + case 'b': + ch = '\b'; + break; + case 'f': + ch = '\f'; + break; + case '\n': + continue; /* line continuation */ + default: + if (e >= '0' && e <= '7') { + int v = e - '0', digits = 1; + while (digits < 3 && q < lx->end && *q >= '0' && *q <= '7') { + v = v * 8 + (*q++ - '0'); + digits++; + } + ch = (char)v; + } else { + ch = e; + } + } + } else if (ch == '(') { + depth++; + } else if (ch == ')') { + depth--; + if (!depth) + break; + } else if (ch == '\r') { /* a raw CR or CRLF becomes an LF */ + if (q < lx->end && *q == '\n') + q++; + ch = '\n'; + } + if (k < sizeof(op.str)) + op.str[k++] = (unsigned char)ch; + } + op.kind = 2; + op.slen = k; + lx->p = q; + lexer_push(lx, &op); + return 1; + } + if (c == '<' && p + 1 < lx->end && p[1] != '<') { /* hex string */ + const char *q = p + 1; + size_t k = 0; + int hi = -1; + while (q < lx->end && *q != '>') { + int ch = (unsigned char)*q++; + if (is_ws(ch)) + continue; + int v = hex_digit(ch); + if (v < 0) + break; + if (hi < 0) { + hi = v; + continue; + } + if (k < sizeof(op.str)) + op.str[k++] = (unsigned char)(hi * 16 + v); + hi = -1; + } + if (q < lx->end && *q == '>') + q++; + if (hi >= 0 && k < sizeof(op.str)) + op.str[k++] = (unsigned char)(hi * 16); + op.kind = 2; + op.slen = k; + lx->p = q; + lexer_push(lx, &op); + return 1; + } + if (c == '[') { + lx->in_array = 1; + lx->narr = 0; + lx->p = p + 1; + return 1; + } + if (c == ']') { + op.kind = 4; + lx->p = p + 1; + lexer_push(lx, &op); + return 1; + } + if (c == '/' ) { + size_t l = name_len(p, lx->end); + op.kind = 3; + snprintf(op.name, sizeof(op.name), "%.*s", (int)(l < 63 ? l : 63), p + 1); + lx->p = p + 1 + l; + lexer_push(lx, &op); + return 1; + } + if (c == '<' && p + 1 < lx->end && p[1] == '<') { /* skip a dict */ + const char *e = skip_value(p, lx->end); + lx->p = e > p ? e : p + 1; + return 1; + } + if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.') { + char *tail = NULL; + double v = strtod(p, &tail); + if (tail && tail > p) { + op.kind = 1; + op.num = v; + lx->p = tail; + if (lx->in_array) { + if (lx->narr < PDF_MAX_ARR) { + lx->arr[lx->narr].is_str = 0; + lx->arr[lx->narr].num = v; + lx->narr++; + } + return 1; + } + lexer_push(lx, &op); + return 1; + } + } + /* operator / keyword */ + const char *q = p; + while (q < lx->end && !is_ws((unsigned char)*q) && !strchr("<>[]()/{}/%", *q)) + q++; + if (q == p) { + lx->p = p + 1; + return 1; + } + size_t l = (size_t)(q - p); + snprintf(op_out, op_sz, "%.*s", (int)(l < op_sz - 1 ? l : op_sz - 1), p); + lx->p = q; + return 2; +} + +static const struct operand *last_op(lexer_t *lx, int n_back) { + if (n_back < 0 || n_back >= lx->nops) + return NULL; + return &lx->ops[lx->nops - 1 - n_back]; +} + +static void run_content(text_t *t, const char *s, size_t n) { + lexer_t lx; + memset(&lx, 0, sizeof(lx)); + lx.p = s; + lx.end = s + n; + char op[32]; + for (;;) { + op[0] = 0; + int kind = lex_token(&lx, op, sizeof(op)); + if (!kind) + break; + if (!op[0]) { + /* an operand: a string inside a TJ array is collected there */ + if (lx.in_array && lx.nops > 0) { + struct operand *o = &lx.ops[lx.nops - 1]; + if (o->kind == 2 && lx.narr < PDF_MAX_ARR) { + lx.arr[lx.narr].is_str = 1; + lx.arr[lx.narr].n = o->slen < 255 ? o->slen : 255; + memcpy(lx.arr[lx.narr].s, o->str, lx.arr[lx.narr].n); + lx.narr++; + lx.nops--; + } + } + continue; + } + if (strcmp(op, "BI") == 0) { + /* an inline image: the bytes after ID are not text, so jump to the EI + * that closes it (it must sit between delimiters) */ + const char *q = lx.p; + while (q + 2 <= lx.end) { + if (q[0] == 'E' && q[1] == 'I' && + (q == lx.p || is_ws((unsigned char)q[-1])) && + (q + 2 == lx.end || is_ws((unsigned char)q[2]) || + strchr("<>[]()/%", q[2]))) + break; + q++; + } + lx.p = q + 2 <= lx.end ? q + 2 : lx.end; + lx.nops = 0; + lx.narr = 0; + t->pending_space = 0; + } else if (strcmp(op, "BT") == 0) { + t->in_text = 1; + t->y = 0; + } else if (strcmp(op, "ET") == 0) { + if (t->in_text) + newline(t); + t->in_text = 0; + lx.nops = 0; + } else if (strcmp(op, "Tf") == 0) { + const struct operand *nm = last_op(&lx, 1); + if (nm && nm->kind == 3) { + font_t *f = font_find(t, nm->name, strlen(nm->name), 1); + t->cur = f ? (int)(f - t->fonts) : t->cur; + } + lx.nops = 0; + } else if (strcmp(op, "Tj") == 0) { + const struct operand *o = last_op(&lx, 0); + if (t->in_text && o && o->kind == 2) + emit_string_operand(t, (const char *)o->str, o->slen); + lx.nops = 0; + } else if (strcmp(op, "TJ") == 0) { + if (t->in_text) { + for (int i = 0; i < lx.narr; i++) { + if (lx.arr[i].is_str) { + emit_bytes(t, lx.arr[i].s, lx.arr[i].n); + } else if (lx.arr[i].num <= -180) { + t->pending_space = 1; /* a kerning gap wide enough to be a space */ + } + } + } + lx.narr = 0; + lx.in_array = 0; + lx.nops = 0; + } else if (strcmp(op, "'") == 0 || strcmp(op, "\"") == 0) { + const struct operand *o = last_op(&lx, 0); + if (t->in_text) { + newline(t); + if (o && o->kind == 2) + emit_string_operand(t, (const char *)o->str, o->slen); + } + lx.nops = 0; + } else if (strcmp(op, "Td") == 0 || strcmp(op, "TD") == 0) { + const struct operand *ty = last_op(&lx, 0); + if (t->in_text) { + double dy = ty && ty->kind == 1 ? ty->num : 0; + if (dy < -0.5 || dy > 0.5) { + newline(t); + } else { + const struct operand *tx = last_op(&lx, 1); + if (tx && tx->kind == 1 && tx->num > 3) + t->pending_space = 1; + } + t->y = t->y + dy; + } + lx.nops = 0; + } else if (strcmp(op, "T*") == 0) { + if (t->in_text) + newline(t); + lx.nops = 0; + } else if (strcmp(op, "Tm") == 0) { + const struct operand *fy = last_op(&lx, 0); + const struct operand *fx = last_op(&lx, 1); + if (t->in_text && fy && fy->kind == 1) { + if (fy->num + 0.5 < t->y || fy->num - 0.5 > t->y) + newline(t); + else if (fx && fx->kind == 1 && fx->num > 3) + t->pending_space = 1; + t->y = fy->num; + } + lx.nops = 0; + } else { + lx.nops = 0; + } + } +} + +/* --- pages --------------------------------------------------------------- */ + +static void page_fonts(text_t *t, const char *res, size_t rlen) { + const char *fv = NULL, *fonts = NULL; + size_t fvlen = 0, flen = 0; + if (!res || dict_get(res, res + rlen, "Font", &fv, &fvlen) != 0) + return; + const struct obj *fo = value_obj(t->d, fv, fvlen); + if (!fo) { + fonts = fv; + flen = fvlen; + } else { + fonts = fo->p; + flen = fo->n; + } + const char *p = skip_ws(fonts, fonts + flen); + if (!(p + 1 < fonts + flen && p[0] == '<' && p[1] == '<')) + return; + p = skip_ws(p + 2, fonts + flen); + while (p < fonts + flen && !(p[0] == '>' && p[1] == '>')) { + if (*p != '/') { + const char *e = skip_value(p, fonts + flen); + p = skip_ws(e > p ? e : p + 1, fonts + flen); + continue; + } + size_t nl = name_len(p, fonts + flen); + const char *val = skip_ws(p + 1 + nl, fonts + flen); + const char *ve = skip_value(val, fonts + flen); + font_t *f = font_find(t, p + 1, nl, 1); + const struct obj *fo2 = value_obj(t->d, val, (size_t)(ve - val)); + if (f && fo2) { + /* subtype Type0 means 2-byte codes even without a CMap */ + const char *sv = NULL, *tv = NULL; + size_t svlen = 0, tvlen = 0; + if (dict_get(skip_ws(fo2->p, fo2->p + fo2->n), fo2->p + fo2->n, + "Subtype", &sv, &svlen) == 0 && + svlen >= 5 && strncmp(sv, "/Type0", 6) == 0) + f->map.two_byte = 1; + if (dict_get(skip_ws(fo2->p, fo2->p + fo2->n), fo2->p + fo2->n, + "ToUnicode", &tv, &tvlen) == 0) { + const struct obj *tu = value_obj(t->d, tv, tvlen); + cst_buf_t dec = {0}; + if (tu && stream_decode(t->d, tu, &dec) == 0) { + parse_cmap(&f->map, dec.buf, dec.len); + cst_buf_free(&dec); + } else { + cst_buf_free(&dec); + } + } + } + p = skip_ws(ve, fonts + flen); + } +} + +static void page_contents(text_t *t, const char *cv, size_t cvlen) { + const char *p = skip_ws(cv, cv + cvlen); + if (p < cv + cvlen && *p == '[') { /* an array of streams */ + p++; + for (;;) { + p = skip_ws(p, cv + cvlen); + if (p >= cv + cvlen || *p == ']') + break; + const char *e = skip_value(p, cv + cvlen); + const struct obj *o = value_obj(t->d, p, (size_t)(e - p)); + if (o) { + cst_buf_t dec = {0}; + if (stream_decode(t->d, o, &dec) == 0) { + run_content(t, dec.buf, dec.len); + cst_buf_free(&dec); + } + } + p = skip_ws(e, cv + cvlen); + } + return; + } + const struct obj *o = value_obj(t->d, cv, cvlen); + if (o) { + cst_buf_t dec = {0}; + if (stream_decode(t->d, o, &dec) == 0) { + run_content(t, dec.buf, dec.len); + cst_buf_free(&dec); + } + } +} + +static void walk_pages(text_t *t, long num, const char *inherited_res, + size_t inherited_len, int *visited, int nvisited, + int depth) { + if (depth > 32 || t->pages >= PDF_MAX_PAGES) + return; + for (int i = 0; i < nvisited; i++) + if (visited[i] == num) + return; + if (nvisited < PDF_MAX_KIDS) + visited[nvisited++] = (int)num; + const struct obj *o = obj_find(t->d, num); + if (!o) + return; + const char *body = skip_ws(o->p, o->p + o->n); + size_t blen = o->n - (size_t)(body - o->p); + const char *res = inherited_res; + size_t rlen = inherited_len; + const char *rv = NULL; + size_t rvlen = 0; + if (dict_get(body, body + blen, "Resources", &rv, &rvlen) == 0) { + const struct obj *ro = value_obj(t->d, rv, rvlen); + if (ro) { + res = skip_ws(ro->p, ro->p + ro->n); + rlen = ro->n - (size_t)(res - ro->p); + } else { + res = rv; + rlen = rvlen; + } + } + const char *kidsv = NULL, *typev = NULL, *cv = NULL; + size_t kidslen = 0, typelen = 0, cvlen = 0; + int is_pages = dict_get(body, body + blen, "Type", &typev, &typelen) == 0 && + typelen >= 6 && strncmp(typev, "/Pages", 6) == 0; + int has_kids = dict_get(body, body + blen, "Kids", &kidsv, &kidslen) == 0; + if (dict_get(body, body + blen, "Contents", &cv, &cvlen) == 0) { + t->pages++; + if (res && rlen) + page_fonts(t, res, rlen); + if (t->out.len) + cst_buf_puts(&t->out, "\n\n"); + page_contents(t, cv, cvlen); + t->in_text = 0; + t->cur = -1; + for (int i = 0; i < t->nfonts; i++) { /* fonts are per page */ + free(t->fonts[i].map.glyphs); + free(t->fonts[i].map.bytes); + } + t->nfonts = 0; + return; + } + if (!has_kids || (!is_pages && !kidsv)) + return; + const char *p = skip_ws(kidsv, kidsv + kidslen); + if (p >= kidsv + kidslen || *p != '[') + return; + p++; + for (;;) { + p = skip_ws(p, kidsv + kidslen); + if (p >= kidsv + kidslen || *p == ']') + break; + const char *e = skip_value(p, kidsv + kidslen); + long kid = 0; + if (read_ref(&p, e, &kid) == 0) + walk_pages(t, kid, res, rlen, visited, nvisited, depth + 1); + p = skip_ws(e, kidsv + kidslen); + } +} + +/* --- entry point --------------------------------------------------------- */ + +static int is_encrypted(doc_t *d) { + for (int i = 0; i < d->nobjs; i++) { + const struct obj *o = &d->objs[i]; + const char *tv = NULL; + size_t tvlen = 0; + const char *body = skip_ws(o->p, o->p + o->n); + if (dict_get(body, o->p + o->n, "Encrypt", &tv, &tvlen) == 0) + return 1; + if (dict_get(body, o->p + o->n, "Type", &tv, &tvlen) == 0 && tvlen >= 5 && + strncmp(tv, "/XRef", 5) == 0) { + const char *ev = NULL; + size_t evlen = 0; + if (dict_get(body, o->p + o->n, "Encrypt", &ev, &evlen) == 0) + return 1; + } + } + /* classic files keep /Encrypt in the (object-less) trailer dictionary */ + const char *s = (const char *)d->file, *end = s + d->flen; + for (const char *p = s; p + 7 <= end; p++) { + if (strncmp(p, "trailer", 7) != 0) + continue; + const char *o2 = NULL; + size_t o2len = 0; + const char *ev = NULL; + size_t evlen = 0; + const char *q = skip_ws(p + 7, end); + if (dict_get(q, end, "Root", &o2, &o2len) == 0 && + dict_get(q, end, "Encrypt", &ev, &evlen) == 0) + return 1; + } + return 0; +} + +char *cst_pdf_text(const void *data, size_t n, int *pages_out, char *err, + size_t err_sz) { + if (pages_out) + *pages_out = 0; + if (!data || n < 8 || memcmp(data, "%PDF-", 5) != 0) { + set_err(err, err_sz, "not a PDF (no %PDF- header)"); + return NULL; + } + doc_t d; + memset(&d, 0, sizeof(d)); + d.file = data; + d.flen = n; + scan_objects(&d); + if (is_encrypted(&d)) { + set_err(err, err_sz, + "the PDF is encrypted; remove its password/owner restrictions " + "first"); + free(d.objs); + free(d.owned); + return NULL; + } + if (!d.nobjs) { + set_err(err, err_sz, "no PDF objects found"); + free(d.objs); + return NULL; + } + expand_objstm(&d); + text_t t; + memset(&t, 0, sizeof(t)); + t.d = &d; + t.cur = -1; + /* root -> pages */ + long root = 0; + for (int i = 0; i < d.nobjs && !root; i++) { + const struct obj *o = &d.objs[i]; + const char *tv = NULL; + size_t tvlen = 0; + const char *body = skip_ws(o->p, o->p + o->n); + if (dict_get(body, o->p + o->n, "Type", &tv, &tvlen) == 0 && tvlen >= 8 && + strncmp(tv, "/Catalog", 8) == 0) { + const char *pv = NULL; + size_t pvlen = 0; + if (dict_get(body, o->p + o->n, "Pages", &pv, &pvlen) == 0) { + long pnum = 0; + if (read_ref(&pv, pv + pvlen, &pnum) == 0) + root = pnum; + } + } + } + int *visited = calloc(PDF_MAX_KIDS, sizeof(*visited)); + if (visited && root) + walk_pages(&t, root, NULL, 0, visited, 0, 0); + if (visited && t.pages == 0) { + /* no usable page tree: take every /Type /Page object in file order */ + for (int i = 0; i < d.nobjs && t.pages < PDF_MAX_PAGES; i++) { + const struct obj *o = &d.objs[i]; + const char *tv = NULL, *cv = NULL; + size_t tvlen = 0, cvlen = 0; + const char *body = skip_ws(o->p, o->p + o->n); + if (dict_get(body, o->p + o->n, "Type", &tv, &tvlen) != 0 || + tvlen < 5 || strncmp(tv, "/Page", 5) != 0) + continue; + if (dict_get(body, o->p + o->n, "Contents", &cv, &cvlen) != 0) + continue; + t.pages++; + if (t.out.len) + cst_buf_puts(&t.out, "\n\n"); + const char *rv = NULL; + size_t rvlen = 0; + if (dict_get(body, o->p + o->n, "Resources", &rv, &rvlen) == 0) { + const struct obj *ro = value_obj(&d, rv, rvlen); + if (ro) + page_fonts(&t, ro->p, ro->n); + else + page_fonts(&t, rv, rvlen); + } + page_contents(&t, cv, cvlen); + t.in_text = 0; + t.cur = -1; + for (int k = 0; k < t.nfonts; k++) { + free(t.fonts[k].map.glyphs); + free(t.fonts[k].map.bytes); + } + t.nfonts = 0; + } + } + free(visited); + for (int i = 0; i < t.nfonts; i++) { + free(t.fonts[i].map.glyphs); + free(t.fonts[i].map.bytes); + } + if (pages_out) + *pages_out = t.pages; + free(d.objs); + for (int i = 0; i < d.nowned; i++) + free(d.owned[i]); + free(d.owned); + if (!t.saw_text || !t.out.len) { + cst_buf_free(&t.out); + set_err(err, err_sz, + t.pages ? "the PDF has no extractable text (the pages are " + "probably scanned images; OCR the file first)" + : "no readable pages or text in the PDF"); + return NULL; + } + size_t len = 0; + char *out = cst_utf8_sanitize(t.out.buf ? t.out.buf : "", t.out.len, &len); + cst_buf_free(&t.out); + if (!out) { + set_err(err, err_sz, "out of memory"); + return NULL; + } + /* collapse runs of blank lines the operators leave behind */ + char *w = out; + int nl = 0; + for (char *r = out; *r;) { + if (*r == '\n') { + if (++nl <= 2) + *w++ = *r; + r++; + continue; + } + nl = 0; + *w++ = *r++; + } + *w = 0; + return out; +} diff --git a/src/session.c b/src/session.c index b52fa77..146190c 100644 --- a/src/session.c +++ b/src/session.c @@ -452,7 +452,10 @@ char *cst_session_export_md(cst_session_t *s, const char *title, for (int k = 0; k < m->nattach; k++) { char d[128]; cst_attach_describe(&m->attach[k], d, sizeof(d)); - cst_buf_printf(&b, "- image `%s` (%s)\n", m->attach[k].name, d); + cst_buf_printf(&b, "- %s `%s` (%s)\n", + cst_attach_is_image(&m->attach[k]) ? "image" + : "document", + m->attach[k].name, d); } if (m->nattach) cst_buf_puts(&b, "\n"); diff --git a/src/settings.c b/src/settings.c index fa49efb..f198630 100644 --- a/src/settings.c +++ b/src/settings.c @@ -47,6 +47,8 @@ int cst_settings_load(cst_settings_t *s) { cJSON_GetObjectItemCaseSensitive(root, "provider")); copy_str(s->effort, sizeof(s->effort), cJSON_GetObjectItemCaseSensitive(root, "effort")); + copy_str(s->mode, sizeof(s->mode), + cJSON_GetObjectItemCaseSensitive(root, "mode")); copy_str(s->approval, sizeof(s->approval), cJSON_GetObjectItemCaseSensitive(root, "approval")); s->show_thinking = @@ -86,6 +88,8 @@ int cst_settings_save(const cst_settings_t *s) { cJSON_AddStringToObject(root, "provider", s->provider); if (s->effort[0]) cJSON_AddStringToObject(root, "effort", s->effort); + if (s->mode[0]) + cJSON_AddStringToObject(root, "mode", s->mode); if (s->approval[0]) cJSON_AddStringToObject(root, "approval", s->approval); if (s->show_thinking) @@ -135,6 +139,21 @@ int cst_approval_parse(const char *s, int *out) { return -1; } +static const char *const MODE_NAMES[] = {"coding", "general"}; + +int cst_mode_valid(const char *m) { + for (size_t i = 0; m && m[0] && i < sizeof(MODE_NAMES) / sizeof(*MODE_NAMES); + i++) { + if (strcmp(m, MODE_NAMES[i]) == 0) + return 1; + } + return 0; +} + +const char *cst_mode_name(const char *m) { + return cst_mode_valid(m) ? m : CST_DEFAULT_MODE; +} + const char *cst_settings_model(const cst_settings_t *s, const char *provider) { for (int i = 0; provider && i < s->nmodels; i++) { if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0]) @@ -148,6 +167,8 @@ int cst_settings_remember(const cst_agent_config_t *cfg) { cst_settings_load(&s); snprintf(s.provider, sizeof(s.provider), "%s", cfg->provider); snprintf(s.effort, sizeof(s.effort), "%s", cfg->effort); + if (cfg->mode[0]) + snprintf(s.mode, sizeof(s.mode), "%s", cfg->mode); int i = 0; while (i < s.nmodels && strcmp(s.models[i].provider, cfg->provider) != 0) i++; @@ -176,6 +197,13 @@ int cst_settings_remember_thinking(int show_thinking) { return cst_settings_save(&s); } +int cst_settings_remember_mode(const char *mode) { + cst_settings_t s; + cst_settings_load(&s); + snprintf(s.mode, sizeof(s.mode), "%s", cst_mode_name(mode)); + return cst_settings_save(&s); +} + static void real_ws(const char *ws, char *out, size_t n) { if (!ws || os_realpath(ws, out, n) != 0) snprintf(out, n, "%s", ws ? ws : ""); diff --git a/src/subagent.c b/src/subagent.c index 32c489e..282c838 100644 --- a/src/subagent.c +++ b/src/subagent.c @@ -40,7 +40,7 @@ static const struct { "runs commands as needed.", "", 0}, {"explore", - "Fast read-only explorer: searches and reads the codebase to answer a " + "Fast read-only explorer: searches and reads the workspace to answer a " "question (where is X, how does Y work, which files do Z). Several can " "run at once.", "You only read. Search broadly first (grep, glob, list_dir), then read " @@ -49,11 +49,11 @@ static const struct { "you looked for and did not find.", 1}, {"plan", - "Read-only planner: investigates the code involved and returns a " + "Read-only planner: investigates the material involved and returns a " "step-by-step implementation plan.", - "You only read. Investigate the code involved, then return a concrete " - "plan: the files to change and what to change in each, risks and edge " - "cases, and how to verify. Sketch code only where it removes ambiguity.", + "You only read. Investigate what is involved, then return a concrete " + "plan: what to produce or change and where, risks and edge cases, and " + "how to check the result. Sketch code only where it removes ambiguity.", 1}, }; #define NBUILTIN ((int)(sizeof(BUILTIN) / sizeof(BUILTIN[0]))) diff --git a/src/tools/agent_tools.c b/src/tools/agent_tools.c index 1ce159a..6539a8e 100644 --- a/src/tools/agent_tools.c +++ b/src/tools/agent_tools.c @@ -7,6 +7,7 @@ #include #include "cst_tools.h" +#include "cst_clipboard.h" #include "cst_util.h" #include "tools_internal.h" @@ -106,3 +107,70 @@ int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, 1); return tool_set_resultf(r, 0, "user answered: %s", ans); } + +/* The system clipboard: what the user copied elsewhere. Reading needs no + * permission (READ); writing replaces what the user has copied (WRITE, so + * the confirmation frontier applies). */ +int cst_tool_clipboard_read(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + (void)ctx; + char err[192]; + char *text = NULL; + size_t n = 0; + if (cst_clipboard_text(&text, &n, err, sizeof(err)) != 0) + return tool_set_result(r, err, 1); + long cap = tool_arg_long(c, "max_bytes", 65536); + if (cap > 0 && (size_t)cap < n) + n = (size_t)cap; + cst_buf_t b = {0}; + cst_buf_printf(&b, "clipboard text (%zu bytes%s):\n", (size_t)n, + (size_t)cap < (size_t)n ? ", truncated" : ""); + cst_buf_append(&b, text, n); + if (b.len && b.buf[b.len - 1] != '\n') + cst_buf_puts(&b, "\n"); + free(text); + return tool_take_buf(r, &b, 0); +} + +int cst_tool_clipboard_write(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, cst_tool_result_t *r) { + (void)ctx; + size_t n = 0; + const char *text = tool_arg_n(c, "text", &n); + if (!text) + return tool_set_result(r, "error: missing text", 1); + char err[192]; + if (cst_clipboard_set_text(text, n, err, sizeof(err)) != 0) + return tool_set_result(r, err, 1); + return tool_set_resultf(r, 0, "copied %zu bytes to the clipboard", n); +} + +/* Confirmation preview: what the clipboard would hold afterwards. Clipboard + * text has no diff, so this is the text itself, cut to a readable size. */ +#define CLIP_PREVIEW_MAX 2000 +int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, char **out) { + (void)ctx; + size_t n = 0; + const char *text = tool_arg_n(c, "text", &n); + if (!text) { + *out = strdup("error: missing text"); + return -1; + } + int lines = 0; + for (size_t i = 0; i < n; i++) + lines += text[i] == '\n'; + size_t cut = n; + if (cut > CLIP_PREVIEW_MAX) + cut = cst_utf8_floor(text, CLIP_PREVIEW_MAX); + cst_buf_t b = {0}; + cst_buf_printf(&b, "clipboard: replace with %zu bytes, %d line%s\n", n, + lines + (n && text[n - 1] != '\n'), lines == 0 ? "" : "s"); + cst_buf_append(&b, text, cut); + if (b.len && b.buf[b.len - 1] != '\n') + cst_buf_puts(&b, "\n"); + if (cut < n) + cst_buf_printf(&b, "… (%zu more bytes)\n", n - cut); + *out = cst_buf_steal(&b); + return 0; +} diff --git a/src/tools/fs_tools.c b/src/tools/fs_tools.c index 5ab7dab..3641838 100644 --- a/src/tools/fs_tools.c +++ b/src/tools/fs_tools.c @@ -267,6 +267,14 @@ int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err)); if (!a) return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err); + if (!cst_attach_is_image(a)) { + cst_attach_free(a); + return tool_set_resultf( + r, 1, + "error: %.200s is not an image; a text file or PDF reaches the model " + "through /attach (a PDF becomes its text layer)", + path); + } snprintf(a->origin, sizeof(a->origin), "view_image"); const char *d = tool_arg(c, "detail"); if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0)) diff --git a/src/tools/registry.c b/src/tools/registry.c index c008aa6..535af30 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -66,8 +66,9 @@ static const cst_tool_def_t TOOLS[] = { {"detail", S, 0, "low or high (provider hint; default auto)"}}, CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN}, {"run_shell", - "Run a non-interactive shell command in the workspace root for builds, " - "tests, git and other CLIs (stdin is empty; long runs are killed). " + "Run a non-interactive shell command in the workspace root: builds, " + "tests, git, scripts, computations, data processing, other CLIs " + "(stdin is empty; long runs are killed). " "Returns stdout+stderr with colors stripped and long output cut to head " "+ tail. Prefer read_file/grep/glob/list_dir over cat/grep/find.", "Run a shell command in the workspace root (non-interactive, stdin is " @@ -158,6 +159,19 @@ static const cst_tool_def_t TOOLS[] = { {{"question", S, 1, "the question"}, {"options", S, 0, "suggested answers, one per line"}}, CST_TP_ASK, 0, cst_tool_ask_user, NULL, NO_DYN}, + {"clipboard_read", + "Read the text on the system clipboard — what the user copied outside " + "the workspace. Returns UTF-8 text.", + NULL, + {{"max_bytes", I, 0, "cap on the returned text (default 65536)"}}, + CST_TP_READ, 0, cst_tool_clipboard_read, NULL, NO_DYN}, + {"clipboard_write", + "Replace the system clipboard with text, so the user can paste it into " + "another app. Overwrites what they had copied.", + NULL, + {{"text", S, 1, "text to put on the clipboard"}}, + CST_TP_WRITE, 0, cst_tool_clipboard_write, cst_preview_clipboard_write, + NO_DYN}, }; #define NTOOLS ((int)(sizeof(TOOLS) / sizeof(TOOLS[0]))) diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index d7b0f9b..28fc962 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -46,6 +46,13 @@ int cst_tool_todo_write(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); +/* Clipboard text (system clipboard, not the workspace). */ +int cst_tool_clipboard_read(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); +int cst_tool_clipboard_write(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, cst_tool_result_t *r); +int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, char **out); int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, diff --git a/src/tui.c b/src/tui.c index 3aeb412..890edec 100644 --- a/src/tui.c +++ b/src/tui.c @@ -2354,6 +2354,7 @@ static const char *const TIPS[] = { "Ctrl+P opens the command palette", "Type @ to mention a file in your message", "/effort sets reasoning effort: low, medium, high, max", + "/mode general reframes the agent for non-code work", "/model lists the models your API key can use", "/undo reverts the file changes of the last turn", "/provider switches provider; keys are saved per provider", @@ -2832,7 +2833,10 @@ static void reload_hist_from_session(tui_t *t) { 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); + cst_buf_printf(&ub, "\n[%s %s · %s]", + cst_attach_is_image(&m->attach[k]) ? "image" + : "document", + m->attach[k].name, d); } block_set_body(b, ub.buf ? ub.buf : "", ub.len); cst_buf_free(&ub); @@ -4361,6 +4365,41 @@ static void cmd_effort(tui_t *t, char *arg, int *quit) { cfg->effort, cfg->provider, cfg->model); } +static void cmd_mode(tui_t *t, char *arg, int *quit) { + (void)quit; + cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); + static const char *const names[] = {"coding", "general"}; + static const char *const desc[] = { + "code framed: repo, build, tests (default)", + "documents, data, research, everyday files"}; + const char *want = NULL; + if (arg && arg[0]) { + if (!cst_mode_valid(arg)) { + push_system_line(t, "Unknown mode '%s' (coding | general)", arg); + return; + } + want = arg; + } else { + char items[2][128]; + int cur_idx = 0; + for (int i = 0; i < 2; i++) { + snprintf(items[i], 128, "%-8s %s", names[i], desc[i]); + if (strcmp(names[i], cst_mode_name(cfg->mode)) == 0) + cur_idx = i; + } + int pick = picker_run(t, "Working mode", items, 2, cur_idx); + if (pick < 0) + return; + want = names[pick]; + } + snprintf(cfg->mode, sizeof(cfg->mode), "%s", want); + cst_settings_remember_mode(cfg->mode); + push_system_line(t, "(mode → %s%s)", cfg->mode, + strcmp(cfg->mode, "general") == 0 + ? "; the system prompt is rewritten on the next turn" + : ""); +} + static void cmd_provider(tui_t *t, char *arg, int *quit) { (void)quit; cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); @@ -4639,14 +4678,15 @@ static void cmd_status(tui_t *t, char *arg, int *quit) { : !same ? "this run only (--api-key / CST_API_KEY)" : strcmp(from, "saved") == 0 ? "saved (/key to replace)" : from); - push_system_line(t, " protocol %s · tools %s · max steps %d", + push_system_line(t, " protocol %s · tools %s · mode %s · max steps %d", cst_protocol_name(cfg->protocol), - cst_tool_profile_name(cfg->tool_profile), cfg->max_steps); + cst_tool_profile_name(cfg->tool_profile), + cst_mode_name(cfg->mode), cfg->max_steps); char appr[64]; approval_label(t, appr, sizeof(appr)); push_system_line(t, " approval %s — %s", appr, approval_help(approval_mode(t))); - push_system_line(t, " mode %s", + push_system_line(t, " plan %s", cfg->plan_mode ? "Plan (read-only tools)" : "Build"); const char *notes = cst_agent_notes(t->agent); push_system_line(t, " notes %s", @@ -4825,19 +4865,39 @@ static void report_attached(tui_t *t, int added) { 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. */ +/* /attach paste: the clipboard image if there is one, else its text goes + * into the composer (CR dropped, control bytes kept out, INPUT_CAP respected). */ +static int paste_text_into_input(tui_t *t, const char *text, size_t n) { + char *buf = malloc(n + 1); + if (!buf) + return 0; + size_t k = 0; + for (size_t i = 0; i < n && t->in_len + k + 1 < INPUT_CAP; i++) { + unsigned char c = (unsigned char)text[i]; + if (c == '\r' || (c < 0x20 && c != '\n' && c != '\t')) + continue; + buf[k++] = (char)c; + } + if (k) + input_insert(t, buf, k); + free(buf); + return (int)k; +} + +/* /attach: stage files for the next user message -- images, PDFs (reduced to + * their text) and text. The payload is copied into the session's attachment + * directory now, so a file picked from /tmp keeps working after it 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 "); + push_system_line(t, "Nothing attached yet. Usage: /attach , " + "/attach paste, /attach list, /attach clear"); return; } - push_system_line(t, "%d image%s with the next message (sent once):", n, - n > 1 ? "s" : ""); + push_system_line(t, "%d attachment%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) @@ -4848,7 +4908,7 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) { } if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) { cst_agent_pending_clear(t->agent); - push_system_line(t, "Dropped the picked images."); + push_system_line(t, "Dropped the attachments."); return; } char err[256]; @@ -4856,7 +4916,24 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) { char *img = NULL; size_t len = 0; if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) { - push_system_line(t, "Error: %s", err); + /* no image: put the clipboard text in the composer instead */ + char *text = NULL; + size_t tn = 0; + char terr[192]; + if (cst_clipboard_text(&text, &tn, terr, sizeof(terr)) != 0) { + push_system_line(t, "Error: %s", terr); + return; + } + int added = paste_text_into_input(t, text, tn); + free(text); + if (added <= 0) { + push_system_line(t, "(no room in the composer for the clipboard " + "text)"); + return; + } + push_system_line(t, "(pasted %d bytes of clipboard text into the " + "composer)", added); + layout_maybe_grow_input(t); return; } char mime[32], name[32]; @@ -4881,19 +4958,29 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) { report_attached(t, 1); } -static int file_is_image(const char *path) { +/* A file worth attaching: an image, a PDF, or something that reads as text. + * Deciding here in the TUI only avoids turning a random dropped binary into + * a pasted path; the store checks the content for real. */ +static int file_is_attachable(const char *path) { FILE *f = fopen(path, "rb"); if (!f) return 0; - unsigned char head[16]; + unsigned char head[64]; size_t got = fread(head, 1, sizeof(head), f); fclose(f); char mime[32]; - return cst_attach_sniff(head, got, mime, sizeof(mime)) == 0; + if (cst_attach_sniff(head, got, mime, sizeof(mime)) == 0) + return 1; + if (got >= 4 && memcmp(head, "%PDF", 4) == 0) + return 1; + for (size_t i = 0; i < got; i++) + if (head[i] == 0 || head[i] < 0x09) + return 0; + return got > 0; } -/* A paste that is nothing but image file paths (a file dropped on the - * terminal) attaches those files instead of typing the paths. */ +/* A paste that is nothing but 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 || @@ -4905,7 +4992,7 @@ static void paste_image_paths(tui_t *t) { if (n <= 0) return; for (int i = 0; i < n; i++) - if (!file_is_image(paths[i])) + if (!file_is_attachable(paths[i])) return; input_erase(t, from, to); int added = 0; @@ -5441,6 +5528,8 @@ static const slash_cmd_t SLASH[] = { cmd_protocol}, {"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0, cmd_tools}, + {"mode", "", "[coding|general]", "coding or general (non-code) framing", 1, + cmd_mode}, {"permissions", "perms", "[reset]", "show saved always-allow rules", 1, cmd_permissions}, {"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1, @@ -5464,8 +5553,8 @@ 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", + {"attach", "image|file", "[path|paste|clear]", + "send files (image, PDF, text) or clipboard text 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}, diff --git a/src/webui.c b/src/webui.c index 1104503..383a8bf 100644 --- a/src/webui.c +++ b/src/webui.c @@ -263,6 +263,7 @@ static cJSON *wui_state_new(wui_t *w) { cJSON_AddStringToObject(o, "provider", cfg->provider); cJSON_AddStringToObject(o, "model", cfg->model); cJSON_AddStringToObject(o, "effort", cfg->effort); + cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode)); cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval)); cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol)); cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode); @@ -1670,6 +1671,12 @@ static int h_settings_get(cst_http_conn_t *c, void *ud) { cJSON *pr = cJSON_AddArrayToObject(o, "profiles"); for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++) cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(p))); + cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode)); + cJSON *md = cJSON_AddArrayToObject(o, "modes"); + if (md) { + cJSON_AddItemToArray(md, cJSON_CreateString("coding")); + cJSON_AddItemToArray(md, cJSON_CreateString("general")); + } cJSON *cf = cJSON_AddObjectToObject(o, "confirm"); if (cf) { cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell); @@ -1728,6 +1735,7 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) { const cJSON *proto = cJSON_GetObjectItem(j, "protocol"); const cJSON *steps = cJSON_GetObjectItem(j, "max_steps"); const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile"); + const cJSON *mode = cJSON_GetObjectItem(j, "mode"); const cJSON *tools = cJSON_GetObjectItem(j, "tools"); const cJSON *conf = cJSON_GetObjectItem(j, "confirm"); cst_protocol_t p = cfg->protocol; @@ -1742,6 +1750,9 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) { else if (prof && (!cJSON_IsString(prof) || cst_tool_profile_parse(prof->valuestring, &profile) != 0)) snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)"); + else if (mode && (!cJSON_IsString(mode) || + !cst_mode_valid(mode->valuestring))) + snprintf(bad, sizeof(bad), "工作模式只能是 coding / general"); else if (tools && !cJSON_IsObject(tools)) snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}"); else if (conf && !cJSON_IsObject(conf)) @@ -1760,6 +1771,11 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) { } cfg->protocol = p; cfg->tool_profile = profile; + if (mode) { + snprintf(cfg->mode, sizeof(cfg->mode), "%s", + cst_mode_name(mode->valuestring)); + cst_settings_remember_mode(cfg->mode); + } if (steps) cfg->max_steps = (int)steps->valuedouble; for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next) diff --git a/tests/test_attach.c b/tests/test_attach.c index 0b2450f..a8aa971 100644 --- a/tests/test_attach.c +++ b/tests/test_attach.c @@ -325,6 +325,100 @@ static void test_import_file(const char *root) { "missing file refused"); } +/* Documents: text is stored as text, a PDF as its text layer while the mime + * still says PDF, and neither is an image as far as the wire is concerned. */ +static void test_documents(const char *root) { + char session[1024], src[1024]; + snprintf(session, sizeof(session), "%.900s/sessions/doc.jsonl", root); + char err[256] = ""; + + const char *text = "# Notes\n\nFive painters, one canvas.\n"; + snprintf(src, sizeof(src), "%.900s/notes.md", root); + FILE *f = fopen(src, "wb"); + EXPECT(f != NULL, "write the text file"); + if (f) { + fwrite(text, 1, strlen(text), f); + fclose(f); + } + cst_attachment_t *a = cst_attach_import_file(session, src, err, sizeof(err)); + EXPECT(a != NULL, "text file imported"); + if (a) { + EXPECT(strcmp(a->mime, "text/plain") == 0, "text mime"); + EXPECT(!cst_attach_is_image(a) && cst_attach_is_text(a), "not an image"); + EXPECT(a->pages == 0, "no pages for text"); + EXPECT(strstr(a->rel, ".txt") != NULL, "text stored as .txt"); + EXPECT(a->bytes == (long long)strlen(text), "text size"); + size_t n = 0; + char *back = cst_attach_read(session, a, &n, err, sizeof(err)); + EXPECT(back && n == strlen(text) && memcmp(back, text, n) == 0, + "text reads back exactly"); + free(back); + EXPECT(cst_attach_data_url(session, a) == NULL, + "a document has no data url"); + char desc[128]; + cst_attach_describe(a, desc, sizeof(desc)); + EXPECT(strstr(desc, "text") != NULL, "describe says text"); + cst_attach_free(a); + } + + /* a one-page PDF with a text layer */ + static const char PDF[] = + "%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R " + ">> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n" + "endobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT /F1 12 Tf 72 720 Td (Attached paper abstract) Tj ET\n" + "endstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + snprintf(src, sizeof(src), "%.900s/paper.pdf", root); + f = fopen(src, "wb"); + EXPECT(f != NULL, "write the PDF"); + if (f) { + fwrite(PDF, 1, strlen(PDF), f); + fclose(f); + } + err[0] = 0; + a = cst_attach_import_file(session, src, err, sizeof(err)); + EXPECT(a != NULL, "PDF imported"); + if (a) { + EXPECT(strcmp(a->mime, "application/pdf") == 0, "the PDF keeps its mime"); + EXPECT(a->pages == 1, "page count comes from the extractor"); + EXPECT(strstr(a->rel, ".txt") != NULL, "the text layer is what is stored"); + EXPECT(cst_attach_is_text(a), "documents are text on the wire"); + char desc[128]; + cst_attach_describe(a, desc, sizeof(desc)); + EXPECT(strstr(desc, "pdf") != NULL && strstr(desc, "1 page") != NULL, + "describe names the PDF and its pages"); + size_t n = 0; + char *back = cst_attach_read(session, a, &n, err, sizeof(err)); + EXPECT(back && strstr(back, "Attached paper abstract") != NULL, + "the extracted text reads back"); + free(back); + cst_attach_free(a); + } + err[0] = 0; + EXPECT(cst_attach_import_file(session, "/nonexistent/x.pdf", err, + sizeof(err)) == NULL, + "a missing PDF is refused"); + + /* neither image, PDF nor text: a binary blob */ + static const char binary[] = "\x01\x02\x03\x00\x04\x05"; + snprintf(src, sizeof(src), "%.900s/blob.bin", root); + f = fopen(src, "wb"); + if (f) { + fwrite(binary, 1, sizeof(binary), f); + fclose(f); + } + err[0] = 0; + EXPECT(cst_attach_import_file(session, src, err, sizeof(err)) == NULL, + "a binary file is refused"); + EXPECT(strstr(err, "not an image") != NULL, "err says what was expected"); +} + static void test_json_roundtrip(void) { cst_attachment_t list[3]; memset(list, 0, sizeof(list)); @@ -344,6 +438,8 @@ static void test_json_roundtrip(void) { list[1].bytes = 4096; snprintf(list[1].detail, sizeof(list[1].detail), "high"); snprintf(list[1].origin, sizeof(list[1].origin), "view_image"); + snprintf(list[1].mime, sizeof(list[1].mime), "application/pdf"); + list[1].pages = 12; snprintf(list[2].id, sizeof(list[2].id), "img-3"); snprintf(list[2].name, sizeof(list[2].name), "c.webp"); @@ -365,6 +461,9 @@ static void test_json_roundtrip(void) { EXPECT(strcmp(back[1].detail, "high") == 0 && strcmp(back[1].origin, "view_image") == 0, "detail and origin survive"); + EXPECT(strcmp(back[1].mime, "application/pdf") == 0 && back[1].pages == 12, + "a document keeps its mime and page count"); + EXPECT(back[2].pages == 0, "pages default to 0"); EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0, "entry without dimensions"); EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults"); @@ -530,6 +629,7 @@ int main(void) { test_dib(); test_store(root); test_import_file(root); + test_documents(root); test_json_roundtrip(); test_lists(); test_agent_pending(root); diff --git a/tests/test_config.c b/tests/test_config.c index bcc98d6..aa4607d 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -106,11 +106,13 @@ static void test_settings(void) { snprintf(cfg.provider, sizeof(cfg.provider), "openai"); snprintf(cfg.model, sizeof(cfg.model), "gpt-5"); snprintf(cfg.effort, sizeof(cfg.effort), "max"); + snprintf(cfg.mode, sizeof(cfg.mode), "general"); CHECK(cst_settings_remember(&cfg) == 0); CHECK(cst_settings_load(&s) == 0); CHECK(strcmp(s.provider, "openai") == 0); CHECK(strcmp(s.effort, "max") == 0); + CHECK(strcmp(s.mode, "general") == 0); CHECK(strcmp(cst_settings_model(&s, "deepseek"), "deepseek-v4-pro") == 0); CHECK(strcmp(cst_settings_model(&s, "openai"), "gpt-5") == 0); CHECK(s.nmodels == 2); @@ -145,6 +147,24 @@ static void test_effort(void) { free(body); } +static void test_mode(void) { + CHECK(cst_mode_valid("coding") && cst_mode_valid("general")); + CHECK(!cst_mode_valid("code") && !cst_mode_valid(NULL)); + CHECK(!cst_mode_valid("")); + CHECK(strcmp(cst_mode_name("general"), "general") == 0); + CHECK(strcmp(cst_mode_name(NULL), CST_DEFAULT_MODE) == 0); + CHECK(strcmp(cst_mode_name("nonsense"), "coding") == 0); + + CHECK(cst_settings_remember_mode("general") == 0); + cst_settings_t s; + CHECK(cst_settings_load(&s) == 0); + CHECK(strcmp(s.mode, "general") == 0); + /* an unknown value is recorded as the default, not as itself */ + CHECK(cst_settings_remember_mode("junk") == 0); + CHECK(cst_settings_load(&s) == 0); + CHECK(strcmp(s.mode, "coding") == 0); +} + int main(void) { CHECK(test_tmpdir(g_dir, sizeof(g_dir), "cst-config") == 0); setenv("XDG_CONFIG_HOME", g_dir, 1); @@ -158,6 +178,7 @@ int main(void) { test_creds(); test_settings(); test_effort(); + test_mode(); CHECK(test_rmrf(g_dir) == 0); printf("test_config: OK\n"); diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index bdc63d0..1cafa7b 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -406,9 +406,118 @@ static void test_images_on(void) { cst_attach_free(img); } +/* Every text part of message i, joined (malloc'd, "" when there is none). */ +static char *text_of(const cJSON *root, int i) { + const cJSON *m = + cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"), i); + const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content"); + if (cJSON_IsString(c)) { + size_t n = strlen(c->valuestring); + char *out = malloc(n + 1); + if (out) + memcpy(out, c->valuestring, n + 1); + return out; + } + size_t len = 1; + const cJSON *p, *t, *s; + cJSON_ArrayForEach(p, c) { + t = cJSON_GetObjectItemCaseSensitive(p, "type"); + s = cJSON_GetObjectItemCaseSensitive(p, "text"); + if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 && + cJSON_IsString(s)) + len += strlen(s->valuestring) + 1; + } + char *out = calloc(1, len); + if (!out) + return NULL; + cJSON_ArrayForEach(p, c) { + t = cJSON_GetObjectItemCaseSensitive(p, "type"); + s = cJSON_GetObjectItemCaseSensitive(p, "text"); + if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 && + cJSON_IsString(s)) { + strcat(out, s->valuestring); + strcat(out, "\n"); + } + } + return out; +} + +/* A document is inlined as text, so a text-only model and a message that + * also carries an image both keep it; the image is what becomes a note. */ +static void test_document_parts(void) { + char dir[1024], sess[1100], err[256], src[1200]; + if (test_tmpdir(dir, sizeof(dir), "cst-body-doc") != 0) { + EXPECT(0, "scratch dir"); + return; + } + snprintf(sess, sizeof(sess), "%s/s.jsonl", dir); + snprintf(src, sizeof(src), "%s/paper.pdf", dir); + static const char PDF[] = + "%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R " + ">> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n" + "endobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT /F1 12 Tf 72 720 Td (Inlined paper text) Tj ET\n" + "endstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + FILE *f = fopen(src, "wb"); + EXPECT(f != NULL, "write the PDF"); + if (!f) + return; + fwrite(PDF, 1, strlen(PDF), f); + fclose(f); + cst_attachment_t *doc = cst_attach_import_file(sess, src, err, sizeof(err)); + EXPECT(doc != NULL, "PDF stored"); + if (!doc) + return; + static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, + '\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R', + 0, 0, 0, 2, 0, 0, 0, 2}; + cst_attachment_t *img = cst_attach_import_bytes( + sess, "shot.png", (const char *)png, sizeof(png), err, sizeof(err)); + EXPECT(img != NULL, "image stored"); + if (!img) { + cst_attach_free(doc); + return; + } + cst_attachment_t both[2] = {*doc, *img}; + cst_message_t m[2]; + m[0] = msg(CST_ROLE_USER, "what does this say?"); + m[0].attach = both; + m[0].nattach = 2; + m[1] = msg(CST_ROLE_USER, "thanks"); + cst_llm_request_t r; + memset(&r, 0, sizeof(r)); + r.model = "deepseek-flash"; + r.msgs = m; + r.nmsgs = 2; + r.images_off = 1; /* a text-only model */ + r.session_path = sess; + cJSON *j = build_req(&r); + char *txt = text_of(j, 0); + EXPECT(txt && strstr(txt, "[document \"paper.pdf\"") && + strstr(txt, "Inlined paper text") && + strstr(txt, "[-- end of document --]"), + "the PDF text is inlined even when images are off"); + EXPECT(txt && strstr(txt, "[image \"shot.png\"") && strstr(txt, "not sent"), + "the image beside it is still only a note"); + free(txt); + char *s = cJSON_PrintUnformatted(j); + EXPECT(s && !strstr(s, "image_url"), "no image part with images off"); + free(s); + cJSON_Delete(j); + cst_attach_free(doc); + cst_attach_free(img); +} + int main(void) { test_images_off(); test_images_on(); + test_document_parts(); test_reasoning_and_prefix(); test_cache_hints(); test_tools_snapshot(); diff --git a/tests/test_pdf_text.c b/tests/test_pdf_text.c new file mode 100644 index 0000000..65e6ed7 --- /dev/null +++ b/tests/test_pdf_text.c @@ -0,0 +1,250 @@ +/* Text extraction from PDFs: hand-made fixtures for the shapes that matter + * (plain content, FlateDecode, CID fonts with ToUnicode CMaps, object + * streams) plus the two error paths (encrypted, not a PDF). */ + +#include "cst_pdf.h" + +#include +#include +#include + +static int failures = 0; + +#define CHECK(cond, what) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \ + failures++; \ + } \ + } while (0) + +static char *extract(const char *pdf, int *pages, char *err, size_t err_sz) { + return cst_pdf_text(pdf, strlen(pdf), pages, err, err_sz); +} + +static int has(const char *hay, const char *needle) { + return hay && strstr(hay, needle) != NULL; +} + +/* --- 1. plain, uncompressed content, two lines via Td --------------- */ + +static const char PLAIN[] = + "%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + "/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT\n/F1 12 Tf\n72 720 Td\n(Line one) Tj\n0 -14 Td\n" + "(A \\(paren\\) and a \\\\ backslash) Tj\nET\n" + "endstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + +static void test_plain(void) { + char err[256] = ""; + int pages = 0; + char *t = extract(PLAIN, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(pages == 1, "one page"); + CHECK(has(t, "Line one"), "first line"); + CHECK(has(t, "A (paren) and a \\ backslash"), "escapes"); + CHECK(!has(t, "\r"), "no CR left"); + free(t); +} + +/* --- 2. FlateDecode content stream ---------------------------------- */ + +/* raw deflate (zlib, level 9, no header) of + * "BT /F1 12 Tf 72 720 Td (Hello, compressed world) Tj ET\n" */ +static const unsigned char FLATE[] = { + 0x73, 0x0a, 0x51, 0xd0, 0x77, 0x33, 0x54, 0x30, 0x34, 0x52, 0x08, 0x49, + 0x53, 0x30, 0x37, 0x02, 0x22, 0x03, 0x85, 0x90, 0x14, 0x05, 0x0d, 0x8f, + 0xd4, 0x9c, 0x9c, 0x7c, 0x1d, 0x85, 0xe4, 0xfc, 0xdc, 0x82, 0xa2, 0xd4, + 0xe2, 0xe2, 0xd4, 0x14, 0x85, 0xf2, 0xfc, 0xa2, 0x9c, 0x14, 0x4d, 0x85, + 0x90, 0x2c, 0x05, 0xd7, 0x10, 0x2e, 0x00}; + +static void test_flate(void) { + char pdf[2048]; + int n = snprintf(pdf, sizeof(pdf), + "%%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font " + "<< /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 >>\nendobj\n" + "5 0 obj\n<< /Filter /FlateDecode >>\nstream\n"); + memcpy(pdf + n, FLATE, sizeof(FLATE)); + n += (int)sizeof(FLATE); + n += snprintf(pdf + n, sizeof(pdf) - (size_t)n, + "\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"); + char err[256] = ""; + int pages = 0; + char *t = cst_pdf_text(pdf, (size_t)n, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(has(t, "Hello, compressed world"), "inflated text"); + free(t); +} + +/* 2b. zlib-framed FlateDecode (RFC 1950), which is what real writers emit: + * zlib.compress(b"BT /F1 12 Tf 72 720 Td (Zlib framed hello) Tj ET\n"). */ +static const unsigned char ZLIB[] = { + 0x78, 0xda, 0x73, 0x0a, 0x51, 0xd0, 0x77, 0x33, 0x54, 0x30, 0x34, 0x52, + 0x08, 0x49, 0x53, 0x30, 0x37, 0x02, 0x22, 0x03, 0x85, 0x90, 0x14, 0x05, + 0x8d, 0xa8, 0x9c, 0xcc, 0x24, 0x85, 0xb4, 0xa2, 0xc4, 0xdc, 0xd4, 0x14, + 0x85, 0x8c, 0xd4, 0x9c, 0x9c, 0x7c, 0x4d, 0x85, 0x90, 0x2c, 0x05, 0xd7, + 0x10, 0x2e, 0x00, 0x33, 0x0f, 0x0d, 0x3a}; + +static void test_zlib_flate(void) { + char pdf[2048]; + int n = snprintf(pdf, sizeof(pdf), + "%%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font " + "<< /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 >>\nendobj\n" + "5 0 obj\n<< /Filter /FlateDecode >>\nstream\n"); + memcpy(pdf + n, ZLIB, sizeof(ZLIB)); + n += (int)sizeof(ZLIB); + n += snprintf(pdf + n, sizeof(pdf) - (size_t)n, + "\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"); + char err[256] = ""; + int pages = 0; + char *t = cst_pdf_text(pdf, (size_t)n, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(has(t, "Zlib framed hello"), "zlib header skipped, text inflated"); + free(t); +} + +/* 2c. an inline image: its bytes are not text and must be skipped. */ + +static const char INLINE_IMG[] = + "%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R " + ">> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT /F1 12 Tf 72 720 Td (Before the image) Tj ET\n" + "q 20 0 0 20 72 700 cm\n" + "BI /W 8 /H 8 /CS /RGB /BPC 8 /F /AHx ID\n" + "00ff0000ff00ff00ff00ff00 8123<0001> EI Q\n" + "BT /F1 12 Tf 72 680 Td (After the image) Tj ET\n" + "endstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + +static void test_inline_image(void) { + char err[256] = ""; + int pages = 0; + char *t = extract(INLINE_IMG, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(has(t, "Before the image"), "text before the inline image"); + CHECK(has(t, "After the image"), "text after it is still found"); + CHECK(!has(t, "00ff0000"), "the image bytes were skipped"); + CHECK(!has(t, "8123"), "its hex data was skipped too"); + free(t); +} + +/* --- 3. CID font with a ToUnicode CMap ------------------------------ */ + +static const char CID[] = + "%PDF-1.5\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R " + ">> >> /Contents 5 0 R >>\nendobj\n" + "4 0 obj\n<< /Type /Font /Subtype /Type0 /Encoding /Identity-H " + "/ToUnicode 6 0 R >>\nendobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT /F1 12 Tf 72 720 Td <0001000200030004> Tj ET\n" + "endstream\nendobj\n" + "6 0 obj\n<< >>\nstream\n" + "1 begincodespacerange\n<0000> \nendcodespacerange\n" + "2 beginbfchar\n<0001> <4F60>\n<0002> <597D>\nendbfchar\n" + "1 beginbfrange\n<0003> <0004> <0041>\nendbfrange\n" + "endcmap\nendstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"; + +static void test_cid(void) { + char err[256] = ""; + int pages = 0; + char *t = extract(CID, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(has(t, "\xe4\xbd\xa0\xe5\xa5\xbd"), "bfchar maps to 你好"); + CHECK(has(t, "AB"), "bfrange walks the code points"); + free(t); +} + +/* --- 4. objects inside an object stream ----------------------------- */ + +static void test_objstm(void) { + char pdf[4096]; + const char *page = "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 " + "4 0 R >> >> /Contents 5 0 R >>"; + const char *font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"; + /* the stream is "num1 off1 num2 off2" -- /N and /First are in the dict -- + * and offsets count from /First, which is where the bodies start, so + * /First is the length of the pairs list itself */ + char hdr[64]; + int off4 = (int)strlen(page) + 1; /* object 4 follows the page */ + int first = snprintf(hdr, sizeof(hdr), "3 0 4 %d ", off4); + int n = snprintf(pdf, sizeof(pdf), + "%%PDF-1.5\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "5 0 obj\n<< >>\nstream\n" + "BT /F1 12 Tf 72 720 Td (Inside an object stream) Tj ET\n" + "endstream\nendobj\n" + "6 0 obj\n<< /Type /ObjStm /N 2 /First %d >>\nstream\n" + "%s%s\n%s", + first, hdr, page, font); + n += snprintf(pdf + n, sizeof(pdf) - (size_t)n, + "\nendstream\nendobj\n" + "trailer\n<< /Root 1 0 R >>\n%%EOF\n"); + char err[256] = ""; + int pages = 0; + char *t = cst_pdf_text(pdf, (size_t)n, &pages, err, sizeof(err)); + CHECK(t != NULL, err); + CHECK(pages == 1, "one page through the object stream"); + CHECK(has(t, "Inside an object stream"), "text of the page in the ObjStm"); + free(t); +} + +/* --- 5. error paths -------------------------------------------------- */ + +static void test_errors(void) { + char err[256] = ""; + char *t = extract("%PDF-1.7\ntrailer\n<< /Root 1 0 R /Encrypt 9 0 R >>\n", + NULL, err, sizeof(err)); + CHECK(t == NULL, "encrypted file is refused"); + CHECK(has(err, "encrypted"), "says it is encrypted"); + + err[0] = 0; + t = extract("hello, not a pdf at all\n", NULL, err, sizeof(err)); + CHECK(t == NULL, "non-PDF is refused"); + CHECK(has(err, "not a PDF"), "says it is not a PDF"); + + err[0] = 0; + t = extract("%PDF-1.4\n1 0 obj\n<< >>\nendobj\n", NULL, err, sizeof(err)); + CHECK(t == NULL, "a PDF without text is refused"); + CHECK(has(err, "no extractable text") || has(err, "no readable pages"), + "explains the missing text"); +} + +int main(void) { + test_plain(); + test_flate(); + test_zlib_flate(); + test_inline_image(); + test_cid(); + test_objstm(); + test_errors(); + if (failures) { + fprintf(stderr, "test_pdf_text: %d failure(s)\n", failures); + return 1; + } + printf("test_pdf_text: OK\n"); + return 0; +} diff --git a/web/app.js b/web/app.js index 24eb458..20926ee 100644 --- a/web/app.js +++ b/web/app.js @@ -1717,6 +1717,7 @@ async function showRunSettings() { const proto = field('工具协议', select(s.protocols || [], s.protocol)); const steps = field('每轮步数', Object.assign(el('input'), { type: 'number', min: 1, max: 1000, value: s.max_steps })); const prof = field('工具集', select(s.profiles || [], s.tool_profile)); + const mode = field('工作模式', select(s.modes || [], s.mode)); box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')')); const conf = {}; @@ -1786,6 +1787,7 @@ async function showRunSettings() { if (proto.value !== s.protocol) body.protocol = proto.value; if (+steps.value !== s.max_steps) body.max_steps = +steps.value; if (prof.value !== s.tool_profile) body.tool_profile = prof.value; + if (mode.value !== s.mode) body.mode = mode.value; const c = {}; for (const k of Object.keys(conf)) if (conf[k].checked !== !!(s.confirm || {})[k]) c[k] = conf[k].checked; if (Object.keys(c).length) body.confirm = c;