v0.8.6: general mode, clipboard text, PDF text extraction
ci / tcc-check (push) Successful in 59s
ci / linux (aarch64, 0) (push) Successful in 19m11s
ci / linux (x86, 1) (push) Successful in 11m31s
ci / linux (x86_64, 1) (push) Successful in 11m11s
ci / windows (push) Successful in 9m24s

Working mode: --mode coding|general (CST_MODE, settings.json, /mode,
WebUI run settings). general swaps the default framing for a general
assistant one -- workflow, safety notes, tool wording (run_shell, the
subagents) -- and /status shows it; minimal (the paper profile) stays
byte-frozen. The prompt state carries the mode so a switch leaves a
system note in history.

Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read,
default 64 KiB window; writing is a WRITE permission with a preview) on
pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32
clipboard. In the TUI, /attach paste falls back to clipboard text when
there is no image on it.

PDF: a dependency-free text extractor (src/pdf_text.c): inflate,
Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object
scan, object streams, page tree walk with inherited resources, content
lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font
ToUnicode CMaps and inline images skipped. Encrypted files and pages
with no text layer are refused with the reason.

Attachments: /attach (a path or files dropped on the terminal) now take
images, PDFs and text. A PDF is stored as its text layer with the mime
still saying application/pdf and its page count; anything else has to be
UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and
text-only models no longer drop them, while images keep their old path.

Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image,
CID + ToUnicode, object stream, error paths), documents in test_attach,
inlined documents in test_llm_body, mode in test_config.
This commit is contained in:
WHC
2026-10-11 11:09:39 +08:00
parent 50abcf1a91
commit e0b0eddf3a
28 changed files with 3291 additions and 111 deletions
+8 -1
View File
@@ -95,6 +95,7 @@ set(CST_CORE_SOURCES
src/subagent.c src/subagent.c
src/attach.c src/attach.c
src/clipboard.c src/clipboard.c
src/pdf_text.c
src/session.c src/session.c
src/tools/tools.c src/tools/tools.c
src/tools/patch.c src/tools/patch.c
@@ -279,7 +280,7 @@ endif()
enable_testing() enable_testing()
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline 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_replay_starvation test_replay_real test_mm_tool test_textsel
test_markdown) test_pdf_text test_markdown)
foreach(t ${_CST_SMALL_TESTS}) foreach(t ${_CST_SMALL_TESTS})
if(t STREQUAL test_textsel) if(t STREQUAL test_textsel)
add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c 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) if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32) target_link_libraries(${t} PRIVATE advapi32 user32)
endif() 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) elseif(t STREQUAL test_markdown)
add_executable(${t} tests/${t}.c) add_executable(${t} tests/${t}.c)
target_include_directories(${t} PRIVATE "${SD_DIR}/include") target_include_directories(${t} PRIVATE "${SD_DIR}/include")
+5
View File
@@ -96,6 +96,7 @@ CORE_SRC = \
src/keymap.c \ src/keymap.c \
src/skills.c \ src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.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/session.c \
src/tools/tools.c \ src/tools/tools.c \
src/tools/patch.c \ src/tools/patch.c \
@@ -135,6 +136,7 @@ TESTS = \
tests/test_apply_patch \ tests/test_apply_patch \
tests/test_session_roundtrip \ tests/test_session_roundtrip \
tests/test_attach \ tests/test_attach \
tests/test_pdf_text \
tests/test_shell \ tests/test_shell \
tests/test_diff \ tests/test_diff \
tests/test_tools_fs \ 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 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) $(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 tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
+26
View File
@@ -233,6 +233,7 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|---|---| |---|---|
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) | | `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) | | `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` | | `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` | | `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | | `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
@@ -276,6 +277,7 @@ cst-agent --headless --prompt 'List files'
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 | | `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | | `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` | | `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) | | `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key | | `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
@@ -297,6 +299,8 @@ cst-agent --headless --prompt 'List files'
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) | | `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | | `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | | `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`(论文实验口径)。 `--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 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 <path>`、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 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`): - **审批模式**(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)`)、推理强度;都会记住 | | `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) | | `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
| `/protocol [name]` | 切换工具调用协议 | | `/protocol [name]` | 切换工具调用协议 |
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 | | `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo | | `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 | | `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
@@ -435,6 +460,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
| `/diff [path]` * | `git status` + `git diff` | | `/diff [path]` * | `git status` + `git diff` |
| `/compact` | 手动压缩上下文 | | `/compact` | 手动压缩上下文 |
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | | `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) | | `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
| `/init` | 让 agent 写 `AGENTS.md` | | `/init` | 让 agent 写 `AGENTS.md` |
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 | | `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
+21 -6
View File
@@ -47,15 +47,18 @@ int cst_protocol_is_text(cst_protocol_t p);
/* Eager protocols execute a call during the stream, at confirmation time. */ /* Eager protocols execute a call during the stream, at confirmation time. */
int cst_protocol_is_eager(cst_protocol_t p); int cst_protocol_is_eager(cst_protocol_t p);
/* One image carried by a message (see cst_attach.h). The bytes live beside /* One file carried by a message (see cst_attach.h): an image, or a document
* the session; this is the metadata plus the name of the stored copy. */ * 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 { typedef struct {
char id[32]; /* unique inside the session ("img-1a2b3c4d") */ char id[32]; /* unique inside the session ("img-1a2b3c4d") */
char name[256]; /* display name (original file name) */ 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 */ 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 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 detail[8]; /* auto | low | high (wire hint, "" = auto) */
char origin[32]; /* user | view_image | mcp (for display only) */ char origin[32]; /* user | view_image | mcp (for display only) */
} cst_attachment_t; } cst_attachment_t;
@@ -82,6 +85,7 @@ typedef struct {
char model[128]; char model[128];
char provider[32]; char provider[32];
char effort[16]; /* low | medium | high | max */ char effort[16]; /* low | medium | high | max */
char mode[16]; /* coding | general (CST_DEFAULT_MODE) */
cst_protocol_t protocol; cst_protocol_t protocol;
int max_steps; /* model calls per user turn */ int max_steps; /* model calls per user turn */
size_t tool_out_cap; /* bytes of one tool output sent to the model */ 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); const char *cst_approval_name(int a);
int cst_approval_parse(const char *s, int *out); 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_session cst_session_t;
typedef struct cst_agent cst_agent_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). */ /* Hidden console prompt (no echo). */
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); 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
* <config>/settings.json. */ * <config>/settings.json. */
#define CST_SETTINGS_MAX_MODELS 16 #define CST_SETTINGS_MAX_MODELS 16
#define CST_SETTINGS_MAX_TRUSTED 32 #define CST_SETTINGS_MAX_TRUSTED 32
typedef struct { typedef struct {
char provider[32]; char provider[32];
char effort[16]; char effort[16];
char mode[16]; /* coding | general, "" = not chosen yet */
char approval[8]; /* "" = not chosen yet */ char approval[8]; /* "" = not chosen yet */
int show_thinking; /* TUI: Thought blocks start expanded */ int show_thinking; /* TUI: Thought blocks start expanded */
/* workspaces whose .agent/plugins and .agent/mcp.json may run */ /* 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_load(cst_settings_t *s);
int cst_settings_save(const 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); 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); int cst_settings_remember(const cst_agent_config_t *cfg);
/* Record an approval mode the user picked (CST_APPROVAL_*). */ /* Record an approval mode the user picked (CST_APPROVAL_*). */
int cst_settings_remember_approval(int approval); int cst_settings_remember_approval(int approval);
int cst_settings_remember_thinking(int show_thinking); 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). */ /* Workspace trust for project plugins / MCP servers (by real path). */
int cst_settings_trusted(const char *workspace); int cst_settings_trusted(const char *workspace);
int cst_settings_trust(const char *workspace, int on); int cst_settings_trust(const char *workspace, int on);
+24 -14
View File
@@ -9,16 +9,24 @@
extern "C" { extern "C" {
#endif #endif
/* --- image attachments --------------------------------------------------- /* --- attachments ---------------------------------------------------------
* An attachment is a validated copy of an image file, stored beside the * An attachment is a validated copy of a file, stored beside the session so
* session so the bytes a model is shown stay the bytes that were picked. * what a model is shown stays what was picked. Images are stored as they are
* Only the metadata travels in the JSONL; the bytes are encoded into the * and encoded into the request when it is built; a PDF is reduced to its text
* request when it is built. */ * 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). */ /* Supported: PNG, JPEG, GIF, WebP, BMP (static images), PDF, UTF-8 text. */
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */ #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_TOTAL (32 * 1024 * 1024) /* per request */
#define CST_ATTACH_MAX_COUNT 8 /* per message */ #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 /* Magic-byte test: 0 and a canonical mime when data holds a supported
* image, -1 otherwise (an extension alone never decides). */ * 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, int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz); char *out, size_t out_sz);
/* Read src_path, verify it is a supported image within limits, and copy it /* Read src_path and copy it into the session's attachment directory: an image
* into the session's attachment directory. Returns a malloc'd entry the * as it is, a PDF as its extracted text, anything else only when it is text.
* caller owns (cst_attach_free), or NULL with err filled. */ * 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, cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err, const char *src_path, char *err,
size_t err_sz); 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, cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data, const char *name, const char *data,
size_t n, char *err, size_t err_sz); 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, char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz); 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); 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_describe(const cst_attachment_t *a, char *out, size_t out_sz);
void cst_attach_free(cst_attachment_t *a); void cst_attach_free(cst_attachment_t *a);
+14
View File
@@ -13,6 +13,20 @@ extern "C" {
* ("no image on the clipboard", "install wl-clipboard", ...). */ * ("no image on the clipboard", "install wl-clipboard", ...). */
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz); 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 /* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */ * 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); char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
+27
View File
@@ -0,0 +1,27 @@
#ifndef CST_PDF_H
#define CST_PDF_H
#include <stddef.h>
#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
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #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) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+108 -6
View File
@@ -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)); 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. */ /* 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) { 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"); cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need");
const char *explore[] = {"list_dir", "glob", "grep"}; const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0; int n = 0;
@@ -235,6 +289,25 @@ static const char DEF_INTRO[] =
"could not verify, say so.\n" "could not verify, say so.\n"
"- Keep going until the task is done or truly blocked.\n\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) { static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nTool use:\n"); cst_buf_puts(b, "\nTool use:\n");
if (tool_on(cfg, "run_shell") && 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 " 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 " "edit. The `N|` prefixes in read_file output are not part "
"of the file.\n"); "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 " cst_buf_puts(b, "- When a call fails, read the error and change approach "
"instead of repeating the same call.\n"); "instead of repeating the same call.\n");
} }
static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) { static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nSafety:\n" if (general_mode(cfg))
"- Do not commit, push or rewrite git history unless asked. " cst_buf_puts(b, "\nSafety:\n"
"Do not delete or overwrite files you did not create unless " "- Do not delete or overwrite files or data you did not "
"the task requires it. Never print secrets or API keys.\n"); "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")) if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the " 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 " "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) { const char *project_notes) {
int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL; int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL;
cst_buf_t b = {0}; 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)) if (cst_protocol_is_native(p))
cst_buf_puts(&b, minimal ? SEC_NATIVE cst_buf_puts(&b, minimal ? SEC_NATIVE
: "Call tools through the 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, * later changes reach the model as persisted notes appended to the history,
* which keeps the cached prefix intact. */ * which keeps the cached prefix intact. */
#define PROMPT_VERSION 2 #define PROMPT_VERSION 3
typedef struct { typedef struct {
int plan; int plan;
char approval[96]; char approval[96];
char mode[16];
char branch[128]; char branch[128];
char day[16]; char day[16];
char notes[24]; /* hash of AGENTS.md, "" = none */ 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)); memset(s, 0, sizeof(*s));
s->plan = a->cfg.plan_mode; s->plan = a->cfg.plan_mode;
snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg)); 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)); git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch));
today(s->day, sizeof(s->day)); today(s->day, sizeof(s->day));
if (a->notes && a->notes[0]) if (a->notes && a->notes[0])
@@ -2267,6 +2355,7 @@ static cJSON *state_json(const pstate_t *s) {
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "plan", s->plan); cJSON_AddNumberToObject(o, "plan", s->plan);
cJSON_AddStringToObject(o, "approval", s->approval); cJSON_AddStringToObject(o, "approval", s->approval);
cJSON_AddStringToObject(o, "mode", s->mode);
cJSON_AddStringToObject(o, "branch", s->branch); cJSON_AddStringToObject(o, "branch", s->branch);
cJSON_AddStringToObject(o, "day", s->day); cJSON_AddStringToObject(o, "day", s->day);
cJSON_AddStringToObject(o, "notes", s->notes); cJSON_AddStringToObject(o, "notes", s->notes);
@@ -2282,6 +2371,7 @@ static void state_parse(const cJSON *o, pstate_t *s) {
if (cJSON_IsString(v)) \ if (cJSON_IsString(v)) \
snprintf(s->field, sizeof(s->field), "%s", v->valuestring); snprintf(s->field, sizeof(s->field), "%s", v->valuestring);
STATE_STR("approval", approval) STATE_STR("approval", approval)
STATE_STR("mode", mode)
STATE_STR("branch", branch) STATE_STR("branch", branch)
STATE_STR("day", day) STATE_STR("day", day)
STATE_STR("notes", notes) 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_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol));
cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION); cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION);
cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile); 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)); cJSON_AddItemToObject(o, "state", state_json(s));
char *out = cJSON_PrintUnformatted(o); char *out = cJSON_PrintUnformatted(o);
cJSON_Delete(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 *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv");
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol"); const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol");
const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools"); const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools");
const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode");
int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION && int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION &&
cJSON_IsString(pr) && cJSON_IsString(pr) &&
strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 && strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 &&
cJSON_IsNumber(tp) && tp->valueint == c->tool_profile; 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); cJSON_Delete(meta);
return ok; return ok;
} }
@@ -2393,6 +2487,14 @@ static void sync_notes(cst_agent_t *a) {
"is available again.\n"); "is available again.\n");
if (strcmp(now.approval, was.approval) != 0) if (strcmp(now.approval, was.approval) != 0)
cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval); 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) if (strcmp(now.branch, was.branch) != 0)
cst_buf_printf(&b, "[cst] The git branch is now %s.\n", cst_buf_printf(&b, "[cst] The git branch is now %s.\n",
now.branch[0] ? now.branch : "(none)"); now.branch[0] ? now.branch : "(none)");
+129 -24
View File
@@ -4,6 +4,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_pdf.h"
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.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) { 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) if (strcmp(mime, "image/png") == 0)
return "png"; return "png";
if (strcmp(mime, "image/jpeg") == 0) 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)); 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, static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin, const char *data, size_t n, const char *mime,
char *err, size_t err_sz) { int pages, const char *origin, char *err,
size_t err_sz) {
if (!name || !name[0]) if (!name || !name[0])
name = "image"; 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]; char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) { if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory"); 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->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto"); snprintf(a->detail, sizeof(a->detail), "auto");
a->bytes = (long long)n; 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]; char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) { 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; err[0] = 0;
if (!session_path || !session_path[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; return NULL;
} }
if (!data || !n) { if (!data || !n) {
snprintf(err, err_sz, "image is empty"); snprintf(err, err_sz, "the file is empty");
return NULL; 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, 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++) for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\') if (*p == '/' || *p == '\\')
leaf = p + 1; 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); 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; 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, char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) { size_t *out_len, char *err, size_t err_sz) {
char dummy[8]; 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); char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) { if (!data) {
snprintf(err, err_sz, snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session " "%.200s is missing; it was deleted from the session store",
"store",
a->name); a->name);
return NULL; return NULL;
} }
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
return data;
char mime[32]; char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) { 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", 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) { 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]; char err[256];
size_t n = 0; size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err)); 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)"); snprintf(out, out_sz, "(none)");
return; return;
} }
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime; const char *kind = a->mime;
char dim[32] = ""; char dim[32] = "";
if (a->width > 0 && a->height > 0) if (cst_attach_is_image(a)) {
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height); 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; out[0] = 0;
if (a->bytes >= 1024 * 1024) if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind, 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, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height); 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) if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail); cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0]) 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; a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height"); v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0; 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"); v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s", snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto"); cJSON_IsString(v) ? v->valuestring : "auto");
+169
View File
@@ -390,6 +390,175 @@ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
#endif #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) { static int hexval(int c) {
if (c >= '0' && c <= '9') if (c >= '0' && c <= '9')
return c - '0'; return c - '0';
+90 -23
View File
@@ -255,28 +255,78 @@ static cJSON *image_part(const cst_llm_request_t *req,
return p; return p;
} }
/* Append every attachment of m as image parts (or a note saying why not). */ /* One stored document as a text part: its text, framed so the model knows
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts, * where it came from. Documents are plain text on the wire, so a text-only
const cst_message_t *m, size_t *budget) { * 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; int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) { if (n > CST_ATTACH_MAX_COUNT) {
char note[160]; char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT, snprintf(note, sizeof(note),
n); "[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note)); parts_add_text(parts, cJSON_CreateString(note));
n = CST_ATTACH_MAX_COUNT; n = CST_ATTACH_MAX_COUNT;
} }
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i]; const cst_attachment_t *a = &m->attach[i];
int is_img = cst_attach_is_image(a);
char why[256]; 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) { if (p) {
cJSON_AddItemToArray(parts, p); cJSON_AddItemToArray(parts, p);
continue; continue;
} }
char note[512]; char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]", snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
a->name, why); is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note)); 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 /* 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 * answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */ * 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, static void defer_attachments(const cst_llm_request_t *req,
cJSON **pending, size_t *budget) { const cst_message_t *m, cJSON **pending,
size_t *budget, size_t *doc_budget,
const char *img_off) {
cJSON *p = *pending; cJSON *p = *pending;
if (!p) { if (!p) {
p = cJSON_CreateObject(); 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"); cJSON_AddArrayToObject(p, "content");
*pending = p; *pending = p;
} }
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget); 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) { static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0}; cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : ""); cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) { for (int i = 0; i < m->nattach; i++) {
if (!cst_attach_is_image(&m->attach[i]))
continue;
char d[128]; char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d)); cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]", 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 *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */ cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL; 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 *noimg = getenv("CST_NO_IMAGES");
const char *img_off = const char *img_off =
noimg && noimg[0] && noimg[0] != '0' 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; cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0]) if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER; 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; char *noted = NULL;
if (has_img && img_off) { 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; 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 /* 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. */ * 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 *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content") ? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL; : NULL;
if (!has_img && !cJSON_IsArray(pc)) { if (!att_parts && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0); cJSON *c = clean_string(m->content, 0);
if (prev_user) { if (prev_user) {
cst_buf_t b = {0}; 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); cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0)); parts_add_text(parts, clean_string(m->content, 0));
if (has_img) if (att_parts)
parts_add_images(req, parts, m, &img_budget); parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
free(noted); free(noted);
continue; continue;
} }
@@ -498,8 +565,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddTrueToObject(o, "prefix"); cJSON_AddTrueToObject(o, "prefix");
} }
cJSON_AddItemToArray(msgs, o); cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img) if (role == CST_ROLE_TOOL && n_att)
defer_images(req, m, &pending, &img_budget); defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
free(noted); free(noted);
} }
if (pending) if (pending)
+25 -8
View File
@@ -26,6 +26,8 @@ static void usage(const char *argv0) {
" (last used)\n" " (last used)\n"
" -m, --model NAME model (last used for the provider)\n" " -m, --model NAME model (last used for the provider)\n"
" -e, --effort LEVEL low | medium | high | max (last used)\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" " -w, --workspace DIR project directory (default: current)\n"
" -c, --continue reopen the last session (default)\n" " -c, --continue reopen the last session (default)\n"
" -N, --new start a new session\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" "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" " in the config dir (credentials, mode 0600); /key changes it.\n"
" Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n" " Fallbacks: <provider env> (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" " settings.json next to the credentials.\n"
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_PROTOCOL CST_TOOLS\n" "Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n"
" CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n" " CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n"
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n" " CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n"
" CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n" " CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n"
" CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\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" " CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
"TUI: type /help inside for commands and keys\n", "TUI: type /help inside for commands and keys\n",
argv0); argv0);
@@ -374,6 +376,7 @@ int main(int argc, char **argv) {
{"approval", required_argument, 0, 'A'}, {"approval", required_argument, 0, 'A'},
{"confirm", required_argument, 0, 'C'}, {"confirm", required_argument, 0, 'C'},
{"effort", required_argument, 0, 'e'}, {"effort", required_argument, 0, 'e'},
{"mode", required_argument, 0, 'M'},
{"help", no_argument, 0, 'h'}, {"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'}, {"version", no_argument, 0, 'V'},
{"list-models", optional_argument, 0, 0x100}, {"list-models", optional_argument, 0, 0x100},
@@ -385,8 +388,8 @@ int main(int argc, char **argv) {
{"web-open", no_argument, 0, 0x106}, {"web-open", no_argument, 0, 0x106},
{0, 0, 0, 0}}; {0, 0, 0, 0}};
int c; int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) != while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:",
-1) { opts, NULL)) != -1) {
switch (c) { switch (c) {
case 'w': case 'w':
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg); 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); snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg);
break; 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': case 'h':
usage(argv[0]); usage(argv[0]);
curl_global_cleanup(); curl_global_cleanup();
@@ -557,6 +568,12 @@ int main(int argc, char **argv) {
snprintf(cfg.effort, sizeof(cfg.effort), "%s", snprintf(cfg.effort, sizeof(cfg.effort), "%s",
cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT); 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]) { if (!cfg.api_base[0]) {
const char *env_base = getenv("CST_API_BASE"); const char *env_base = getenv("CST_API_BASE");
if (!env_base || !env_base[0]) if (!env_base || !env_base[0])
+1901
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -452,7 +452,10 @@ char *cst_session_export_md(cst_session_t *s, const char *title,
for (int k = 0; k < m->nattach; k++) { for (int k = 0; k < m->nattach; k++) {
char d[128]; char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d)); 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) if (m->nattach)
cst_buf_puts(&b, "\n"); cst_buf_puts(&b, "\n");
+28
View File
@@ -47,6 +47,8 @@ int cst_settings_load(cst_settings_t *s) {
cJSON_GetObjectItemCaseSensitive(root, "provider")); cJSON_GetObjectItemCaseSensitive(root, "provider"));
copy_str(s->effort, sizeof(s->effort), copy_str(s->effort, sizeof(s->effort),
cJSON_GetObjectItemCaseSensitive(root, "effort")); cJSON_GetObjectItemCaseSensitive(root, "effort"));
copy_str(s->mode, sizeof(s->mode),
cJSON_GetObjectItemCaseSensitive(root, "mode"));
copy_str(s->approval, sizeof(s->approval), copy_str(s->approval, sizeof(s->approval),
cJSON_GetObjectItemCaseSensitive(root, "approval")); cJSON_GetObjectItemCaseSensitive(root, "approval"));
s->show_thinking = s->show_thinking =
@@ -86,6 +88,8 @@ int cst_settings_save(const cst_settings_t *s) {
cJSON_AddStringToObject(root, "provider", s->provider); cJSON_AddStringToObject(root, "provider", s->provider);
if (s->effort[0]) if (s->effort[0])
cJSON_AddStringToObject(root, "effort", s->effort); cJSON_AddStringToObject(root, "effort", s->effort);
if (s->mode[0])
cJSON_AddStringToObject(root, "mode", s->mode);
if (s->approval[0]) if (s->approval[0])
cJSON_AddStringToObject(root, "approval", s->approval); cJSON_AddStringToObject(root, "approval", s->approval);
if (s->show_thinking) if (s->show_thinking)
@@ -135,6 +139,21 @@ int cst_approval_parse(const char *s, int *out) {
return -1; 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) { const char *cst_settings_model(const cst_settings_t *s, const char *provider) {
for (int i = 0; provider && i < s->nmodels; i++) { for (int i = 0; provider && i < s->nmodels; i++) {
if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0]) 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); cst_settings_load(&s);
snprintf(s.provider, sizeof(s.provider), "%s", cfg->provider); snprintf(s.provider, sizeof(s.provider), "%s", cfg->provider);
snprintf(s.effort, sizeof(s.effort), "%s", cfg->effort); 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; int i = 0;
while (i < s.nmodels && strcmp(s.models[i].provider, cfg->provider) != 0) while (i < s.nmodels && strcmp(s.models[i].provider, cfg->provider) != 0)
i++; i++;
@@ -176,6 +197,13 @@ int cst_settings_remember_thinking(int show_thinking) {
return cst_settings_save(&s); 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) { static void real_ws(const char *ws, char *out, size_t n) {
if (!ws || os_realpath(ws, out, n) != 0) if (!ws || os_realpath(ws, out, n) != 0)
snprintf(out, n, "%s", ws ? ws : ""); snprintf(out, n, "%s", ws ? ws : "");
+5 -5
View File
@@ -40,7 +40,7 @@ static const struct {
"runs commands as needed.", "runs commands as needed.",
"", 0}, "", 0},
{"explore", {"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 " "question (where is X, how does Y work, which files do Z). Several can "
"run at once.", "run at once.",
"You only read. Search broadly first (grep, glob, list_dir), then read " "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.", "you looked for and did not find.",
1}, 1},
{"plan", {"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.", "step-by-step implementation plan.",
"You only read. Investigate the code involved, then return a concrete " "You only read. Investigate what is involved, then return a concrete "
"plan: the files to change and what to change in each, risks and edge " "plan: what to produce or change and where, risks and edge cases, and "
"cases, and how to verify. Sketch code only where it removes ambiguity.", "how to check the result. Sketch code only where it removes ambiguity.",
1}, 1},
}; };
#define NBUILTIN ((int)(sizeof(BUILTIN) / sizeof(BUILTIN[0]))) #define NBUILTIN ((int)(sizeof(BUILTIN) / sizeof(BUILTIN[0])))
+68
View File
@@ -7,6 +7,7 @@
#include <string.h> #include <string.h>
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_clipboard.h"
#include "cst_util.h" #include "cst_util.h"
#include "tools_internal.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); 1);
return tool_set_resultf(r, 0, "user answered: %s", ans); 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;
}
+8
View File
@@ -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)); cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err));
if (!a) if (!a)
return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err); 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 <path> (a PDF becomes its text layer)",
path);
}
snprintf(a->origin, sizeof(a->origin), "view_image"); snprintf(a->origin, sizeof(a->origin), "view_image");
const char *d = tool_arg(c, "detail"); const char *d = tool_arg(c, "detail");
if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0)) if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0))
+16 -2
View File
@@ -66,8 +66,9 @@ static const cst_tool_def_t TOOLS[] = {
{"detail", S, 0, "low or high (provider hint; default auto)"}}, {"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN}, CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN},
{"run_shell", {"run_shell",
"Run a non-interactive shell command in the workspace root for builds, " "Run a non-interactive shell command in the workspace root: builds, "
"tests, git and other CLIs (stdin is empty; long runs are killed). " "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 " "Returns stdout+stderr with colors stripped and long output cut to head "
"+ tail. Prefer read_file/grep/glob/list_dir over cat/grep/find.", "+ tail. Prefer read_file/grep/glob/list_dir over cat/grep/find.",
"Run a shell command in the workspace root (non-interactive, stdin is " "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"}, {{"question", S, 1, "the question"},
{"options", S, 0, "suggested answers, one per line"}}, {"options", S, 0, "suggested answers, one per line"}},
CST_TP_ASK, 0, cst_tool_ask_user, NULL, NO_DYN}, 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]))) #define NTOOLS ((int)(sizeof(TOOLS) / sizeof(TOOLS[0])))
+7
View File
@@ -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); cst_tool_result_t *r);
int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); 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, int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); cst_tool_result_t *r);
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
+109 -20
View File
@@ -2354,6 +2354,7 @@ static const char *const TIPS[] = {
"Ctrl+P opens the command palette", "Ctrl+P opens the command palette",
"Type @ to mention a file in your message", "Type @ to mention a file in your message",
"/effort sets reasoning effort: low, medium, high, max", "/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", "/model lists the models your API key can use",
"/undo reverts the file changes of the last turn", "/undo reverts the file changes of the last turn",
"/provider switches provider; keys are saved per provider", "/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++) { for (int k = 0; k < m->nattach; k++) {
char d[128]; char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d)); 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); block_set_body(b, ub.buf ? ub.buf : "", ub.len);
cst_buf_free(&ub); 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); 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) { static void cmd_provider(tui_t *t, char *arg, int *quit) {
(void)quit; (void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); 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)" : !same ? "this run only (--api-key / CST_API_KEY)"
: strcmp(from, "saved") == 0 ? "saved (/key to replace)" : strcmp(from, "saved") == 0 ? "saved (/key to replace)"
: from); : 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_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]; char appr[64];
approval_label(t, appr, sizeof(appr)); approval_label(t, appr, sizeof(appr));
push_system_line(t, " approval %s — %s", appr, push_system_line(t, " approval %s — %s", appr,
approval_help(approval_mode(t))); 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"); cfg->plan_mode ? "Plan (read-only tools)" : "Build");
const char *notes = cst_agent_notes(t->agent); const char *notes = cst_agent_notes(t->agent);
push_system_line(t, " notes %s", 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); cst_agent_config(t->agent)->model);
} }
/* /attach: stage images for the next user message. The bytes are copied into /* /attach paste: the clipboard image if there is one, else its text goes
* the session's attachment directory now, so a picture picked from /tmp keeps * into the composer (CR dropped, control bytes kept out, INPUT_CAP respected). */
* working after the file is gone. */ 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) { static void cmd_attach(tui_t *t, char *arg, int *quit) {
(void)quit; (void)quit;
int n = cst_agent_pending_count(t->agent); int n = cst_agent_pending_count(t->agent);
if (!arg || !arg[0] || strcmp(arg, "list") == 0) { if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
if (n == 0) { if (n == 0) {
push_system_line(t, "No images picked. Usage: /attach <path>"); push_system_line(t, "Nothing attached yet. Usage: /attach <path>, "
"/attach paste, /attach list, /attach clear");
return; return;
} }
push_system_line(t, "%d image%s with the next message (sent once):", n, push_system_line(t, "%d attachment%s with the next message (sent once):",
n > 1 ? "s" : ""); n, n > 1 ? "s" : "");
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
char line[256]; char line[256];
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0) 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) { if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
cst_agent_pending_clear(t->agent); cst_agent_pending_clear(t->agent);
push_system_line(t, "Dropped the picked images."); push_system_line(t, "Dropped the attachments.");
return; return;
} }
char err[256]; char err[256];
@@ -4856,7 +4916,24 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) {
char *img = NULL; char *img = NULL;
size_t len = 0; size_t len = 0;
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 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; return;
} }
char mime[32], name[32]; char mime[32], name[32];
@@ -4881,19 +4958,29 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) {
report_attached(t, 1); 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"); FILE *f = fopen(path, "rb");
if (!f) if (!f)
return 0; return 0;
unsigned char head[16]; unsigned char head[64];
size_t got = fread(head, 1, sizeof(head), f); size_t got = fread(head, 1, sizeof(head), f);
fclose(f); fclose(f);
char mime[32]; 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 /* A paste that is nothing but file paths (a file dropped on the terminal)
* terminal) attaches those files instead of typing the paths. */ * attaches those files instead of typing the paths. */
static void paste_image_paths(tui_t *t) { static void paste_image_paths(tui_t *t) {
size_t from = t->paste_at, to = t->cur; size_t from = t->paste_at, to = t->cur;
if (to <= from || to - from != t->in_len - t->paste_len0 || 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) if (n <= 0)
return; return;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
if (!file_is_image(paths[i])) if (!file_is_attachable(paths[i]))
return; return;
input_erase(t, from, to); input_erase(t, from, to);
int added = 0; int added = 0;
@@ -5441,6 +5528,8 @@ static const slash_cmd_t SLASH[] = {
cmd_protocol}, cmd_protocol},
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0, {"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
cmd_tools}, cmd_tools},
{"mode", "", "[coding|general]", "coding or general (non-code) framing", 1,
cmd_mode},
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1, {"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
cmd_permissions}, cmd_permissions},
{"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1, {"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, {"export", "", "[file]", "write the transcript as Markdown", 1,
cmd_export}, cmd_export},
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy}, {"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
{"attach", "image", "[path|paste|clear]", {"attach", "image|file", "[path|paste|clear]",
"send image(s) with the next message", "send files (image, PDF, text) or clipboard text with the next message",
1, cmd_attach}, 1, cmd_attach},
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init}, {"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
{"details", "", "", "expand/collapse all tool output", 1, cmd_details}, {"details", "", "", "expand/collapse all tool output", 1, cmd_details},
+16
View File
@@ -263,6 +263,7 @@ static cJSON *wui_state_new(wui_t *w) {
cJSON_AddStringToObject(o, "provider", cfg->provider); cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model); cJSON_AddStringToObject(o, "model", cfg->model);
cJSON_AddStringToObject(o, "effort", cfg->effort); 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, "approval", cst_approval_name(cfg->approval));
cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol)); cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol));
cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode); 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"); cJSON *pr = cJSON_AddArrayToObject(o, "profiles");
for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++) for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++)
cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(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"); cJSON *cf = cJSON_AddObjectToObject(o, "confirm");
if (cf) { if (cf) {
cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell); 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 *proto = cJSON_GetObjectItem(j, "protocol");
const cJSON *steps = cJSON_GetObjectItem(j, "max_steps"); const cJSON *steps = cJSON_GetObjectItem(j, "max_steps");
const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile"); const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile");
const cJSON *mode = cJSON_GetObjectItem(j, "mode");
const cJSON *tools = cJSON_GetObjectItem(j, "tools"); const cJSON *tools = cJSON_GetObjectItem(j, "tools");
const cJSON *conf = cJSON_GetObjectItem(j, "confirm"); const cJSON *conf = cJSON_GetObjectItem(j, "confirm");
cst_protocol_t p = cfg->protocol; 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) || else if (prof && (!cJSON_IsString(prof) ||
cst_tool_profile_parse(prof->valuestring, &profile) != 0)) cst_tool_profile_parse(prof->valuestring, &profile) != 0))
snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)"); 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)) else if (tools && !cJSON_IsObject(tools))
snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}"); snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}");
else if (conf && !cJSON_IsObject(conf)) 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->protocol = p;
cfg->tool_profile = profile; 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) if (steps)
cfg->max_steps = (int)steps->valuedouble; cfg->max_steps = (int)steps->valuedouble;
for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next) for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next)
+100
View File
@@ -325,6 +325,100 @@ static void test_import_file(const char *root) {
"missing file refused"); "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) { static void test_json_roundtrip(void) {
cst_attachment_t list[3]; cst_attachment_t list[3];
memset(list, 0, sizeof(list)); memset(list, 0, sizeof(list));
@@ -344,6 +438,8 @@ static void test_json_roundtrip(void) {
list[1].bytes = 4096; list[1].bytes = 4096;
snprintf(list[1].detail, sizeof(list[1].detail), "high"); snprintf(list[1].detail, sizeof(list[1].detail), "high");
snprintf(list[1].origin, sizeof(list[1].origin), "view_image"); 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].id, sizeof(list[2].id), "img-3");
snprintf(list[2].name, sizeof(list[2].name), "c.webp"); 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 && EXPECT(strcmp(back[1].detail, "high") == 0 &&
strcmp(back[1].origin, "view_image") == 0, strcmp(back[1].origin, "view_image") == 0,
"detail and origin survive"); "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, EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
"entry without dimensions"); "entry without dimensions");
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults"); EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
@@ -530,6 +629,7 @@ int main(void) {
test_dib(); test_dib();
test_store(root); test_store(root);
test_import_file(root); test_import_file(root);
test_documents(root);
test_json_roundtrip(); test_json_roundtrip();
test_lists(); test_lists();
test_agent_pending(root); test_agent_pending(root);
+21
View File
@@ -106,11 +106,13 @@ static void test_settings(void) {
snprintf(cfg.provider, sizeof(cfg.provider), "openai"); snprintf(cfg.provider, sizeof(cfg.provider), "openai");
snprintf(cfg.model, sizeof(cfg.model), "gpt-5"); snprintf(cfg.model, sizeof(cfg.model), "gpt-5");
snprintf(cfg.effort, sizeof(cfg.effort), "max"); snprintf(cfg.effort, sizeof(cfg.effort), "max");
snprintf(cfg.mode, sizeof(cfg.mode), "general");
CHECK(cst_settings_remember(&cfg) == 0); CHECK(cst_settings_remember(&cfg) == 0);
CHECK(cst_settings_load(&s) == 0); CHECK(cst_settings_load(&s) == 0);
CHECK(strcmp(s.provider, "openai") == 0); CHECK(strcmp(s.provider, "openai") == 0);
CHECK(strcmp(s.effort, "max") == 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, "deepseek"), "deepseek-v4-pro") == 0);
CHECK(strcmp(cst_settings_model(&s, "openai"), "gpt-5") == 0); CHECK(strcmp(cst_settings_model(&s, "openai"), "gpt-5") == 0);
CHECK(s.nmodels == 2); CHECK(s.nmodels == 2);
@@ -145,6 +147,24 @@ static void test_effort(void) {
free(body); 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) { int main(void) {
CHECK(test_tmpdir(g_dir, sizeof(g_dir), "cst-config") == 0); CHECK(test_tmpdir(g_dir, sizeof(g_dir), "cst-config") == 0);
setenv("XDG_CONFIG_HOME", g_dir, 1); setenv("XDG_CONFIG_HOME", g_dir, 1);
@@ -158,6 +178,7 @@ int main(void) {
test_creds(); test_creds();
test_settings(); test_settings();
test_effort(); test_effort();
test_mode();
CHECK(test_rmrf(g_dir) == 0); CHECK(test_rmrf(g_dir) == 0);
printf("test_config: OK\n"); printf("test_config: OK\n");
+109
View File
@@ -406,9 +406,118 @@ static void test_images_on(void) {
cst_attach_free(img); 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) { int main(void) {
test_images_off(); test_images_off();
test_images_on(); test_images_on();
test_document_parts();
test_reasoning_and_prefix(); test_reasoning_and_prefix();
test_cache_hints(); test_cache_hints();
test_tools_snapshot(); test_tools_snapshot();
+250
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
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> <FFFF>\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;
}
+2
View File
@@ -1717,6 +1717,7 @@ async function showRunSettings() {
const proto = field('工具协议', select(s.protocols || [], s.protocol)); 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 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 prof = field('工具集', select(s.profiles || [], s.tool_profile));
const mode = field('工作模式', select(s.modes || [], s.mode));
box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')')); box.appendChild(el('h4', 'sub', '需要确认(审批模式 ' + (s.approval || '-') + ')'));
const conf = {}; const conf = {};
@@ -1786,6 +1787,7 @@ async function showRunSettings() {
if (proto.value !== s.protocol) body.protocol = proto.value; if (proto.value !== s.protocol) body.protocol = proto.value;
if (+steps.value !== s.max_steps) body.max_steps = +steps.value; if (+steps.value !== s.max_steps) body.max_steps = +steps.value;
if (prof.value !== s.tool_profile) body.tool_profile = prof.value; if (prof.value !== s.tool_profile) body.tool_profile = prof.value;
if (mode.value !== s.mode) body.mode = mode.value;
const c = {}; const c = {};
for (const k of Object.keys(conf)) if (conf[k].checked !== !!(s.confirm || {})[k]) c[k] = conf[k].checked; 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; if (Object.keys(c).length) body.confirm = c;